Repository: moqui/moqui-framework Branch: master Commit: d12a86e1ac73 Files: 336 Total size: 4.8 MB Directory structure: gitextract_s15y4lew/ ├── .github/ │ └── workflows/ │ └── gradle-wrapper-validation.yml ├── .gitignore ├── .travis.yml ├── .whitesource ├── AUTHORS ├── LICENSE.md ├── MoquiInit.properties ├── Procfile ├── Procfile.README ├── README.md ├── ReleaseNotes.md ├── SECURITY.md ├── addons.xml ├── build.gradle ├── build.xml ├── docker/ │ ├── README.md │ ├── build-compose-up.sh │ ├── clean.sh │ ├── compose-down.sh │ ├── compose-up.sh │ ├── elasticsearch/ │ │ ├── data/ │ │ │ └── README │ │ └── moquiconfig/ │ │ ├── elasticsearch.yml │ │ └── log4j2.properties │ ├── kibana/ │ │ └── kibana.yml │ ├── moqui-acme-postgres.yml │ ├── moqui-cluster1-compose.yml │ ├── moqui-mysql-compose.yml │ ├── moqui-postgres-compose.yml │ ├── moqui-run.sh │ ├── mysql-compose.yml │ ├── nginx/ │ │ └── my_proxy.conf │ ├── opensearch/ │ │ └── data/ │ │ └── nodes/ │ │ └── README │ ├── postgres-compose.yml │ ├── postgres_backup.sh │ └── simple/ │ ├── Dockerfile │ └── docker-build.sh ├── framework/ │ ├── build.gradle │ ├── data/ │ │ ├── CommonL10nData.xml │ │ ├── CurrencyData.xml │ │ ├── EntityTypeData.xml │ │ ├── GeoCountryData.xml │ │ ├── MoquiSetupData.xml │ │ ├── SecurityTypeData.xml │ │ └── UnitData.xml │ ├── entity/ │ │ ├── BasicEntities.xml │ │ ├── EntityEntities.xml │ │ ├── OlapEntities.xml │ │ ├── ResourceEntities.xml │ │ ├── Screen.eecas.xml │ │ ├── ScreenEntities.xml │ │ ├── SecurityEntities.xml │ │ ├── ServerEntities.xml │ │ ├── ServiceEntities.xml │ │ └── TestEntities.xml │ ├── lib/ │ │ └── btm-4.0.1.jar │ ├── screen/ │ │ ├── AddedEmailAuthcFactor.xml │ │ ├── EmailAuthcFactorSent.xml │ │ ├── NotificationEmail.xml │ │ ├── PasswordReset.xml │ │ ├── ScreenRenderEmail.xml │ │ └── SingleUseCode.xml │ ├── service/ │ │ └── org/ │ │ └── moqui/ │ │ ├── EmailServices.xml │ │ ├── EntityServices.xml │ │ ├── SmsServices.xml │ │ ├── impl/ │ │ │ ├── BasicServices.xml │ │ │ ├── ElFinderServices.xml │ │ │ ├── EmailServices.xml │ │ │ ├── EntityServices.xml │ │ │ ├── EntitySyncServices.xml │ │ │ ├── GoogleServices.xml │ │ │ ├── InstanceServices.xml │ │ │ ├── PrintServices.xml │ │ │ ├── ScreenServices.xml │ │ │ ├── ServerServices.xml │ │ │ ├── ServiceServices.xml │ │ │ ├── SystemMessageServices.xml │ │ │ ├── UserServices.xml │ │ │ └── WikiServices.xml │ │ └── search/ │ │ ├── ElasticSearchServices.xml │ │ └── SearchServices.xml │ ├── src/ │ │ ├── main/ │ │ │ ├── groovy/ │ │ │ │ └── org/ │ │ │ │ └── moqui/ │ │ │ │ └── impl/ │ │ │ │ ├── actions/ │ │ │ │ │ └── XmlAction.java │ │ │ │ ├── context/ │ │ │ │ │ ├── ArtifactExecutionFacadeImpl.groovy │ │ │ │ │ ├── ArtifactExecutionInfoImpl.java │ │ │ │ │ ├── CacheFacadeImpl.groovy │ │ │ │ │ ├── ContextJavaUtil.java │ │ │ │ │ ├── ElasticFacadeImpl.groovy │ │ │ │ │ ├── ExecutionContextFactoryImpl.groovy │ │ │ │ │ ├── ExecutionContextImpl.java │ │ │ │ │ ├── L10nFacadeImpl.java │ │ │ │ │ ├── LoggerFacadeImpl.groovy │ │ │ │ │ ├── MessageFacadeImpl.groovy │ │ │ │ │ ├── NotificationMessageImpl.groovy │ │ │ │ │ ├── ResourceFacadeImpl.groovy │ │ │ │ │ ├── TransactionCache.groovy │ │ │ │ │ ├── TransactionFacadeImpl.groovy │ │ │ │ │ ├── TransactionInternalBitronix.groovy │ │ │ │ │ ├── UserFacadeImpl.groovy │ │ │ │ │ ├── WebFacadeImpl.groovy │ │ │ │ │ ├── reference/ │ │ │ │ │ │ ├── BaseResourceReference.java │ │ │ │ │ │ ├── ComponentResourceReference.java │ │ │ │ │ │ ├── ContentResourceReference.groovy │ │ │ │ │ │ ├── DbResourceReference.groovy │ │ │ │ │ │ └── WrapperResourceReference.groovy │ │ │ │ │ ├── renderer/ │ │ │ │ │ │ ├── FtlMarkdownTemplateRenderer.groovy │ │ │ │ │ │ ├── FtlTemplateRenderer.java │ │ │ │ │ │ ├── GStringTemplateRenderer.groovy │ │ │ │ │ │ ├── MarkdownTemplateRenderer.groovy │ │ │ │ │ │ └── NoTemplateRenderer.groovy │ │ │ │ │ └── runner/ │ │ │ │ │ ├── GroovyScriptRunner.groovy │ │ │ │ │ ├── JavaxScriptRunner.groovy │ │ │ │ │ └── XmlActionsScriptRunner.groovy │ │ │ │ ├── entity/ │ │ │ │ │ ├── AggregationUtil.java │ │ │ │ │ ├── EntityCache.groovy │ │ │ │ │ ├── EntityConditionFactoryImpl.groovy │ │ │ │ │ ├── EntityDataDocument.groovy │ │ │ │ │ ├── EntityDataFeed.groovy │ │ │ │ │ ├── EntityDataLoaderImpl.groovy │ │ │ │ │ ├── EntityDataWriterImpl.groovy │ │ │ │ │ ├── EntityDatasourceFactoryImpl.groovy │ │ │ │ │ ├── EntityDbMeta.groovy │ │ │ │ │ ├── EntityDefinition.groovy │ │ │ │ │ ├── EntityDynamicViewImpl.groovy │ │ │ │ │ ├── EntityEcaRule.groovy │ │ │ │ │ ├── EntityFacadeImpl.groovy │ │ │ │ │ ├── EntityFindBase.groovy │ │ │ │ │ ├── EntityFindBuilder.java │ │ │ │ │ ├── EntityFindImpl.java │ │ │ │ │ ├── EntityJavaUtil.java │ │ │ │ │ ├── EntityListImpl.java │ │ │ │ │ ├── EntityListIteratorImpl.java │ │ │ │ │ ├── EntityListIteratorWrapper.java │ │ │ │ │ ├── EntityQueryBuilder.java │ │ │ │ │ ├── EntitySqlException.groovy │ │ │ │ │ ├── EntityValueBase.java │ │ │ │ │ ├── EntityValueImpl.java │ │ │ │ │ ├── FieldInfo.java │ │ │ │ │ ├── condition/ │ │ │ │ │ │ ├── BasicJoinCondition.java │ │ │ │ │ │ ├── ConditionAlias.java │ │ │ │ │ │ ├── ConditionField.java │ │ │ │ │ │ ├── DateCondition.groovy │ │ │ │ │ │ ├── EntityConditionImplBase.java │ │ │ │ │ │ ├── FieldToFieldCondition.groovy │ │ │ │ │ │ ├── FieldValueCondition.java │ │ │ │ │ │ ├── ListCondition.java │ │ │ │ │ │ ├── TrueCondition.java │ │ │ │ │ │ └── WhereCondition.groovy │ │ │ │ │ └── elastic/ │ │ │ │ │ ├── ElasticDatasourceFactory.groovy │ │ │ │ │ ├── ElasticEntityFind.java │ │ │ │ │ ├── ElasticEntityListIterator.java │ │ │ │ │ ├── ElasticEntityValue.java │ │ │ │ │ └── ElasticSynchronization.groovy │ │ │ │ ├── screen/ │ │ │ │ │ ├── ScreenDefinition.groovy │ │ │ │ │ ├── ScreenFacadeImpl.groovy │ │ │ │ │ ├── ScreenForm.groovy │ │ │ │ │ ├── ScreenRenderImpl.groovy │ │ │ │ │ ├── ScreenSection.groovy │ │ │ │ │ ├── ScreenTestImpl.groovy │ │ │ │ │ ├── ScreenTree.groovy │ │ │ │ │ ├── ScreenUrlInfo.groovy │ │ │ │ │ ├── ScreenWidgetRender.java │ │ │ │ │ ├── ScreenWidgetRenderFtl.groovy │ │ │ │ │ ├── ScreenWidgets.groovy │ │ │ │ │ └── WebFacadeStub.groovy │ │ │ │ ├── service/ │ │ │ │ │ ├── EmailEcaRule.groovy │ │ │ │ │ ├── ParameterInfo.java │ │ │ │ │ ├── RestApi.groovy │ │ │ │ │ ├── ScheduledJobRunner.groovy │ │ │ │ │ ├── ServiceCallAsyncImpl.groovy │ │ │ │ │ ├── ServiceCallImpl.java │ │ │ │ │ ├── ServiceCallJobImpl.groovy │ │ │ │ │ ├── ServiceCallSpecialImpl.groovy │ │ │ │ │ ├── ServiceCallSyncImpl.java │ │ │ │ │ ├── ServiceDefinition.java │ │ │ │ │ ├── ServiceEcaRule.groovy │ │ │ │ │ ├── ServiceFacadeImpl.groovy │ │ │ │ │ ├── ServiceJsonRpcDispatcher.groovy │ │ │ │ │ ├── ServiceRunner.groovy │ │ │ │ │ └── runner/ │ │ │ │ │ ├── EntityAutoServiceRunner.groovy │ │ │ │ │ ├── InlineServiceRunner.java │ │ │ │ │ ├── JavaServiceRunner.groovy │ │ │ │ │ ├── RemoteJsonRpcServiceRunner.groovy │ │ │ │ │ ├── RemoteRestServiceRunner.groovy │ │ │ │ │ └── ScriptServiceRunner.java │ │ │ │ ├── tools/ │ │ │ │ │ ├── H2ServerToolFactory.groovy │ │ │ │ │ ├── JCSCacheToolFactory.groovy │ │ │ │ │ ├── JackrabbitRunToolFactory.groovy │ │ │ │ │ ├── MCacheToolFactory.java │ │ │ │ │ └── SubEthaSmtpToolFactory.groovy │ │ │ │ ├── util/ │ │ │ │ │ ├── EdiHandler.groovy │ │ │ │ │ ├── ElFinderConnector.groovy │ │ │ │ │ ├── ElasticSearchLogger.groovy │ │ │ │ │ ├── JdbcExtractor.java │ │ │ │ │ ├── MoquiShiroRealm.groovy │ │ │ │ │ ├── RestSchemaUtil.groovy │ │ │ │ │ ├── SimpleSgmlReader.groovy │ │ │ │ │ └── SimpleSigner.java │ │ │ │ └── webapp/ │ │ │ │ ├── ElasticRequestLogFilter.groovy │ │ │ │ ├── GroovyShellEndpoint.groovy │ │ │ │ ├── MoquiAbstractEndpoint.groovy │ │ │ │ ├── MoquiAuthFilter.groovy │ │ │ │ ├── MoquiContextListener.groovy │ │ │ │ ├── MoquiFopServlet.groovy │ │ │ │ ├── MoquiServlet.groovy │ │ │ │ ├── MoquiSessionListener.groovy │ │ │ │ ├── NotificationEndpoint.groovy │ │ │ │ ├── NotificationWebSocketListener.groovy │ │ │ │ └── ScreenResourceNotFoundException.groovy │ │ │ ├── java/ │ │ │ │ └── org/ │ │ │ │ └── moqui/ │ │ │ │ ├── BaseArtifactException.java │ │ │ │ ├── BaseException.java │ │ │ │ ├── Moqui.java │ │ │ │ ├── context/ │ │ │ │ │ ├── ArtifactAuthorizationException.java │ │ │ │ │ ├── ArtifactExecutionFacade.java │ │ │ │ │ ├── ArtifactExecutionInfo.java │ │ │ │ │ ├── ArtifactTarpitException.java │ │ │ │ │ ├── AuthenticationRequiredException.java │ │ │ │ │ ├── CacheFacade.java │ │ │ │ │ ├── ElasticFacade.java │ │ │ │ │ ├── ExecutionContext.java │ │ │ │ │ ├── ExecutionContextFactory.java │ │ │ │ │ ├── L10nFacade.java │ │ │ │ │ ├── LogEventSubscriber.java │ │ │ │ │ ├── LoggerFacade.java │ │ │ │ │ ├── MessageFacade.java │ │ │ │ │ ├── MessageFacadeException.java │ │ │ │ │ ├── MoquiLog4jAppender.java │ │ │ │ │ ├── NotificationMessage.java │ │ │ │ │ ├── NotificationMessageListener.java │ │ │ │ │ ├── PasswordChangeRequiredException.java │ │ │ │ │ ├── ResourceFacade.java │ │ │ │ │ ├── ScriptRunner.java │ │ │ │ │ ├── SecondFactorRequiredException.java │ │ │ │ │ ├── TemplateRenderer.java │ │ │ │ │ ├── ToolFactory.java │ │ │ │ │ ├── TransactionException.java │ │ │ │ │ ├── TransactionFacade.java │ │ │ │ │ ├── TransactionInternal.java │ │ │ │ │ ├── UserFacade.java │ │ │ │ │ ├── ValidationError.java │ │ │ │ │ ├── WebFacade.java │ │ │ │ │ └── WebMediaTypeException.java │ │ │ │ ├── entity/ │ │ │ │ │ ├── EntityCondition.java │ │ │ │ │ ├── EntityConditionFactory.java │ │ │ │ │ ├── EntityDataLoader.java │ │ │ │ │ ├── EntityDataWriter.java │ │ │ │ │ ├── EntityDatasourceFactory.java │ │ │ │ │ ├── EntityDynamicView.java │ │ │ │ │ ├── EntityException.java │ │ │ │ │ ├── EntityFacade.java │ │ │ │ │ ├── EntityFind.java │ │ │ │ │ ├── EntityList.java │ │ │ │ │ ├── EntityListIterator.java │ │ │ │ │ ├── EntityNotFoundException.java │ │ │ │ │ ├── EntityValue.java │ │ │ │ │ └── EntityValueNotFoundException.java │ │ │ │ ├── etl/ │ │ │ │ │ ├── FlatXmlExtractor.java │ │ │ │ │ └── SimpleEtl.java │ │ │ │ ├── jcache/ │ │ │ │ │ ├── MCache.java │ │ │ │ │ ├── MCacheConfiguration.java │ │ │ │ │ ├── MCacheManager.java │ │ │ │ │ ├── MEntry.java │ │ │ │ │ └── MStats.java │ │ │ │ ├── resource/ │ │ │ │ │ ├── ClasspathResourceReference.java │ │ │ │ │ ├── ResourceReference.java │ │ │ │ │ └── UrlResourceReference.java │ │ │ │ ├── screen/ │ │ │ │ │ ├── ScreenFacade.java │ │ │ │ │ ├── ScreenRender.java │ │ │ │ │ └── ScreenTest.java │ │ │ │ ├── service/ │ │ │ │ │ ├── ServiceCall.java │ │ │ │ │ ├── ServiceCallAsync.java │ │ │ │ │ ├── ServiceCallJob.java │ │ │ │ │ ├── ServiceCallSpecial.java │ │ │ │ │ ├── ServiceCallSync.java │ │ │ │ │ ├── ServiceCallback.java │ │ │ │ │ ├── ServiceException.java │ │ │ │ │ └── ServiceFacade.java │ │ │ │ └── util/ │ │ │ │ ├── CollectionUtilities.java │ │ │ │ ├── ContextBinding.java │ │ │ │ ├── ContextStack.java │ │ │ │ ├── LiteStringMap.java │ │ │ │ ├── MClassLoader.java │ │ │ │ ├── MNode.java │ │ │ │ ├── ObjectUtilities.java │ │ │ │ ├── RestClient.java │ │ │ │ ├── SimpleTopic.java │ │ │ │ ├── StringUtilities.java │ │ │ │ ├── SystemBinding.java │ │ │ │ └── WebUtilities.java │ │ │ ├── resources/ │ │ │ │ ├── META-INF/ │ │ │ │ │ ├── jakarta.mime.types │ │ │ │ │ └── services/ │ │ │ │ │ └── org.moqui.context.ExecutionContextFactory │ │ │ │ ├── MoquiDefaultConf.xml │ │ │ │ ├── bitronix-default-config.properties │ │ │ │ ├── cache.ccf │ │ │ │ ├── log4j2.xml │ │ │ │ ├── org/ │ │ │ │ │ └── moqui/ │ │ │ │ │ └── impl/ │ │ │ │ │ ├── pollEmailServer.groovy │ │ │ │ │ ├── sendEmailMessage.groovy │ │ │ │ │ └── sendEmailTemplate.groovy │ │ │ │ └── shiro.ini │ │ │ └── webapp/ │ │ │ └── WEB-INF/ │ │ │ └── web.xml │ │ ├── start/ │ │ │ └── java/ │ │ │ └── MoquiStart.java │ │ └── test/ │ │ └── groovy/ │ │ ├── CacheFacadeTests.groovy │ │ ├── ConcurrentExecution.groovy │ │ ├── EntityCrud.groovy │ │ ├── EntityFindTests.groovy │ │ ├── EntityNoSqlCrud.groovy │ │ ├── L10nFacadeTests.groovy │ │ ├── MessageFacadeTests.groovy │ │ ├── MoquiSuite.groovy │ │ ├── ResourceFacadeTests.groovy │ │ ├── ServiceCrudImplicit.groovy │ │ ├── ServiceFacadeTests.groovy │ │ ├── SubSelectTests.groovy │ │ ├── SystemScreenRenderTests.groovy │ │ ├── TimezoneTest.groovy │ │ ├── ToolsRestApiTests.groovy │ │ ├── ToolsScreenRenderTests.groovy │ │ ├── TransactionFacadeTests.groovy │ │ └── UserFacadeTests.groovy │ ├── template/ │ │ └── XmlActions.groovy.ftl │ └── xsd/ │ ├── common-types-3.xsd │ ├── email-eca-3.xsd │ ├── entity-definition-3.xsd │ ├── entity-eca-3.xsd │ ├── framework-catalog.xml │ ├── moqui-conf-3.xsd │ ├── rest-api-3.xsd │ ├── service-definition-3.xsd │ ├── service-eca-3.xsd │ ├── xml-actions-3.xsd │ ├── xml-form-3.xsd │ └── xml-screen-3.xsd ├── gradle/ │ └── wrapper/ │ ├── gradle-wrapper.jar │ └── gradle-wrapper.properties ├── gradle.properties ├── gradlew ├── gradlew.bat └── settings.gradle ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/gradle-wrapper-validation.yml ================================================ name: "Validate Gradle Wrapper" on: [push, pull_request] jobs: validation: name: "Validation" runs-on: ubuntu-latest steps: - uses: actions/checkout@v2 - uses: gradle/wrapper-validation-action@v1 ================================================ FILE: .gitignore ================================================ # gradle/build files build .gradle /framework/dependencies # build generated files /moqui*.war /Save*.zip # runtime directory (separate repository so ignore directory entirely) /runtime /execwartmp /docker/runtime /docker/db /docker/elasticsearch/data/nodes /docker/opensearch/data/nodes/* /docker/opensearch/data/*.conf !/docker/opensearch/data/nodes/README /docker/acme.sh /docker/nginx/conf.d /docker/nginx/vhost.d /docker/nginx/html ## Do not want to accidentally commit production certificates https://www.theregister.com/2024/07/25/data_from_deleted_github_repos/ /docker/certs !/docker/certs/moqui1.local.* !/docker/certs/moqui2.local.* !/docker/certs/moqui.local.* !/docker/certs/README # IntelliJ IDEA files .idea out *.ipr *.iws *.iml # Eclipse files (and some general ones also used by Eclipse) .metadata bin tmp *.tmp *.bak *.swp *~.nib local.properties .settings .loadpath # NetBeans files nbproject/private nbbuild .nb-gradle nbdist nbactions.xml nb-configuration.xml # VSCode files .vscode # Emacs files .projectile # Version managers (sdkman, mise, asdf) mise.toml .tool-versions # OSX auto files .DS_Store .AppleDouble .LSOverride ._* # Windows auto files Thumbs.db ehthumbs.db Desktop.ini # Linux auto files *~ ================================================ FILE: .travis.yml ================================================ language: groovy jdk: - openjdk11 install: true env: - TERM=dumb script: - ./gradlew getRuntime - ./gradlew load - ./gradlew test --info cache: directories: - $HOME/.gradle/caches - $HOME/.gradle/wrapper ================================================ FILE: .whitesource ================================================ { "generalSettings": { "shouldScanRepo": true }, "checkRunSettings": { "vulnerableCheckRunConclusionLevel": "failure" }, "issueSettings": { "minSeverityLevel": "LOW" } } ================================================ FILE: AUTHORS ================================================ Moqui Framework (http://github.com/moqui/moqui) This software is in the public domain under CC0 1.0 Universal plus a Grant of Patent License. To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. This software is distributed without any warranty. You should have received a copy of the CC0 Public Domain Dedication along with this software (see the LICENSE.md file). If not, see . =========================================================================== Copyright Waiver I dedicate any and all copyright interest in this software to the public domain. I make this dedication for the benefit of the public at large and to the detriment of my heirs and successors. I intend this dedication to be an overt act of relinquishment in perpetuity of all present and future rights to this software under copyright law. To the best of my knowledge and belief, my contributions are either originally authored by me or are derived from prior works which I have verified are also in the public domain and are not subject to claims of copyright by other parties. To the best of my knowledge and belief, no individual, business, organization, government, or other entity has any copyright interest in my contributions, and I affirm that I will not make contributions that are otherwise encumbered. Signed by git commit adding my legal name and git username: Written in 2010-2022 by David E. Jones - jonesde Written in 2021-2026 by D. Michael Jones - acetousk Written in 2014-2015 by Solomon Bessire - sbessire Written in 2014-2015 by Jacopo Cappellato - jacopoc Written in 2014-2015 by Abdullah Shaikh - abdullahs Written in 2014-2015 by Yao Chunlin - chunlinyao Written in 2014-2015 by Jimmy Shen - shendepu Written in 2014-2015 by Dony Zulkarnaen - donniexyz Written in 2015 by Sam Hamilton - samhamilton Written in 2015 by Leonardo Carvalho - CarvalhoLeonardo Written in 2015 by Swapnil M Mane - swapnilmmane Written in 2015 by Anton Akhiar - akhiar Written in 2015-2023 by Jens Hardings - jenshp Written in 2016 by Shifeng Zhang - zhangshifeng Written in 2016 by Scott Gray - lektran Written in 2016 by Mark Haney - mphaney Written in 2016 by Qiushi Yan - yanqiushi Written in 2017 by Oleg Andrieiev - oandreyev Written in 2018 by Zhang Wei - zhangwei1979 Written in 2018 by Nirendra Singh - nirendra10695 Written in 2018-2023 by Ayman Abi Abdallah - aabiabdallah Written in 2019 by Daniel Taylor - danieltaylor-nz Written in 2020 by Jacob Barnes - Tellan Written in 2020 by Amir Anjomshoaa - amiranjom Written in 2021 by Deepak Dixit - dixitdeepak Written in 2021 by Taher Alkhateeb - pythys Written in 2022 by Zhang Wei - hellozhangwei Written in 2023 by Rohit Pawar - rohitpawar2811 =========================================================================== Grant of Patent License I hereby grant to recipients of software 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 me that are necessarily infringed by my Contribution(s) alone or by combination of my Contribution(s) with the Work to which such Contribution(s) was submitted. If any entity institutes patent litigation against me or any other entity (including a cross-claim or counterclaim in a lawsuit) alleging that my Contribution, or the Work to which I have contributed, constitutes direct or contributory patent infringement, then any patent licenses granted to that entity under this Agreement for that Contribution or Work shall terminate as of the date such litigation is filed. Signed by git commit adding my legal name and git username: Written in 2010-2022 by David E. Jones - jonesde Written in 2021-2021 by D. Michael Jones - acetousk Written in 2014-2015 by Solomon Bessire - sbessire Written in 2014-2015 by Jacopo Cappellato - jacopoc Written in 2014-2015 by Yao Chunlin - chunlinyao Written in 2015 by Dony Zulkarnaen - donniexyz Written in 2015 by Swapnil M Mane - swapnilmmane Written in 2015 by Jimmy Shen - shendepu Written in 2015-2016 by Sam Hamilton - samhamilton Written in 2015 by Leonardo Carvalho - CarvalhoLeonardo Written in 2015 by Anton Akhiar - akhiar Written in 2015-2023 by Jens Hardings - jenshp Written in 2016 by Shifeng Zhang - zhangshifeng Written in 2016 by Scott Gray - lektran Written in 2016 by Mark Haney - mphaney Written in 2016 by Qiushi Yan - yanqiushi Written in 2017 by Oleg Andrieiev - oandreyev Written in 2018 by Zhang Wei - zhangwei1979 Written in 2018 by Nirendra Singh - nirendra10695 Written in 2018-2023 by Ayman Abi Abdallah - aabiabdallah Written in 2019 by Daniel Taylor - danieltaylor-nz Written in 2020 by Jacob Barnes - Tellan Written in 2020 by Amir Anjomshoaa - amiranjom Written in 2021 by Deepak Dixit - dixitdeepak Written in 2021 by Taher Alkhateeb - pythys Written in 2022 by Zhang Wei - hellozhangwei Written in 2023 by Rohit Pawar - rohitpawar2811 =========================================================================== ================================================ FILE: LICENSE.md ================================================ Because of a lack of patent licensing in CC0 1.0 this software includes a separate Grant of Patent License adapted from Apache License 2.0. =========================================================================== Creative Commons Legal Code CC0 1.0 Universal CREATIVE COMMONS CORPORATION IS NOT A LAW FIRM AND DOES NOT PROVIDE LEGAL SERVICES. DISTRIBUTION OF THIS DOCUMENT DOES NOT CREATE AN ATTORNEY-CLIENT RELATIONSHIP. CREATIVE COMMONS PROVIDES THIS INFORMATION ON AN "AS-IS" BASIS. CREATIVE COMMONS MAKES NO WARRANTIES REGARDING THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER, AND DISCLAIMS LIABILITY FOR DAMAGES RESULTING FROM THE USE OF THIS DOCUMENT OR THE INFORMATION OR WORKS PROVIDED HEREUNDER. Statement of Purpose The laws of most jurisdictions throughout the world automatically confer exclusive Copyright and Related Rights (defined below) upon the creator and subsequent owner(s) (each and all, an "owner") of an original work of authorship and/or a database (each, a "Work"). Certain owners wish to permanently relinquish those rights to a Work for the purpose of contributing to a commons of creative, cultural and scientific works ("Commons") that the public can reliably and without fear of later claims of infringement build upon, modify, incorporate in other works, reuse and redistribute as freely as possible in any form whatsoever and for any purposes, including without limitation commercial purposes. These owners may contribute to the Commons to promote the ideal of a free culture and the further production of creative, cultural and scientific works, or to gain reputation or greater distribution for their Work in part through the use and efforts of others. For these and/or other purposes and motivations, and without any expectation of additional consideration or compensation, the person associating CC0 with a Work (the "Affirmer"), to the extent that he or she is an owner of Copyright and Related Rights in the Work, voluntarily elects to apply CC0 to the Work and publicly distribute the Work under its terms, with knowledge of his or her Copyright and Related Rights in the Work and the meaning and intended legal effect of CC0 on those rights. 1. Copyright and Related Rights. A Work made available under CC0 may be protected by copyright and related or neighboring rights ("Copyright and Related Rights"). Copyright and Related Rights include, but are not limited to, the following: i. the right to reproduce, adapt, distribute, perform, display, communicate, and translate a Work; ii. moral rights retained by the original author(s) and/or performer(s); iii. publicity and privacy rights pertaining to a person's image or likeness depicted in a Work; iv. rights protecting against unfair competition in regards to a Work, subject to the limitations in paragraph 4(a), below; v. rights protecting the extraction, dissemination, use and reuse of data in a Work; vi. database rights (such as those arising under Directive 96/9/EC of the European Parliament and of the Council of 11 March 1996 on the legal protection of databases, and under any national implementation thereof, including any amended or successor version of such directive); and vii. other similar, equivalent or corresponding rights throughout the world based on applicable law or treaty, and any national implementations thereof. 2. Waiver. To the greatest extent permitted by, but not in contravention of, applicable law, Affirmer hereby overtly, fully, permanently, irrevocably and unconditionally waives, abandons, and surrenders all of Affirmer's Copyright and Related Rights and associated claims and causes of action, whether now known or unknown (including existing as well as future claims and causes of action), in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "Waiver"). Affirmer makes the Waiver for the benefit of each member of the public at large and to the detriment of Affirmer's heirs and successors, fully intending that such Waiver shall not be subject to revocation, rescission, cancellation, termination, or any other legal or equitable action to disrupt the quiet enjoyment of the Work by the public as contemplated by Affirmer's express Statement of Purpose. 3. Public License Fallback. Should any part of the Waiver for any reason be judged legally invalid or ineffective under applicable law, then the Waiver shall be preserved to the maximum extent permitted taking into account Affirmer's express Statement of Purpose. In addition, to the extent the Waiver is so judged Affirmer hereby grants to each affected person a royalty-free, non transferable, non sublicensable, non exclusive, irrevocable and unconditional license to exercise Affirmer's Copyright and Related Rights in the Work (i) in all territories worldwide, (ii) for the maximum duration provided by applicable law or treaty (including future time extensions), (iii) in any current or future medium and for any number of copies, and (iv) for any purpose whatsoever, including without limitation commercial, advertising or promotional purposes (the "License"). The License shall be deemed effective as of the date CC0 was applied by Affirmer to the Work. Should any part of the License for any reason be judged legally invalid or ineffective under applicable law, such partial invalidity or ineffectiveness shall not invalidate the remainder of the License, and in such case Affirmer hereby affirms that he or she will not (i) exercise any of his or her remaining Copyright and Related Rights in the Work or (ii) assert any associated claims and causes of action with respect to the Work, in either case contrary to Affirmer's express Statement of Purpose. 4. Limitations and Disclaimers. a. No trademark or patent rights held by Affirmer are waived, abandoned, surrendered, licensed or otherwise affected by this document. b. Affirmer offers the Work as-is and makes no representations or warranties of any kind concerning the Work, express, implied, statutory or otherwise, including without limitation warranties of title, merchantability, fitness for a particular purpose, non infringement, or the absence of latent or other defects, accuracy, or the present or absence of errors, whether or not discoverable, all to the greatest extent permissible under applicable law. c. Affirmer disclaims responsibility for clearing rights of other persons that may apply to the Work or any use thereof, including without limitation any person's Copyright and Related Rights in the Work. Further, Affirmer disclaims responsibility for obtaining any necessary consents, permissions or other rights required for any use of the Work. d. Affirmer understands and acknowledges that Creative Commons is not a party to this document and has no duty or obligation with respect to this CC0 or use of the Work. =========================================================================== Grant of Patent License "License" shall mean the terms and conditions for use, reproduction, and distribution. "Licensor" shall mean the original copyright owner or entity authorized by the original copyright owner that is granting the License. "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. "You" (or "Your") shall mean an individual or Legal Entity exercising permissions granted by this License. "Source" form shall mean the preferred form for making modifications, including but not limited to software source code, documentation source, and configuration files. "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. "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. "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. "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." "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. 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. ================================================ FILE: MoquiInit.properties ================================================ # No copyright or license for configuration file, details here are not # considered a creative work. # This file is used for the base settings when deploying moqui.war as a # webapp in a servlet container or running the WAR file as an executable # JAR with java -jar. # NOTE: configure here before running "gradle build", this file is added # to the war file. # You can override these settings with command-line arguments like: # -Dmoqui.runtime=runtime # -Dmoqui.conf=conf/MoquiProductionConf.xml # The location of the runtime directory for Moqui to use. # If empty it will come from the "moqui.runtime" system property. # # The default property below assumes the application server is started in a # directory that is a sibling to a "moqui" directory that contains a "runtime" # directory. moqui.runtime=../moqui/runtime # NOTE: if there is a "runtime" directory in the war file (in the root of the # webapp) that will be used instead of this setting to make it easier to # include the runtime in a deployed war without knowing where it will be # exploded in the file system. # The Moqui Conf XML file to use for runtime settings. # This property is relative to the runtime location. moqui.conf=conf/MoquiProductionConf.xml ================================================ FILE: Procfile ================================================ web: java -cp . MoquiStart port=5000 conf=conf/MoquiProductionConf.xml ================================================ FILE: Procfile.README ================================================ No memory or other JVM options specified here so that the standard JAVA_TOOL_OPTIONS env var may be used (command line args trump JAVA_TOOL_OPTIONS) For example: export JAVA_TOOL_OPTIONS="-Xmx1024m -Xms1024m" Note that in Java 21 if no max heap size is specified it will default to 1/4 system memory The port specified here is the default for the AWS ElasticBeanstalk Java SE image see: https://docs.aws.amazon.com/elasticbeanstalk/latest/dg/java-se-procfile.html ================================================ FILE: README.md ================================================ ## Welcome to Moqui Framework [![license](https://img.shields.io/badge/license-CC0%201.0%20Universal-blue.svg)](https://github.com/moqui/moqui-framework/blob/master/LICENSE.md) [![build](https://travis-ci.org/moqui/moqui-framework.svg)](https://travis-ci.org/moqui/moqui-framework) [![release](https://img.shields.io/github/release/moqui/moqui-framework.svg)](https://github.com/moqui/moqui-framework/releases) [![commits since release](http://img.shields.io/github/commits-since/moqui/moqui-framework/v4.0.0.svg)](https://github.com/moqui/moqui-framework/commits/master) [![downloads](https://img.shields.io/github/downloads/moqui/moqui-framework/total.svg)](https://github.com/moqui/moqui-framework/releases) [![downloads](https://img.shields.io/github/downloads/moqui/moqui-framework/v4.0.0/total.svg)](https://github.com/moqui/moqui-framework/releases/tag/v4.0.0) [![Discourse Forum](https://img.shields.io/badge/moqui%20forum-discourse-blue.svg)](https://forum.moqui.org) [![Google Group](https://img.shields.io/badge/google%20group-moqui-blue.svg)](https://groups.google.com/d/forum/moqui) [![LinkedIn Group](https://img.shields.io/badge/linked%20in%20group-moqui-blue.svg)](https://www.linkedin.com/groups/4640689) [![Gitter Chat at https://gitter.im/moqui/moqui-framework](https://badges.gitter.im/moqui/moqui-framework.svg)](https://gitter.im/moqui/moqui-framework?utm_source=badge&utm_medium=badge&utm_campaign=pr-badge&utm_content=badge) [![Stack Overflow](https://img.shields.io/badge/stack%20overflow-moqui-blue.svg)](http://stackoverflow.com/questions/tagged/moqui) For information about community infrastructure for code, discussions, support, etc see the Community Guide: For details about running and deploying Moqui see: Note that a runtime directory is required for Moqui Framework to run, but is not included in the source repository. The Gradle get component, load, and run tasks will automatically add the default runtime (from the moqui-runtime repository). For information about the current and near future status of Moqui Framework see the [ReleaseNotes.md](https://github.com/moqui/moqui-framework/blob/master/ReleaseNotes.md) file. For an overview of features see: Get started with Moqui development quickly using the Tutorial at: For comprehensive documentation of Moqui Framework see the wiki based documentation on moqui.org (*running on Moqui HiveMind*): ================================================ FILE: ReleaseNotes.md ================================================ # Moqui Framework Release Notes ## Release 4.0.0 - 27 Feb 2026 Moqui framework v4.0.0 is a major new release with massive changes some of which are breaking changes. All users are advised to upgrade to benefit from all the new features, security fixes, upgrades, performance improvements and so on. ### Major Changes #### Java Upgrade to Version 21 (Incompatible Change) Moqui Framework now requires Java 21. This provides improved performance, long-term support, and access to modern JVM features, while removing legacy APIs. All custom code and components must be validated against Java 21 to ensure compatibility. As part of this work: - Remove deprecated finalize methods no longer applicable in JDK21. - Lots of code improvements to comply with JDK21. #### Groovy upgrade to version 5 (Incompatible Change) Groovy 5 in combination with newer JDK21 is more strict in @CompileStatic. There were illegal bytecodes being generated, and it has to do with accessing fields from inner classes. Another change is that Groovysh is removed. Therefore, the terminal interface was rewritten from scratch using a different architecture based on `groovy.lang.GroovyShell`. This led to both Screen changes (in runtime) and backend changes. #### Change EntityValue API (Breaking Change) Change `EntityValue.getEntityName()` to `EntityValue.resolveEntityName()` and `EntityValue.getEntityNamePretty()` to `EntityValue.resolveEntityNamePretty()`. Groovy 4+ introduced a change in the way property to method mapping happens as [documented](https://groovy-lang.org/style-guide.html#_getters_and_setters). This introduced a bug that occurs when querying an entity that has a field named `entityName`. The bug occurs because the query returns an object of type `org.moqui.entity.EntityValue`. The problem is that the EntityValue class has a method called getEntityName() and as per the groovy 4+ behavior this function is called when trying to access a field named `entityName`. Sample code: ``` def someMember = ec.entity .find('moqui.entity.view.DbViewEntityMember') .condition(...) .one() someMember.entityName // BUG returns .getEntityName(), not .get('entityName') ``` #### Upgrade to Jetty version 12.1 and EE 11 This is a major migration. It bumps jetty to version 12.1 and also servlet related packages (websocket, webapp, proxy) to jakarta EE 11. The upgrade broke the existing custom moqui class loaders, and required significant refactoring of class loaders and webapp structure (e.g. WebAppContext, Session Handling, etc ...) Impact on developers: Any custom work for jetty should be upgraded to the new versions compatible with jetty 12.1 and jakarta EE 11 #### Upgrade all javax libraries to jakarta All libraries including commons-fileupload, xml.bind-api, activation, mail, websocket, servlets (6.1), and others are all migrated to their jakarta equivalents. As part of this exercise, many deprecated, old or irrelevant / not used dependencies were removed. This change required refactoring critical moqui facades and core API to comply with the switch to Jakarta. Any custom work for older javax should be upgraded where applicable to use the jakarta equivalent libraries. #### Integration with the New Bitronix Fork (Incompatible Change) Moqui Framework now depends on the actively maintained Bitronix fork at: https://github.com/moqui/bitronix The current integrated version is 4.0.0-BETA1, with stabilization ongoing. This fork includes: - Major modernization and cleanup - Jakarta namespace migration - JMS namespace migration - Important bug fixes and stability improvements - Legacy Bitronix artifacts are no longer supported. - Deployments must remove old Bitronix dependencies. #### Migration From javax.transaction to jakarta.transaction (BREAKING CHANGE) Moqui has migrated all transaction-related imports and internal APIs from javax.transaction.* to jakarta.transaction.*, following changes in the new Bitronix fork. Impact on developers: - Any code referencing javax.transaction.* must update imports to jakarta.transaction.*. - Affects transaction facade usage, user transactions, and service-layer transaction management. - If using custom transaction API, then compilation failures should be expected until imports are updated. This does not impact projects that are purely depending on moqui facades without accessing the underlying APIs This aligns Moqui with the Jakarta EE namespace changes and the newer Bitronix transaction manager. #### Upgrade Infrastructure - Postgres to version 18.1 - MySQL to version 9.5 - Remove docker compose "version" obsolete key - Upgrade opensearch to 3.4.0 - Upgrade java in docker to eclipse-temurin:21 - Switch jwilder/nginx-proxy to nginxproxy/nginx-proxy These upgrades require careful planning when migrating to moqui V4. It is recommended to delete elastic / open search and reindex, and to switch from elasticsearch to opensearch. Also ensure an upgrade path for your chosen database. Also, in newer versions of docker, the "version" key is obsolete, so ensure updating installed docker so that it works without the "version" setting. #### Gradle Wrapper Updated to 9.2 (BREAKING CHANGE) The framework now builds using Gradle 9.2, bringing: - Faster builds - Stricter validation and deprecation cleanup Changes included: - Refactored property assignments and function calls to satisfy newer Gradle immutability rules. - Replaced deprecated exec {} blocks with Groovy execute() usage (Windows support still being refined). - Updated and corrected dependency declarations, including replacing deprecated modules and fixing invalid version strings. - Numerous misc. updates required by Gradle 9.x API changes. - Unified dependencyUpdates settings This upgrade required significant modifications to component build scripts. Given the upgrade to gradle, Java and bitronix, the following community components were upgraded to comply with new requirements: - HiveMind - PopCommerce - PopRestStore - example - mantle-braintree - mantle-usl - moqui-camel - moqui-cups - moqui-fop - moqui-hazelcast - moqui-image - moqui-orientdb - moqui-poi - moqui-runtime - moqui-sftp - moqui-sso - moqui-wikitext - start ### New Features - Upgrade groovy to version 5 - Upgrade to JDK21 by default - Upgrade to Apache Shiro 2, no longer using INI factory, but rather INI environment classes - Upgrade to jetty 2.1 and jakarta EE 11 - Upgrade docker infrastructure including opensearch, mysql, postgres to latest - Upgrade all dependencies to their latest versions - Switch from Thread.getId() to Thread.threadId() to work on both virtual and platform threads ## Release 3.9.9 - 25 Feb 2026 Moqui Framework 3.9.9 is a minor new feature and bug fix release, but mostly a maintenance release for the Moqui Framework 4.0.0 release series. For a complete list see the commit log: https://github.com/moqui/moqui-framework/compare/v3.0.0...v3.9.9 ## Release 3.1.0 - Canceled release ## Release 3.0.0 - 31 May 2022 Moqui Framework 3.0.0 is a major new feature and bug fix release with some changes that are not backward compatible. Java 11 is now the minimum Java version required. For development and deployment make sure Java 11 is installed (such as openjdk-11-jdk or adoptopenjdk-11-openj9 on Linux), active (on Linux use 'sudo update-alternatives --config java'), and that JAVA_HOME is set to the Java 11 JDK install path (for openjdk-11-jdk on Linux: /usr/lib/jvm/java-11-openjdk-amd64). In this release the old moqui-elasticsearch component with embedded ElasticSearch is no longer supported. Instead, the new ElasticFacade is included in the framework as a client to an external OpenSearch or ElasticSearch instance which can be installed in runtime/opensearch or runtime/elasticsearch and automatically started/stopped in a separate process by the MoquiStart class (executable WAR, not when WAR file dropped into Servlet container). For search the recommended versions for this release are OpenSearch 1.3.1 (https://opensearch.org/) or ElasticSearch 7.10.2. For ElasticSearch this is the last version released under the Apache 2.0 license). Now that JavaScript/CSS minify and certain other issues with tools have been resolved, Gradle 7+ is supported. This is a brief summary of the changes since the last release, for a complete list see the commit log: https://github.com/moqui/moqui-framework/compare/v2.1.3...v3.0.0 ### Non Backward Compatible Changes - Java 11 is now required, updated from Java 8 - Updated Spock to 2.1 and with that update now using JUnit Platform and JUnit 5 (Jupiter); with this update old JUnit 4 test annotations and such are supported, but JUnit 4 TestSuite implementations need to be updated to use the new JUnit Platform and Jupiter annotations - Library updates have been done that conflict with ElasticSearch making it impossible to run embedded - XMLRPC support had been partly removed years ago, is now completely removed - CUPS4J library no longer included in moqui-framework, use the moqui-cups component to add this functionality - Network printing services (org.moqui.impl.PrintServices) are now mostly placeholders that return error messages if used, CUPS4J library and services that depend on it are now in the moqui-cups tool component - H2 has non-backward compatible changes, including VALUE now being a reserved word; the Moqui Conf XML file now supports per-database entity and field name substitution to handle this and similar future issues; the main issue this cannot solve is with older H2 database files that have columns named VALUE, these may need to be changes to THE_VALUE using an older version of H2 before updating (this is less common as H2 databases are not generally retained long-term) ### New Features - Recommended Gradle version is 7+ with updates to support the latest versions of Gradle - Updated Jetty to version 10 (which requires Java 11 or later) - MFA support for login and update password in screens and REST API with factors including authc code by email and SMS, TOTP code (via authenticator app), backup codes; can set a flag on UserGroup to require second factor for all users in the group, and if any user has any additional factor enable then a second factor will be required - Various security updates including vulnerabilities in 3rd party libraries (including Log4j, Jackson, Shiro, Jetty), and some in Moqui itself including XSS vulnerabilities in certain error cases and other framework generated messages/responses based on testing with OWASP Zap and two commercial third party reviews (done by larger Moqui users) - Optimization for startup-add-missing to get meta data for all tables and columns instead of per entity for much faster startup when enabled; default for runtime-add-missing is now 'false' and startup-add-missing is now 'true' for all DBs including H2 - View Entity find improvements - correlated sub-select using SQL LATERAL (mysql8, postgres, db2) or APPLY (mssql and oracle; not yet implemented) - extend the member-entity.@sub-select attribute with non-lateral option where not wanted, is used by default as is best for how sub-select is commonly used in view entities - entity find SQL improvements for view entities where a member entity links to another member-entity with a function on a join field - support entity-condition in view-entity used as a sub-select, was being ignored before - Improvements to DataDocument generation for a DataFeed to handle very large database tables to feed to ES or elsewhere, including chunking and excluding service parameters from the per ExecutionContext instance service call history - DataFeed and DataDocument support for manual delete of documents and automatic delete on primary entity record delete - Scheduled screen render to send regular reports to users by email (simple email with CSV or XSLT attachment) using saved finds on any form-list based screen - For entity field encryption default to PBEWithHmacSHA256AndAES_128 instead of PBEWithMD5AndDES, and add configuration options for old field encrypt settings (algo, key, etc) to support changing settings, with a service to re-encrypt all encrypted fields on all records, or can re-encrypt only when data is touched (as long as all old settings are retained, the framework will attempt decrypt with each) - Groovy Shell screen added to the Tools app (with special permission), an interactive Groovy Console for testing in various environments and for fixing certain production issues ### Bug Fixes - H2 embedded shutdown hook removal updated, no more Bitronix errors on shutdown from H2 already having been terminated ## Release 2.1.3 - 07 Dec 2019 Moqui Framework 2.1.3 is a patch level new feature and bug fix release. There are only minor changes and fixes in this release. For a complete list of changes see: https://github.com/moqui/moqui-framework/compare/v2.1.2...v2.1.3 This is the last release where the moqui-elasticsearch component for embedded ElasticSearch will be supported. It is being replaced by the new ElasticFacade included in this release. ### New Features - Java 11 now supported with some additional libraries (like javax.activation) included by default; some code changes to address deprecations in the Java 11 API but more needed to resolve all for better future compatibility (in other words expect deprecation warnings when building with Java 11) - Built-in ElasticSearch client in the new ElasticFacade that uses pooled HTTP connections with the Moqui RestClient for the ElasticSearch JSON REST API; this is most easily used with Groovy where you can use the inline Map and List syntax to build what becomes the JSON body for search and other requests; after this release it will replace the old moqui-elasticsearch component, now included in the framework because the large ES jar files are no longer required - RestClient improvements to support an externally managed RequestFactory to maintain a HttpClient across requests for connection pooling, managing cookies, etc - Support for binary render modes for screen with new ScreenWidgetRender interface and screen-facade.screen-output element in the Moqui Conf XML file; this was initially implemented to support an xlsx render mode implemented in the new moqui-poi tool component - Screen rendering to XLSX file with one sheet to form-list enabled with the form-list.@show-xlsx-button attribute, the XLS button will only show if the moqui-poi tool component is in place - Support for binary rendered screen attachments to emails, and reusable emailScreenAsync transition and EmailScreenSection to easily add a form to screens to send the screen render as an attachment to an outgoing email, rendered in the background - WikiServices to upload and delete attachments, and delete wiki pages; improvements to clone wiki page ## Release 2.1.2 - 23 July 2019 Moqui Framework 2.1.2 is a patch level new feature and bug fix release. There are only minor changes and fixes in this release. For a complete list of changes see: https://github.com/moqui/moqui-framework/compare/v2.1.1...v2.1.2 ### New Features - Service include for refactoring, etc with new services.service-include element - RestClient now supports retry on timeout for call() and 429 (velocity) return for callFuture() - The general worker thread pool now checks for an active ExecutionContext after each run to make sure destroyed - CORS preflight OPTIONS request and CORS actual request handling in MoquiServlet - headers configured using cors-preflight and cors-actual types in webapp.response-header elements with default headers in MoquiDefaultConf.xml - allowed origins configured with the webapp.@allow-origins attribute which defaults the value of the 'webapp_allow_origins' property or env var for production configuration; default to empty which means only same origin is allowed - Docker and instance management monitoring and configuration option improvements, Postgres support for database instances - Entity field currency-amount now has 4 decimal digits in the DB and currency-precise has 5 decimal digits for more currency flexibility - Added minRetryTime to ServiceJob to avoid immediate and excessive retries - New Gradle tasks for managing git tags - Support for read only clone datasource configuration and use (if available) in entity finds ### Bug Fixes - Issue with DataFeed Runnable not destroying the ExecutionContext causing errors to bleed over - Fix double content type header in RestClient in certain scenarios ## Release 2.1.1 - 29 Nov 2018 Moqui Framework 2.1.1 is a patch level new feature and bug fix release. While this release has new features maybe significant enough to warrant a 2.2.0 version bump it is mostly refinements and improvements to existing functionality or to address design limitations and generally make things easier and cleaner. There are various bug fixes and security improvements in this release. There are no known backward compatibility issues since the last release but there are minor cases where default behavior has changed (see detailed notes). ### New Features - Various library updates (see framework/build.gradle for details) - Updated to Gradle 4 along with changes to gradle files that require Gradle 4.0 or later - In gradle addRuntime task create version.json files for framework/runtime and for each component, shown on System app dashboard - New gradle gitCheckoutAll task to bulk checkout branches with option to create - New default/example Procfile, include in moqui-plus-runtime.war ##### Web Facade and HTTP - RestClient improvements for background requests with a Future, retry on 429 for velocity limited APIs, multipart requests, etc - In user preferences support override by Java system property (or env var if default-property declared in Moqui Conf XML) - Add WebFacade.getRequestBodyText() method, use to get body text more easily and now necessary as WebFacade reads the body for all requests with a text content type instead of just application/json or text/json types as before - Add email support for notifications with basic default template, enabled only per user for a specific NotificationTopic - Add NotificationTopic for web (screen) critical errors - Invalidate session before login (with attributes copy to new session) to mitigate session fixation attacks - Add more secure defaults for Strict-Transport-Security, Content-Security-Policy, and X-Frame-Options ##### XML Screen and Form - Support for Vue component based XML Screens using a .js file and a .vuet file that gets merged into the Vue component as the template (template can be inline in the .js file); for an example see the DynamicExampleItems.xml screen in the example component - XML Screen and WebFacade response headers now configurable with webapp.response-header element in Moqui Conf XML - Add moqui-conf.screen-facade.screen and screen.subscreens-item elements that override screen.subscreens.subscreens-item elements within a screen definition so that application root screens can be added under webroot and apps in a MoquiConf.xml file in a component or in the active Moqui Conf XML file instead of using database records - Add support for 'no sub-path' subscreens to extend or override screens, transitions, and resources under the parent screen by looking first in each no sub-path subscreen for a given screen path and if not found then look under the parent screen; for example this is used in the moqui-org component for the moqui.org web-site so that /index.html is found in the moqui-org component and so that /Login resolves to the Login.xml screen in the moqui-org component instead of the default one under webroot - Add screen path alias support configured with ScreenPathAlias entity records - Now uses URLDecoder for all screen path segments to match use of URLEncoder as default for URL encoding in output - In XML Screen transition both service-call and actions are now allowed, service-call runs first - Changed Markdown rendering from Pegdown to flexmark-java to support CommonMark 0.28, some aspects of GitHub Flavored Markdown, and automatic table of contents - Add form-single.@pass-through-parameters attribute to create hidden inputs for current request parameters - Moved validate-* attributes from XML Form field element to sub-field elements so that in form-list different validation can be done for header, first-/second-/last-row, and default-/conditional-field; as part of this the automatic validate settings from transition.service-call are now set on the sub-field instead of the field element ##### Service Facade - Add seca.@id and eeca.@id attributes to specify optional IDs that can be used to override or disable SECAs and EECAs - SystemMessage improvements for security, HTTP receive endpoint, processing/etc timeouts, etc - Service semaphore concurrency improvements, support for semaphore-name which defaults to prior behavior of service name ##### Entity Facade - Add eeca.set-results attribute to set results of actions in the fields for rules run before entity operation - Add entity.relationship.key-value element for constants on join conditions - Authorization based entity find filters are now applied after view entities are trimmed so constraints are only added for entities actually used in the query - EntityDataLoader now supports a create only mode (used in the improved Data Import screen in the Tools app, usable directly) - Add mysql8 database conf for new MySQL 8 JDBC driver ### Bug Fixes - Serious bug in MoquiAuthFilter where it did not destroy ExecutionContext leaving it in place for the next request using that thread; also changed MoquiServlet to better protect against existing ExecutionContext for thread; also changed WebFacade init from HTTP request to remove current user if it doesn't match user authenticated in session with Shiro, or if no user is authenticated in session - MNode merge methods did not properly clear node by name internal cache when adding child nodes causing new children to show up in full child node list but not when getting first or all children by node name if they had been accessed by name before the merge - Fix RestClient path and parameter encoding - Fix RestClient basic authentication realm issue, now custom builds Authorization request header - Fix issue in update#Password service with reset password when UserAccount has a resetPassword but no currentPassword - Disable default geo IP lookup for Visit records because the freegeoip service has been discontinued - Fix DataFeed trigger false positives for PK fields on related entities included in DataDocument definitions - Fix transaction response type screen-last in vuet/vapps mode, history wasn't being maintained server side ## Release 2.1.0 - 22 Oct 2017 Moqui Framework 2.1.0 is a minor new feature and bug fix release. Most of the effort in the Moqui Ecosystem since the last release has been on the business artifact and application levels. Most of the framework changes have been for improved user interfaces but there have also been various lower level refinements and enhancements. This release has a few bug fixes from the 2.0.0 release and has new features like DbResource and WikiPage version management, a simple tool for ETL, DataDocument based dynamic view entities, and various XML Screen and Form widget options and usability improvements. This release was originally planned to be a patch level release primarily for bug fixes but very soon after the 2.0.0 release work start on the Vue based client rendering (SPA) functionality and various other new features that due to business deals progressed quickly. The default moqui-runtime now has support for hybrid static/dynamic XML Screen rendering based on Vue JS. There are various changes for better server side handling but most changes are in moqui-runtime. See the moqui-runtime release notes for more details. Some of these changes may be useful for other client rendering purposes, ie for other client side tools and frameworks. ### Non Backward Compatible Changes - New compile dependency on Log4J2 and not just SLF4J - DataDocument JSON generation no longer automatically adds all primary key fields of the primary entity to allow for aggregation by function in DataDocument based queries (where DataDocument is used to create a dynamic view entity); for ElasticSearch indexing a unique ID is required so all primary key fields of the primary entity should be defined - The DataDocumentField, DataDocumentCondition, and DataDocumentLink entities now have an artificial/sequenced secondary key instead of using another field (fieldPath, fieldNameAlias, label); existing tables may work with some things but reloading seed data will fail if you have any DataDocument records in place; these are typically seed data records so the easiest way to update/migrate is to drop the tables for DataDocumentField/Link/Condition entities and then reload seed data as normal for a code update - If using moqui-elasticsearch the index approach has changed to one index per DataDocument to prep for ES6 and improve the performance and index types by field name; to update an existing instance it is best to start with an empty ES instance or at least delete old indexes and re-index based on data feeds - The default Dockerfile now runs the web server on port 80 instead of 8080 within the container ### New Features - Various library updates - SLF4J MDC now used to track moqui_userId and moqui_visitorId for logging - New ExecutionContextFactory.registerLogEventSubscriber() method to register for Log4J2 LogEvent processing, initially used in the moqui-elasticsearch component to send log messages to ElasticSearch for use in the new LogViewer screen in the System app - Improved Docker Compose samples with HTTPS and PostgreSQL, new file for Kibana behind transparent proxy servlet in Moqui - Added MoquiAuthFilter that can be used to require authorization and specified permission for arbitrary paths such as servlets; this is used along with the Jetty ProxyServlet$Transparent to provide secure access to things server only accessible tools like ElasticSearch (on /elastic) and Kibana (on /kibana) in the moqui-elasticsearch component - Multi service calls now pass results from previous calls to subsequent calls if parameter names match, and return results - Service jobs may now have a lastRunTime parameter passed by the job scheduler; lastRunTime on lock and passed to service is now the last run time without an error - view-entity now supports member-entity with entity-condition and no key-map for more flexible join expressions - TransactionCache now handles more situations like using EntityListIterator.next() calls and not just getCompleteList(), and deletes through the tx cache are more cleanly handled for records created through the tx cache - ResourceReference support for versions in supported implementations (initially DbResourceReference) - ResourceFacade locations now support a version suffix following a hash - Improved wiki services to track version along with underlying ResourceReference - New SimpleEtl class plus support for extract and load through EntityFacade - Various improvements in send#EmailTemplate, email view tracking with transparent pixel image - Improvements for form-list aggregations and show-total now supports avg, count, min, max, first, and last in addition to sum - Improved SQLException handling with more useful messages and error codes from database - Added view-entity.member-relationship element as a simpler alternative to member-entity using existing relationships - DataDocumentField now has a functionName attribute for functions on fields in a DataDocument based query - Any DataDocument can now be treated as an entity using the name pattern DataDocument.${dataDocumentId} - Sub-select (sub-query) is now supported for view-entity by a simple flag on member-entity (or member-relationship); this changes the query structure so the member entity is joined in a select clause with any conditions for fields on that member entity put in its where clause instead of the where clause for the top-level select; any fields selected are selected in the sub-select as are any fields used for the join ON conditions; the first example of this is the InvoicePaymentApplicationSummary view-entity in mantle-usl which also uses alias.@function and alias.complex-alias to use concat_ws for combined name aliases - Sub-select also now supported for view-entity members of other view entities; this provides much more flexibility for functions and complex-aliases in the sub-select queries; there are also examples of this in mantle-usl - Now uses Jackson Databind for JSON serialization and deserialization; date/time values are in millis since epoch ### Bug Fixes - Improved exception (throwable) handling for service jobs, now handled like other errors and don't break the scheduler - Fixed field.@hide attribute not working with runtime conditions, now evaluated each time a form-list is rendered - Fixed long standing issue with distinct counts and limited selected fields, now uses a distinct sub-select under a count select - Fixed long standing issue where view-entity aliased fields were not decrypted - Fixed issue with XML entity data loading using sub-elements for related entities and under those sub-elements for field data - Fixed regression in EntityFind where cache was used even if forUpdate was set - Fixed concurrency issue with screen history (symptom was NPE on iterator.next() call) ## Release 2.0.0 - 24 Nov 2016 Moqui Framework 2.0.0 is a major new feature and bug fix release, with various non backward compatible API and other changes. This is the first release since 1.0.0 with significant and non backwards compatible changes to the framework API. Various deprecated methods have been removed. The Cache Facade now uses the standard javax.cache interfaces and the Service Facade now uses standard java.util.concurrent interfaces for async and scheduled services. Ehcache and Quartz Scheduler have been replaced by direct, efficient interfaces implementations. This release includes significant improvements in configuration and with the new ToolFactory functionality is more modular with more internals exposed through interfaces and extendable through components. Larger and less universally used tool are now in separate components including Apache Camel, Apache FOP, ElasticSearch, JBoss KIE and Drools, and OrientDB. Multi-server instances are far better supported by using Hazelcast for distributed entity cache invalidation, notifications, caching, background service execution, and for web session replication. The moqui-hazelcast component is pre-configured to enable all of this functionality in its MoquiConf.xml file. To use add the component and add a hazelcast.xml file to the classpath with settings for your cluster (auto-discover details, etc). Moqui now scales up better with performance improvements, concurrency fixes, and Hazelcast support (through interfaces other distributed system libraries like Apache Ignite could also be used). Moqui also now scales down better with improved memory efficiency and through more modular tools much smaller runtime footprints are possible. The multi-tenant functionality has been removed and replaced with the multi-instance approach. There is now a Dockerfile included with the recommended approach to run Moqui in Docker containers and Docker Compose files for various scenarios including an automatic reverse proxy using nginx-proxy. There are now service interfaces and screens in the System application for managing multiple Moqui instances from a master instance. Instances with their own database can be automatically provisioned using configurable services, with initial support for Docker containers and MySQL databases. Provisioning services will be added over time to support other instance hosts and databases, and you can write your own for whatever infrastructure you prefer to use. To support WebSocket a more recent Servlet API the embedded servlet container is now Jetty 9 instead of Winstone. When running behind a proxy such as nginx or httpd running in the embedded mode (executable WAR file) is now adequate for production use. If you are upgrading from an earlier version of Moqui Framework please read all notes about Non Backward Compatible Changes. Code, configuration, and database meta data changes may be necessary depending on which features of the framework you are using. In this version Moqui Framework starts and runs faster, uses less memory, is more flexible, configuration is easier, and there are new and better ways to deploy and manage multiple instances. A decent machine ($1800 USD Linux workstation, i7-6800K 6 core CPU) generated around 350 screens per second with an average response time under 200ms. This was running Moqui and MySQL on the same machine with a JMeter script running on a separate machine doing a 23 step order to ship/bill process that included 2 reports (one MySQL based, one ElasticSearch based) and all the GL posting, etc. The load simulated entering and shipping (by internal users) around 1000 orders/minute which would support thousands of concurrent internal or ecommerce users. On larger server hardware and with some lower level tuning (this was on stock/default Linux, Java 8, and MySQL 5.7 settings) a single machine could handle significantly more traffic. With the latest framework code and the new Hazelcast plugin Moqui supports high performance clusters to handle massive loads. The most significant limit is now database performance as we need a transactional SQL database for this sort of business process (with locking on inventory reservations and issuances, GL posting, etc as currently implemented in Mantle USL). Enjoy! ### Non Backward Compatible Changes - Java JDK 8 now required (Java 7 no longer supported) - Now requires Servlet Container supporting the Servlet 3.1 specification - No longer using Winstone embedded web server, now using Jetty 9 - Multi-Tenant Functionality Removed - ExecutionContext.getTenant() and getTenantId() removed - UserFacade.loginUser() third parameter (tenantId) removed - CacheFacade.getCache() with second parameter for tenantId removed - EntityFacade no longer per-tenant, getTenantId() removed - TransactionInternal and EntityDatasourceFactory methods no longer have tenantId parameter - Removed tenantcommon entity group and moqui.tenant entities - Removed tenant related MoquiStart command line options - Removed tenant related Moqui Conf XML, XML Screen, etc attributes - Entity Definitions - XSDs updated for these changes, though old attributes still supported - changed entity.@package-name to entity.@package - changed entity.@group-name to entity.@group - changed relationship.@related-entity-name to relationship.@related - changed key-map.@related-field-name to key-map.@related - UserField no longer supported (UserField and UserFieldValue entities) - XML Screen and Form - field.@entry-name attribute replaced by field.@from attribute (more meaningful, matches attribute used on set element); the old entry-name attribute is still supported, but removed from XSD - Service Job Scheduling - Quartz Scheduler has been removed, use new ServiceJob instead with more relevant options, much cleaner and more manageable - Removed ServiceFacade.getScheduler() method - Removed ServiceCallSchedule interface, implementation, and ServiceFacade.schedule() factory method - Removed ServiceQuartzJob class (impl of Job interface) - Removed EntityJobStore class (impl of JobStore interface); this is a huge and complicated class to handle the various complexities of Quartz and was never fully working, had some remaining issues in testing - Removed HistorySchedulerListener and HistoryTriggerListener classes - Removed all entities in the moqui.service.scheduler and moqui.service.quartz packages - Removed quartz.properties and quartz_data.xml configuration files - Removed Scheduler screens from System app in tools component - For all of these artifacts see moqui-framework commit #d42ede0 and moqui-runtime commit #6a9c61e - Externalized Tools - ElasticSearch (and Apache Lucene) - libraries, classes and all related services, screens, etc are now in the moqui-elasticsearch component - System/DataDocument screens now in moqui-elasticsearch component and added to tools/System app through SubscreensItem record - all ElasticSearch services in org.moqui.impl.EntityServices moved to org.moqui.search.SearchServices including: index#DataDocuments, put#DataDocumentMappings, index#DataFeedDocuments, search#DataDocuments, search#CountBySource - Moved index#WikiSpacePages service from org.moqui.impl.WikiServices to org.moqui.search.SearchServices - ElasticSearch dependent REST API methods moved to the 'elasticsearch' REST API in the moqui-elasticsearch component - Apache FOP is now in the moqui-fop tool component; everything in the framework, including the now poorly named MoquiFopServlet, use generic interfaces but XML-FO files will not transform to PDF/etc without this component in place - OrientDB and Entity Facade interface implementations are now in the moqui-orientdb component, see its README.md for usage - Apache Camel along with the CamelServiceRunner and MoquiServiceEndpoint are now in the moqui-camel component which has a MoquiConf.xml file so no additional configuration is needed - JBoss KIE and Drools are now in tool component moqui-kie, an optional component for mantle-usl; has MoquiConf to add ToolFactory - Atomikos TM moved to moqui-atomikos tool component - ExecutionContext and ExecutionContextFactory - Removed initComponent(), destroyComponent() methods; were never well supported (runtime component init/destroy caused issues) - Removed getCamelContext() from ExecutionContextFactory and ExecutionContext, use getTool("Camel", CamelContext.class) - Removed getElasticSearchClient() from ExecutionContextFactory and ExecutionContext, use getTool("ElasticSearch", Client.class) - Removed getKieContainer, getKieSession, and getStatelessKieSession methods from ExecutionContextFactory and ExecutionContext, use getTool("KIE", KieToolFactory.class) and use the corresponding methods there - See new feature notes under Tool Factory - Caching - Ehcache has been removed - The org.moqui.context.Cache interface is replaced by javax.cache.Cache - Configuration options for caches changed (moqui-conf.cache-list.cache) - NotificationMessage - NotificationMessage, NotificationMessageListener interfaces have various changes for more features and to better support serialized messages for notification through a distributed topic - Async Services - Now uses more standard java.util.concurrent interfaces - Removed ServiceCallAsync.maxRetry() - was never supported - Removed ServiceCallAsync.persist() - was never supported well, used to simply call through Quartz Scheduler when set - Removed persist option from XML Actions service-call.@async attribute - Async services never called through Quartz Scheduler (only scheduled) - ServiceCallAsync.callWaiter() replaced by callFuture() - Removed ServiceCallAsync.resultReceiver() - Removed ServiceResultReceiver interface - use callFuture() instead - Removed ServiceResultWaiter class - use callFuture() instead - See related new features below - Service parameter.subtype element removed, use the much more flexible nested parameter element - JCR and Apache Jackrabbit - The repository.@type, @location, and @conf-location attributes have been removed and the repository.parameter sub-element added for use with the javax.jcr.RepositoryFactory interface - See new configuration examples in MoquiDefaultConf.xml under the repository-list element - OWASP ESAPI and AntiSamy - ESAPI removed, now using simple StringEscapeUtils from commons-lang - AntiSamy replaced by Jsoup.clean() - Removed ServiceSemaphore entity, now using ServiceParameterSemaphore - Deprecated methods - These methods were deprecated (by methods with shorter names) long ago and with other API changes now removing them - Removed getLocalizedMessage() and formatValue() from L10nFacade - Removed renderTemplateInCurrentContext(), runScriptInCurrentContext(), evaluateCondition(), evaluateContextField(), and evaluateStringExpand() from ResourceFacade - Removed EntityFacade.makeFind() - ArtifactHit and ArtifactHitBin now use same artifact type enum as ArtifactAuthz, for efficiency and consistency; configuration of artifact-stats by sub-type no longer supported, had little value and caused performance overhead - Removed ArtifactAuthzRecord/Cond entities and support for them; this was never all that useful and is replaced by the ArtifactAuthzFilter and EntityFilter entities - The ContextStack class has moved to the org.moqui.util package - Replaced Apache HttpComponents client with jetty-client to get support for HTTP/2, cleaner API, better async support, etc - When updating to this version recommend stopping all instances in a cluster before starting any instance with the new version ### New Features - Now using Jetty embedded for the executable WAR instead of Winstone - using Jetty 9 which requires Java 8 - now internally using Servlet API 3.1.0 - Many library updates, cleanup of classes found in multiple jar files (ElasticSearch JarHell checks pass; nice in general) - Configuration - Added default-property element to set Java System properties from the configuration file - Added Groovy string expansion to various configuration attributes - looks for named fields in Java System properties and environment variables - used in default-property.@value and all xa-properties attributes - replaces the old explicit check for ${moqui.runtime}, which was a simple replacement hack - because these are Groovy expressions the typical dots used in property names cannot be used in these strings, use an underscore instead of a dot, ie ${moqui_runtime} instead of ${moqui.runtime}; if a property name contains underscores and no value is found with the literal name it replaces underscores with dots and looks again - Deployment and Docker - The MoquiStart class can now run from an expanded WAR file, i.e. from a directory with the contents of a Moqui executable WAR - On startup DataSource (database) connections are retried 5 times, every 5 seconds, for situations where init of separate containers is triggered at the same time like with Docker Compose - Added a MySQLConf.xml file where settings can come from Java system properties or system environment variables - The various webapp.@http* attributes can now be set as system properties or environment variables - Added a Dockerfile and docker-build.sh script to build a Docker image from moqui-plus-runtime.war or moqui.war and runtime - Added sample Docker Compose files for moqui+mysql, and for moqui, mysql, and nginx-proxy for reverse proxy that supports virtual hosts for multiple Docker containers running Moqui - Added script to run a Docker Compose file after copying configuration and data persistence runtime directories if needed - Multi-Instance Management - New services (InstanceServices.xml) and screens in the System app for Moqui instance management - This replaces the removed multi-tenant functionality - Initially supports Docker for the instance hosting environment via Docker REST API - Initially supports MySQL for instance databases (one DB per instance, just like in the past) - Tool Factory - Added org.moqui.context.ToolFactory interface used to initialize, destroy, and get instances of tools - Added tools.tool-factory element in Moqui Conf XML file; has default tools in MoquiDefaultConf.xml and can be populated or modified in component and/or runtime conf XML files - Use new ExecutionContextFactory.getToolFactory(), ExecutionContextFactory.getTool(), and ExecutionContext.getTool() methods to interact with tools - See non backward compatible change notes for ExecutionContextFactory - WebSocket Support - Now looks for javax.websocket.server.ServerContainer in ServletContext during init, available from ECFI.getServerContainer() - If ServletContainer found adds endpoints defined in the webapp.endpoint element in the Moqui Conf XML file - Added MoquiAbstractEndpoint, extend this when implementing an Endpoint so that Moqui objects such as ExecutionContext/Factory are available, UserFacade initialized from handshake (HTTP upgrade) request, etc - Added NotificationEndpoint which listens for NotificationMessage through ECFI and sends them over WebSocket to notify user - NotificationMessage - Notifications can now be configured to send through a topic interface for distributed topics (implemented in the moqui-hazelcast component); this handles the scenario where a notification is generated on one server but a user is connected (by WebSocket, etc) to another - Various additional fields for display in the JavaScript NotificationClient including type, title and link templates, etc - Caching - CacheFacade now supports separate local and distributed caches both using the javax.cache interfaces - Added new MCache class for faster local-only caches - implements the javax.cache.Cache interface - supports expire by create, access, update - supports custom expire on get - supports max entries, eviction done in separate thread - Support for distributed caches such as Hazelcast - New interfaces to plugin entity distributed cache invalidation through a SimpleTopic interface, supported in moqui-hazelcast - Set many entities to cache=never, avoid overhead of cache where read/write ratio doesn't justify it or cache could cause issues - Async Services - ServiceCallAsync now using standard java.util.concurrent interfaces - Use callFuture() to get a Future object instead of callWaiter() - Can now get Runnable or Callable objects to run a service through a ExecutorService of your choice - Services can now be called local or distributed - Added ServiceCallAsync.distribute() method - Added distribute option to XML Actions service-call.@async attribute - Distributed executor is configurable, supported in moqui-hazelcast - Distributed services allow offloading service execution to worker nodes - Service Jobs - Configure ad-hoc (explicitly executed) or scheduled jobs using the new ServiceJob and related entities - Tracks execution in ServiceJobRun records - Can send NotificationMessage, success or error, to configured topic - Run service job through ServiceCallJob interface, ec.service.job() - Replacement for Quartz Scheduler scheduled services - Added SubEtha SMTP server which receives email messages and calls EMECA rules, an alternative to polling IMAP and POP3 servers - Hazelcast Integration (moqui-hazelcast component) - These features are only enabled with this tool component in place - Added default Hazelcast web session replication config - Hazelcast can be used for distributed entity cache, web session replication, distributed execution, and OrientDB clustering - Implemented distributed entity cache invalidate using a Hazelcast Topic, enabled in Moqui Conf XML file with the @distributed-cache-invalidate attribute on the entity-facade element - XSL-FO rendering now supports a generic ToolFactory to create a org.xml.sax.ContentHandler object, with an implementation using Apache FOP now in the moqui-fop component - JCR and Apache Jackrabbit - JCR support (for content:// locations in the ResourceFacade) now uses javax.jcr interfaces only, no dependencies on Jackrabbit - JCR repository configuration now supports other JCR implementations by using RepositoryFactory parameters - Added ADMIN_PASSWORD permission for administrative password change (in UserServices.update#Password service) - Added UserServices.enable#UserAccount service to enable disabled account - Added support for error screens rendered depending on type of error - configured in the webapp.error-screen element in Moqui Conf XML file - if error screen render fails sends original error response - this is custom content that avoids sending an error response - A component may now have a MoquiConf.xml file that overrides the default configuration file (MoquiDefaultConf.xml from the classpath) but is overridden by the runtime configuration file; the MoquiConf.xml file in each component is merged into the main conf based on the component dependency order (logged on startup) - Added ExecutionContext.runAsync method to run a closure in a worker thread with an ExecutionContext like the current (user, etc) - Added configuration for worker thread pool parameters, used for local async services, EC.runAsync, etc - Transaction Facade - The write-through transaction cache now supports a read only mode - Added service.@no-tx-cache attribute which flushes and disables write through transaction cache for the rest of the transaction - Added flushAndDisableTransactionCache() method to flush/disable the write through cache like service.@no-tx-cache - Entity Facade - In view-entity.alias.complex-alias the expression attribute is now expanded so context fields may be inserted or other Groovy expressions evaluated using dollar-sign curly-brace (${}) syntax - Added view-entity.alias.case element with when and else sub-elements that contain complex-alias elements; these can be used for CASE, CASE WHEN, etc SQL expressions - EntityFind.searchFormMap() now has a defaultParameters argument, used when no conditions added from the input fields Map - EntityDataWriter now supports export with a entity master definition name, applied only to entities exported that have a master definition with the given master name - XML Screen and Form - screen path URLs that don't exist are now by default disabled instead of throwing an exception - form-list now supports @header-dialog to put header-field widgets in a dialog instead of in the header - form-list now supports @select-columns to allow users to select which fields are displayed in which columns, or not displayed - added search-form-inputs.default-parameters element whose attributes are used as defaultParameters in searchFormMap() - ArtifactAuthzFailure records are only created when a user tries to use an artifact, not when simply checking to see if use is permitted (such as in menus, links, etc) - significant macro cleanups and improvements - csv render macros now improved to support more screen elements, more intelligently handle links (only include anchor/text), etc - text render macros now use fixed width output (number of characters) along with new field attributes to specify print settings - added field.@aggregate attribute for use in form-list with options to aggregate field values across multiple results or display fields in a sub-list under a row with the common fields for the group of rows - added form-single.@owner-form attribute to skip HTML form element and add the HTML form attribute to fields so they are owned by a different form elsewhere in the web page - The /status path now a transition instead of a screen and returns JSON with more server status information - XML Actions now statically import all the old StupidUtilities methods so 'StupidUtilities.' is no longer needed, shouldn't be used - StupidUtilities and StupidJavaUtilities reorganized into the new ObjectUtilities, CollectionUtilities, and StringUtilities classes in the moqui.util package (in the moqui-util project) ### Bug Fixes - Fixed issues with clean shutdown running with the embedded Servlet container and with gradle test - Fixed issue with REST and other requests using various HTTP request methods that were not handled, MoquiServlet now uses the HttpServlet.service() method instead of the various do*() methods - Fixed issue with REST and other JSON request body parameters where single entry lists were unwrapped to just the entry - Fixed NPE in EntityFind.oneMaster() when the master value isn't found, returns null with no error; fixes moqui-runtime issue #18 - Fixed ElFinder rm (moqui-runtime GitHub issue #23), response for upload - Screen sub-content directories treated as not found so directory entries not listed (GitHub moqui-framework issue #47) - In entity cache auto clear for list of view-entity fixed mapping of member entity fields to view entity alias, and partial match when only some view entity fields are on a member entity - Cache clear fix for view-entity list cache, fixes adding a permission on the fly - Fixed issue with Entity/DataEdit screens in the Tools application where the parameter and form field name 'entityName' conflicted with certain entities that have a field named entityName - Concurrency Issues - Fixed concurrent update errors in EntityCache RA (reverse association) using Collections.synchronizedList() - Fixed per-entity DataFeed info rebuild to avoid multiple runs and rebuild before adding to cache in use to avoid partial data - Fixed attribute and child node wrapper caching in FtlNodeWrapper where in certain cases a false null would be returned ## Release 1.6.2 - 26 Mar 2016 Moqui Framework 1.6.2 is a minor new feature and bug fix release. This release is all about performance improvements, bug fixes, library updates and cleanups. There are a number of minor new features like better multi-tenant handling (and security), optionally loading data on start if the DB is empty, more flexible handling of runtime Moqui Conf XML location, database support and transaction management, and so on. ### Non Backward Compatible Changes - Entity field types are somewhat more strict for database operations; this is partly for performance reasons and partly to avoid database errors that happen only on certain databases (ie some allow passing a String for a Timestamp, others don't; now you have to use a Timestamp or other date object); use EntityValue.setString or similar methods to do data conversions higher up - Removed the TenantCurrency, TenantLocale, TenantTimeZone, and TenantCountry entities; they aren't generally used and better not to have business settings in these restricted technical config entities ### New Features - Many performance improvements based on profiling; cached entities finds around 6x faster, non cached around 3x; screen rendering also faster - Added JDBC Connection stash by tenant, entity group, and transaction, can be disabled with transaction-facade.@use-connection-stash=false in the Moqui Conf XML file - Many code cleanups and more CompileStatic with XML handling using new MNode class instead of Groovy Node; UserFacadeImpl and TransactionFacadeImpl much cleaner with internal classes for state - Added tools.@empty-db-load attribute with data file types to load on startup (through webapp ContextListener init only) if the database is empty (no records for moqui.basic.Enumeration) - If the moqui.conf property (system property, command line, or in MoquiInit.properties) starts with a forward slash ('/') it is now considered an absolute path instead of relative to the runtime directory allowing a conf file outside the runtime directory (an alternative to using ../) - UserAccount.userId and various other ID fields changed from id-long to id as userId is only an internal/sequenced ID now, and for various others the 40 char length changed years ago is more than adequate; existing columns can be updated for the shorter length, but don't have to be - Changes to run tests without example component in place (now a component separate from moqui-runtime), using the moqui.test and other entities - Added run-jackrabbit option to run Apache Jackrabbit locally when Moqui starts and stop is when Moqui stops, with conf/etc in runtime/jackrabbit - Added SubscreensDefault entity and supporting code to override default subscreens by tenant and/or condition with database records - Now using the VERSION_2_3_23 version for FreeMarker instead of a previous release compatibility version - Added methods to L10nFacade that accept a Locale when something other than the current user's locale is needed - Added TransactionFacade runUseOrBegin() and runRequireNew() methods to run code (in a Groovy Closure) in a transaction - ArtifactHit/Bin persistence now done in a worker thread instead of async service; uses new eci.runInWorkerThread() method, may be added ExecutionContext interface in the future - Added XML Form text-line.depends-on element so autocomplete fields can get data on the client from other form fields and clear on change - Improved encode/decode handling for URL path segments and parameters - Service parameters with allow-html=safe are now accepted even with filtered elements and attributes, non-error messages are generated and the clean HTML from AntiSamy is used - Now using PegDown for Markdown processing instead of Markdown4J - Multi Tenant - Entity find and CrUD operations for entities in the tenantcommon group are restricted to the DEFAULT instance, protects REST API and so on regardless of admin permissions a tenant admin might assign - Added tenants allowed on SubscreensItem entity and subscreens-item element, makes more sense to filter apps by tenant than in screen - Improvements to tenant provisioning services, new MySQL provisioning, and enable/disable tenant services along with enable check on switch - Added ALL_TENANTS option for scheduled services, set on system maintenance services in quartz_data.xml by default; runs the service for each known tenant (by moqui.tenant.Tenant records) - Entity Facade - DB meta data (create tables, etc) and primary sequenced ID queries now use a separate thread to run in a different transaction instead of suspend/resume as some databases have issues with that, especially nested which happens when service and framework code suspends - Service Facade - Added separateThread option to sync service call as an alternative to requireNewTransaction which does a suspend/resume, runs service in a separate thread and waits for the service to complete - Added service.@semaphore-parameter attribute which creates a distinct semaphore per value of that parameter - Services called with a ServiceResultWaiter now get messages passed through from the service job in the current MessageFacade (through the MessageFacadeException), better handling for other Throwable - Async service calls now run through lighter weight worker thread pool if persist not set (if persist set still through Quartz Scheduler) - Dynamic (SPA) browser features - Added screen element when render screen to support macros at the screen level, such as code for components and services in Angular 2 - Added support for render mode extension (like .html, .js, etc) to last screen name in screen path (or URL), uses the specified render-mode and doesn't try to render additional subscreens - Added automatic actions.json transition for all screens, runs actions and returns results as JSON for use in client-side template rendering - Added support for .json extension to transitions, will run the transition and if the response goes to another screen returns path to that screen in a list and parameters for it, along with messages/errors/etc for client side routing between screens ### Bug Fixes - DB operations for sequenced IDs, service semaphores, and DB meta data are now run in a separate thread instead of tx suspend/resume as some databases have issues with suspend/resume, especially multiple outstanding suspended transactions - Fixed issue with conditional default subscreen URL caching - Internal login from login/api key and async/scheduled services now checks for disabled accounts, expired passwords, etc just like normal login - Fixed issue with entity lists in TransactionCache, were not cloned so new/updated records changed lists that calling code might use - Fixed issue with cached entity lists not getting cleared when a record is updated that wasn't in a list already in the cache but that matches its condition - Fixed issue with cached view-entity lists not getting cleared on new or updated records; fixes issues with new authz, tarpits and much more not applied immediately - Fixed issue with cached view-entity one results not getting cleared when a member entity is updated (was never implemented) - Entities in the tenantcommon group no longer available for find and CrUD operations outside the DEFAULT instance (protect tenant data) - Fixed issue with find one when using a Map as a condition that may contain non-PK fields and having an artifact authz filter applied, was getting non-PK fields and constraining query when it shouldn't (inconsistent with previous behavior) - Fixed ElasticSearch automatic mappings where sub-object mappings always had just the first property - Fixed issues with Entity DataFeed where cached DataDocument mappings per entity were not consistent and no feed was done for creates - Fixed safe HTML service parameters (allow-html=safe), was issue loading antisamy-esapi.xml though ESAPI so now using AntiSamy directly - Fixed issues with DbResource reference move and other operations - Fixed issues with ResourceReference operations and wiki page updates ## Release 1.6.1 - 24 Jan 2016 Moqui Framework 1.6.1 is a minor new feature and bug fix release. This is the first release after the repository reorganization in Moqui Ecosystem. The runtime directory is now in a separate repository. The framework build now gets JAR files from Bintray JCenter instead of having them in the framework/lib directory. Overall the result is a small foundation with additional libraries, components, etc added as needed using Gradle tasks. ### Build Changes - Gradle tasks to help handle runtime directory in a separate repository from Moqui Framework - Added component management features as Gradle tasks - Components available configured in addons.xml - Repositories components come from configured in addons.xml - Get component from current or release archive (getCurrent, getRelease) - Get component from git repositories (getGit) - When getting a component, automatically gets all components it depends on (must be configured in addons.xml so it knows where to get them) - Do a git pull for moqui, runtime, and all components - Most JAR files removed, framework build now uses Bintray JCenter - JAR files are downloaded as needed on build - For convenience in IDEs to copy JAR files to the framework/dependencies directory use: gradle framework:copyDependencies; note that this is not necessary in IntelliJ IDEA (will import dependencies when creating a new project based on the gradle files, use the refresh button in the Gradle tool window to update after updating moqui) - If your component builds source or runs Spock tests changes will be needed, see the runtime/base-component/example/build.gradle file ### New Features - The makeCondition(Map) methods now support _comp entry for comparison operator, _join entry for join operator, and _list entry for a list of conditions that will be combined with other fields/values in the Map - In FieldValueCondition if the value is a collection and operator is EQUALS set to IN, or if NOT_EQUAL then NOT_IN ### Bug Fixes - Fixed issue with EntityFindBase.condition() where condition break down set ignore case to true - Fixed issue with from/thru date where conversion from String was ignored - Fixed MySQL date-time type for milliseconds; improved example conf for XA - If there are errors in screen actions the error message is displayed instead of rendering the widgets (usually just resulting in more errors) ## Long Term To Do List - aka Informal Road Map - Support local printers, scales, etc in web-based apps using https://qz.io/ - PDF, Office, etc document indexing for wiki attachments (using Apache Tika) - Wiki page version history with full content history diff, etc; store just differences, lib for that? - https://code.google.com/archive/p/java-diff-utils/ - compile group: 'com.googlecode.java-diff-utils', name: 'diffutils', version: '1.3.0' - https://bitbucket.org/cowwoc/google-diff-match-patch/ - compile group: 'org.bitbucket.cowwoc', name: 'diff-match-patch', version: '1.1' - Option for transition to only mount if all response URLs for screen paths exist - Saved form-list Finds - Save settings for a user or group to share (i.e. associate with userId or userGroupId). Allow for any group a user is in. - allow different aggregate/show-total/etc options in select-columns, more complex but makes sense? - add form-list presets in xml file, like saved finds but perhaps more options? allow different aggregate settings in presets? - form-list data prep, more self-contained - X form-list.entity-find element support instead of form-list.@list attribute - _ form-list.service-call - _ also more general form-list.actions element? - form-single.entity-find-one element support, maybe form-single.actions too - Instance Provisioning and Management - embedded and gradle docker client (for docker host or docker swarm) - direct through Docker API - https://docs.docker.com/engine/reference/commandline/dockerd/#bind-docker-to-another-host-port-or-a-unix-socket - https://docs.docker.com/engine/security/https/ - https://docs.docker.com/engine/reference/api/docker_remote_api/ - Support incremental (add/subtract) updates in EntityValue.update() or a variation of it; deterministic DB style - Support seek for faster pagination like jOOQ: https://blog.jooq.org/2013/10/26/faster-sql-paging-with-jooq-using-the-seek-method/ - Improved Distributed Datasource Support - Put all framework, mantle entities in the 4 new groups: transactional, nontransactional, configuration, analytical - Review warnings about view-entities that have members in multiple groups (which may be in different databases) - Test with transactional in H2 and nontransactional, configuration, analytical in OrientDB - Known changes needed - Check distributed foreign keys in create, update, delete (make sure records exist or don't exist in other databases) - Add augment-member to view-entity that can be in a separate database - Make it easier to define view-entity so that caller can treat it mostly as a normal join-based view - Augment query results with optionally cached values from records in a separate database - For conditions on fields from augment-member do a pre-query to get set of PKs, use them in an IN condition on the main query (only support simple AND scenario, error otherwise); sort of like a sub-select - How to handle order by fields on augment-member? Might require separate query and some sort of fancy sorting... - Some sort of EntityDynamicView handling without joins possible? Maybe augment member methods? - DataDocument support across multiple databases, doing something other than one big dynamic join... - Possibly useful - Consider meta-data management features such as versioning and more complete history for nontransactional and configuration, preferably using some sort of more efficient underlying features in the datasource (like Jackrabbit/Oak; any support for this in OrientDB? ElasticSearch keeps version number for concurrency, but no history) - Write EntityFacade interface for ElasticSearch to use like OrientDB? - Support persistence through EntityFacade as nested documents, ie specify that detail/etc entities be included in parent/master document - SimpleFind interface as an alternative to EntityFind for datasources that don't support joins, etc (like OrientDB) and maybe add support for the internal record ID that can be used for faster graph traversal, etc - Try Caffeine JCache at https://github.com/ben-manes/caffeine - do in moqui-caffeine tool component - add multiple threads to SpeedTest.xml? - WebSocket Notifications - Increment message, event, task count labels in header? - DataDocument add flag if new or updated - if new increment count with JS - Side note: DataDocument add info about what was updated somehow? - User Notification - Add Moqui Conf XML elements to configure NotificationMessageListener classes - Listener to send Email with XML Screen to layout (and try out using JSON documents as nested Maps from a screen) - where to configure the email and screen to use? use EmailTemplate/emailTemplateId, but where to specify? - for notifications from DataFeeds can add DataFeed.emailTemplateId (or not, what about toAddresses, etc?) - maybe have a more general way to configure details of topics, including emailTemplateId and screenLocation... - Hazelcast based improvements - configuration for 'microservice' deployments, partitioning services to run on particular servers in a cluster and not others (partition groups or other partition feature?) - can use for reliable WAN service calls like needed for EntitySync? - ie to a remote cluster - different from commercial only WAN replication feature - would be nice for reliable message queue - Quartz Scheduler - can use Hazelcast for scheduled service execution in a cluster, perhaps something on top of, underneath, or instead of Quartz Scheduler? - consider using Hazelcast as a Quartz JobStore, ie: https://github.com/FlavioF/quartz-scheduler-hazelcast-jobstore - DB (or ElasticSearch?) MapStore for persisted (backed up) Hazelcast maps - use MapStore and MapLoader interfaces - see http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#loading-and-storing-persistent-data - https://github.com/mozilla-metrics/bagheera-elasticsearch - older, useful only as a reference for implementing something like this in Moqui - best to implement something using the EntityFacade for easier configuration, etc - see JDBC, etc samples: https://github.com/hazelcast/hazelcast-code-samples/tree/master/distributed-map/mapstore/src/main/java - Persisted Queue for Async Services, etc - use QueueStore interface - see http://docs.hazelcast.org/docs/latest/manual/html-single/index.html#queueing-with-persistent-datastore - use DB? - XML Screens - Screen section-iterate pagination - Screen form automatic client JS validation for more service in-parameters for: number-range, text-length, text-letters, time-range, credit-card.@types - Dynamic Screens (database-driven: DynamicScreen* entities) - Entity Facade - LiquiBase integration (entity.change-set element?) - Add view log, like current change audit log (AuditLogView?) - Improve entity cache auto-clear performance using ehcache search http://ehcache.org/generated/2.9.0/html/ehc-all/#page/Ehcache_Documentation_Set%2Fto-srch_searching_a_cache.html%23 - Artifact Execution Facade - Call ArtifactExecutionFacade.push() (to track, check authz, etc) for other types of artifacts (if/as determined to be helpful), including: Component, Webapp, Screen Section, Screen Form, Screen Form Field, Template, Script, Entity Field - For record-level authz automatically add constraints to queries if the query follows an adequate pattern and authz requires it, or fail authz if can't add constraint - Tools Screens - Auto Screen - Editable data grid, created by form-list, for detail and assoc related entities - Entity - Entity model internal check (relationship, view-link.key-map, ?) - Database meta-data check/report against entity definitions; NOTE: use LiquiBase for this - Script Run (or groovy shell?) - Service - Configure and run chain of services (dynamic wizard) - Artifact Info screens (with in/out references for all) - Screen tree and graph browse screen - Entity usage/reference section - Service usage/reference section on ServiceDetail screen - Screen to install a component (upload and register, load data from it; require special permission for this, not enabled on the demo server) - Data Document and Feed - API (or service?) push outstanding data changes (registration/connection, time trigger; tie to SystemMessage) - API (or service?) receive/persist data change messages - going reverse of generation for DataDocuments... should be interesting - Consumer System Registry - feed transport (for each: supports confirmation?) - WebSocket (use Notification system, based on notificationName (and userId?)) - Service to send email from DataFeed (ie receive#DataFeed implementation), use XML Screen for email content - don't do this directly, do through NotificationMessage, ie the next item... or maybe not, too many parameters for email from too many places related to a DataDocument, may not be flexible enough and may be quite messy - Service (receive#DataFeed impl) to send documents as User NotificationMessages (one message per DataDocument); this is probably the best way to tie a feed to WebSocket notifications for data updates - Use the dataFeedId as the NotificationMessage topic - Use this in HiveMind to send notifications of project, task, and wiki changes (maybe?) - Integration - OData V4 (http://www.odata.org) compliant entity auto REST API - like current but use OData URL structure, query parameters, etc - mount on /odata4 as alternative to existing /rest - generate EDMX for all entities (and exported services?) - use Apache Olingo (http://olingo.apache.org) - see: https://templth.wordpress.com/2015/04/27/implementing-an-odata-service-with-olingo/ - also add an ElasticSearch interface? https://templth.wordpress.com/2015/04/03/handling-odata-queries-with-elasticsearch/ - Generate minimal Data Document based on changes (per TX possible, runs async so not really; from existing doc, like current ES doc) - Update database from Data Document - Data Document UI - show/edit field, rel alias, condition, link - special form for add (edit?) field with 5 drop-downs for relationships, one for field, all updated based on master entity and previous selections - Data Document REST interface - get single by dataDocumentId and PK values for primary entity - search through ElasticSearch for those with associated feed/index - json-schema, RAML, Swagger API defs - generic service for sending Data Document to REST (or other?) end point - Service REST API - allow mapping DataDocument operations as well - Add attribute for resource/method like screen for anonymous and no authz access - OAuth2 Support - Simple OAuth2 for authentication only - https://tools.ietf.org/html/draft-ietf-oauth-v2-27#section-4.4 - use current api key functionality, or expand for limiting tokens to a particular client by registered client ID - Use Apache Oltu, see https://cwiki.apache.org/confluence/display/OLTU/OAuth+2.0+Authorization+Server - Spec at http://tools.ietf.org/html/rfc6749 - http://oltu.apache.org/apidocs/oauth2/reference/org/apache/oltu/oauth2/as/request/package-summary.html - http://search.maven.org/#search|ga|1|org.apache.oltu - https://stormpath.com/blog/build-api-restify-stormpath/ - https://github.com/PROCERGS/login-cidadao/blob/master/app/Resources/doc/en/examplejava.md - https://github.com/swagger-api/swagger-ui/issues/807 - Add authz and token transitions in rest.xml - Support in Service REST API (and entity/master?) - Add examples of auth and service calls using OAuth2 - Add OAuth2 details in Swagger and RAML files - More? - AS2 Client and Server - use OpenAS2 (http://openas2.sourceforge.net, https://github.com/OpenAS2/OpenAs2App)? - tie into SystemMessage for send/receive (with AS2 service for send, code to receive SystemMessage from AS2 server) - Email verification by random code on registration and email change - Login through Google, Facebook, etc - OpenID, SAML, OAuth, ... - https://developers.facebook.com/docs/facebook-login/login-flow-for-web/v2.0 - Workflow that manages activity flow with screens and services attached to activities, and tasks based on them taking users to defined or automatic screen; see BonitaSoft.com Open Source BPM for similar concept; generally workflow without requiring implementation of an entire app once the workflow itself is defined ================================================ FILE: SECURITY.md ================================================ # Security Policy ## Supported Versions The primary supported version for each repository is the latest commit in the master (primary) branch. Moqui Ecosystem projects are maintained by volunteer contributors, primarily people who use and work with the code as part of their employment or professional services. There are periodic community releases but most distributions and releases involve custom code and are managed, internally or publicly, by third parties. Community releases are checkpoint releases, not maintained release branches, and are best for evaluation rather than production use. Moqui uses a 'continous release' approach for managing code repositories. Aside from new (work-in-progress) and archived repositories, the master branch in each repository is considered production ready. Rather than running a centrally dictated release schedule and process, the focus is on keeping master branches in a production ready state so that users may use whatever release process and frequency they prefer. For most use cases we recommend using code directly from the master branch in each repository. For stabilization and periodic updates (instead of continuous) we recommend using a fork for each git repository with an 'upstream' remote pointing to the Moqui Ecosystem repository for easy upstream updates. ## Reporting a Vulnerability To report security issues that should not be disclosed publicly before they are fixed, please use the private **[moqui-board@googlegroups.com](mailto:moqui-board@googlegroups.com)** mailing list. This is setup so that anyone can send messages to it, but only members of the group can read the messages. ## Issues and Pull Requests For more information on submitting issues and pull requests please see the [Issue and Pull Request Guide](https://moqui.org/m/docs/moqui/Issue+and+Pull+Request+Guide) on moqui.org. ================================================ FILE: addons.xml ================================================ ================================================ FILE: build.gradle ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ plugins { id 'com.github.ben-manes.versions' version '0.53.0' id 'org.ajoberstar.grgit' version '5.3.3' } // Filters dependencyUpdates to report only stable (official) releases // Use `./gradlew dependencyUpdates` to check which packages are upgradable in all components dependencyUpdates.resolutionStrategy { componentSelection { rules -> rules.all { ComponentSelection selection -> boolean rejected = ['alpha', 'beta', 'rc', 'cr', 'm', 'b'].any { qualifier -> selection.candidate.version ==~ /(?i).*[.-]${qualifier}[.\d-].*/ } if (rejected) selection.reject('Release candidate') } } } // Run headless so GradleWorkerMain does not steal focus (mostly a macOS annoyance) allprojects { tasks.withType(JavaForkOptions) { jvmArgs '-Djava.awt.headless=true' } repositories { mavenCentral() } } import groovy.util.Node import groovy.xml.XmlParser import groovy.xml.XmlSlurper import org.ajoberstar.grgit.* defaultTasks 'build' def openSearchVersion = '3.4.0' def elasticSearchVersion = '7.10.2' def tomcatHome = '../apache-tomcat' // no longer include version in war file name: def getWarName() { 'moqui-' + childProjects.framework.version + '.war' } def getWarName() { 'moqui.war' } def plusRuntimeName = 'moqui-plus-runtime.war' def execTempDir = 'execwartmp' def moquiRuntime = 'runtime' def moquiConfDev = 'conf/MoquiDevConf.xml' def moquiConfProduction = 'conf/MoquiProductionConf.xml' def allCleanTasks = getTasksByName('clean', true) def allBuildTasks = getTasksByName('build', true) def allTestTasks = getTasksByName('test', true) allTestTasks.each { it.systemProperties << System.properties.subMap(getDefaultPropertyKeys()) } // kill the build -> check -> test dependency, only run tests explicitly and not always on build getTasksByName('check', true).each { it.dependsOn.clear() } Set getComponentTestTasks() { Set testTasks = new LinkedHashSet() for (Project subProject in getSubprojects()) if (subProject.getPath().startsWith(':runtime:component:')) testTasks.addAll(subProject.getTasksByName('test', false)) return testTasks } def getDefaultPropertyKeys() { def defaultProperties = [] Node confXml = new XmlParser().parse(file('framework/src/main/resources/MoquiDefaultConf.xml')) for (Node defaultProperty in confXml.'default-property') { defaultProperties << defaultProperty.'@name' } defaultProperties } // ========== clean tasks ========== task clean(type: Delete) { delete file(warName); delete file(execTempDir); delete file('wartemp'); cleanVersionDetailFiles() } task cleanTempDir(type: Delete) { delete file(execTempDir) } task cleanDb { doLast { if (!file(moquiRuntime).exists()) return delete files(file(moquiRuntime+'/db/derby').listFiles()) - files(moquiRuntime+'/db/derby/derby.properties') delete file(moquiRuntime+'/db/h2') delete file(moquiRuntime+'/db/orientdb/databases') delete fileTree(dir: moquiRuntime+'/txlog', include: '*') cleanElasticSearch(moquiRuntime) } } task cleanLog(type: Delete) { delete fileTree(dir: moquiRuntime+'/log', include: '*') } task cleanSessions(type: Delete) { delete fileTree(dir: moquiRuntime+'/sessions', include: '*') } task cleanLoadSave(type: Delete) { delete file('SaveH2.zip'); delete file('SaveDEFAULT.zip') delete file('SaveTransactional.zip'); delete file('SaveAnalytical.zip'); delete file('SaveOrientDb.zip') delete file('SaveElasticSearch.zip'); delete file('SaveOpenSearch.zip') } task cleanPlusRuntime(type: Delete) { delete file(plusRuntimeName) } task cleanOther(type: Delete) { delete fileTree(dir: '.', includes: ['**/.nbattrs', '**/*~', '**/.#*', '**/.DS_Store', '**/*.rej', '**/*.orig']) } task cleanAll { dependsOn clean, allCleanTasks, cleanDb, cleanLog, cleanSessions, cleanLoadSave, cleanPlusRuntime } // ========== ElasticSearch tasks (for install in runtime/elasticsearch) ========== def cleanElasticSearch(String moquiRuntime) { File osDir = file(moquiRuntime + '/opensearch') String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch') if (file(workDir+'/bin').exists()) { def pidFile = file(workDir+'/pid') if (pidFile.exists()) { String pid = pidFile.getText() logger.lifecycle("${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} running with pid ${pid}, stopping before deleting data then restarting") ['kill', pid].execute(null, file(workDir)).waitFor() ['tail', "--pid=${pid}", '-f', '/dev/null'].execute(null, file(workDir)).waitFor() delete file(workDir+'/data') if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles()) if (pidFile.exists()) delete pidFile startSearch() } else { logger.lifecycle("Found ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} in ${workDir}/bin directory but no pid, deleting data without stop/start; WARNING if ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} is running this will cause problems!") delete file(workDir+'/data') if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles()) } } else { delete file(workDir+'/data') if (file(workDir+'/logs').exists()) delete files(file(workDir+'/logs').listFiles()) } } task downloadOpenSearch { doLast { // NOTE: works with Linux and macOS // TODO: Windows support... String distType = "tar.gz" // https://artifacts.opensearch.org/releases/core/opensearch/1.3.1/opensearch-min-1.3.1-linux-x64.tar.gz String esUrl = "https://artifacts.opensearch.org/releases/core/opensearch/${openSearchVersion}/opensearch-min-${openSearchVersion}-linux-x64.${distType}" String targetDirPath = moquiRuntime + '/opensearch' String esExtraDirPath = targetDirPath + '/opensearch-' + openSearchVersion File targetDir = file(targetDirPath) if (targetDir.exists()) { logger.lifecycle("Found directory at ${targetDirPath}, deleting"); delete targetDir } File zipFile = file("${moquiRuntime}/opensearch-min-${openSearchVersion}-linux-x64.${distType}") if (!zipFile.exists()) { logger.lifecycle("Downloading OpenSearch from ${esUrl}") ant.get(src: esUrl, dest: zipFile) } else { logger.lifecycle("Found OpenSearch archive at ${zipFile.getPath()}, using that instead of downloading") } // the eachFile closure removes the first path from each file, moving everything up a directory, which also requires delete of the extra dirs copy { from distType == "zip" ? zipTree(zipFile) : tarTree(zipFile); into targetDir; eachFile { def pathList = it.getRelativePath().getSegments() as List if (pathList[0] == ".") pathList = pathList.tail() it.setPath(pathList.tail().join("/")) return it } } // make sure there is a logs directory, OpenSearch (just like ES) has start error without it File esLogsDir = file(targetDirPath + '/logs') if (!esLogsDir.exists()) esLogsDir.mkdir() File extraDir = file(esExtraDirPath) if (extraDir.exists()) delete extraDir delete zipFile }} task downloadElasticSearch { doLast { String suffix String distType String osName = System.getProperty("os.name").toLowerCase() if (osName.startsWith("windows")) { suffix = "windows-x86_64.zip" distType = "zip" } else if (osName.startsWith("mac")) { suffix = "darwin-x86_64.tar.gz" distType = "tar.gz" } else { suffix = "linux-x86_64.tar.gz" distType = "tar.gz" } String esUrl = "https://artifacts.elastic.co/downloads/elasticsearch/elasticsearch-oss-${elasticSearchVersion}-no-jdk-${suffix}" String targetDirPath = moquiRuntime + '/elasticsearch' String esExtraDirPath = targetDirPath + '/elasticsearch-' + elasticSearchVersion File targetDir = file(targetDirPath) if (targetDir.exists()) { logger.lifecycle("Found directory at ${targetDirPath}, deleting"); delete targetDir } File zipFile = file("${targetDirPath}-${elasticSearchVersion}.${distType}") if (!zipFile.exists()) { logger.lifecycle("Downloading ElasticSearch from ${esUrl}") ant.get(src: esUrl, dest: zipFile) } else { logger.lifecycle("Found ElasticSearch archive at ${zipFile.getPath()}, using that instead of downloading") } // the eachFile closure removes the first path from each file, moving everything up a directory, which also requires delete of the extra dirs copy { from distType == "zip"? zipTree(zipFile) : tarTree(zipFile); into targetDir; eachFile { def pathList = it.getRelativePath().getSegments() as List if (pathList[0] == ".") pathList = pathList.tail() it.setPath(pathList.tail().join("/")) return it } } // make sure there is a logs directory, ES start error without it File esLogsDir = file(targetDirPath + '/logs') if (!esLogsDir.exists()) esLogsDir.mkdir() File extraDir = file(esExtraDirPath) if (extraDir.exists()) delete extraDir delete zipFile }} /* startElasticSearch old approach, with ES 7.10.2 and OpenSearch never exits, gradle just sits there doing nothing (though same command in terminal does exit) task startElasticSearch(type:Exec) { File osDir = file(moquiRuntime + '/opensearch') workingDir moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch') commandLine (osDir.exists() ? ['./bin/opensearch', '-d', '-p', 'pid'] : ['./bin/elasticsearch', '-d', '-p', 'pid']) ignoreExitValue true onlyIf { (file(moquiRuntime + '/elasticsearch/bin').exists() || file(moquiRuntime + '/opensearch/bin').exists()) && !file(moquiRuntime + '/elasticsearch/pid').exists() && !file(moquiRuntime + '/opensearch/pid').exists() } doFirst { logger.lifecycle("Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in runtime/${osDir.exists() ? 'opensearch' : 'elasticsearch'}") } } */ void startSearch(String moquiRuntime) { File osDir = file(moquiRuntime + '/opensearch') String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch') def pidFile = file(workDir + '/pid') def binFile = file(workDir + '/bin') if (binFile.exists() && !pidFile.exists()) { logger.lifecycle("Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}") ProcessBuilder pb = new ProcessBuilder((osDir.exists() ? './bin/opensearch' : './bin/elasticsearch'), '-d', '-p', 'pid') pb.directory(file(workDir)) pb.redirectOutput() pb.redirectError() pb.inheritIO() logger.lifecycle("Starting process with command ${pb.command()} in ${pb.directory().path}") try { Process proc = pb.start() // logger.lifecycle("ran start waiting...") int result = proc.waitFor() logger.lifecycle("Process finished with ${result}") } catch (Exception e) { logger.lifecycle("Error starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}", e) } } else { if (pidFile.exists()) logger.lifecycle("Not Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}, pid file already exists") if (!binFile.exists()) logger.lifecycle("Not Starting ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}, no ${workDir}/bin directory found") } } task startElasticSearch { doLast { startSearch(moquiRuntime) } } void stopSearch(String moquiRuntime) { File osDir = file(moquiRuntime + '/opensearch') String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch') def pidFile = file(workDir + '/pid') def binFile = file(workDir + '/bin') if (pidFile.exists() && binFile.exists()) { String pid = pidFile.getText() logger.lifecycle("Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir} with pid ${pid}") ["kill", pid].execute(null, file(workDir)).waitFor() if (pidFile.exists()) delete pidFile } else { if (!pidFile.exists()) logger.lifecycle("Not Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} installed in ${workDir}, no pid file found") if (!binFile.exists()) logger.lifecycle("Not Stopping ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'}, no ${workDir}/bin directory found") } } task stopElasticSearch { doLast { stopSearch(moquiRuntime) } } // ========== JDBC driver download tasks ========== task getPostgresJdbc { description = "Download the latest PostgreSQL JDBC driver to runtime/lib" dependsOn 'getRuntime' doLast { def libDir = file(moquiRuntime + '/lib') if (!libDir.exists()) libDir.mkdirs() // Remove existing Postgres JAR files fileTree(dir: libDir, include: 'postgres*.jar').each { it.delete() } // Get the latest version from Maven repository def metadataUrl = 'https://repo1.maven.org/maven2/org/postgresql/postgresql/maven-metadata.xml' def metadataFile = file("${buildDir}/postgresql-maven-metadata.xml") ant.get(src: metadataUrl, dest: metadataFile) def metadata = new XmlSlurper().parse(metadataFile) def latestVersion = metadata.versioning.latest.text() metadataFile.delete() // Download the latest version def downloadUrl = "https://repo1.maven.org/maven2/org/postgresql/postgresql/${latestVersion}/postgresql-${latestVersion}.jar" def jarFile = file("${libDir}/postgresql-${latestVersion}.jar") logger.lifecycle("Downloading PostgreSQL JDBC driver ${latestVersion} from ${downloadUrl}") ant.get(src: downloadUrl, dest: jarFile) logger.lifecycle("Downloaded PostgreSQL JDBC driver to ${jarFile}") } } task getMySqlJdbc { description = "Download the latest MySQL JDBC driver to runtime/lib" dependsOn 'getRuntime' doLast { def libDir = file(moquiRuntime + '/lib') if (!libDir.exists()) libDir.mkdirs() // Remove existing MySQL connector JAR files fileTree(dir: libDir, include: 'mysql-connector*.jar').each { it.delete() } // Get the latest version from Maven repository def metadataUrl = 'https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/maven-metadata.xml' def metadataFile = file("${buildDir}/mysql-connector-j-maven-metadata.xml") ant.get(src: metadataUrl, dest: metadataFile) def metadata = new XmlSlurper().parse(metadataFile) def latestVersion = metadata.versioning.latest.text() metadataFile.delete() // Download the latest version def downloadUrl = "https://repo1.maven.org/maven2/com/mysql/mysql-connector-j/${latestVersion}/mysql-connector-j-${latestVersion}.jar" def jarFile = file("${libDir}/mysql-connector-j-${latestVersion}.jar") logger.lifecycle("Downloading MySQL JDBC driver ${latestVersion} from ${downloadUrl}") ant.get(src: downloadUrl, dest: jarFile) logger.lifecycle("Downloaded MySQL JDBC driver to ${jarFile}") } } // ========== development tasks ========== task setupIntellij { description = "Adds all XML catalog items to intellij to enable autocomplete" doLast { def ideaDir = "${rootDir}/.idea" def parser = new XmlSlurper() parser.setFeature("http://apache.org/xml/features/disallow-doctype-decl", false) parser.setFeature("http://apache.org/xml/features/nonvalidating/load-external-dtd", false) def catalogEntries = parser.parse(file("${rootDir}/framework/xsd/framework-catalog.xml")) .system .list() .stream() .map { [url: it.@systemId, location: "\$PROJECT_DIR\$/framework/xsd/${it.@uri}"] } .collect(java.util.stream.Collectors.toList()) mkdir ideaDir def rawXml def miscFile = file("${ideaDir}/misc.xml") if (!miscFile.exists()) { def builder = new groovy.xml.StreamingMarkupBuilder() builder.encoding = 'UTF-8' rawXml = builder.bind { project(version: '4') { component(name: 'ExternalStorageConfigurationManager', enabled: true) component(name: 'ProjectResources') { catalogEntries.each { resource(url: it.url, location: it.location) } } } } } else { def projectNode = parser.parse(miscFile) def resourcesNode = projectNode.children().find { it.@name == 'ProjectResources' } if (resourcesNode.size() == 0) { projectNode.appendNode { component(name: 'ProjectResources') { catalogEntries.each { resource(url: it.url, location: it.location) } } } } else { catalogEntries.each { cat -> def existingEntry = resourcesNode.children().find { it.@url == cat.url } if (existingEntry.size() > 0) { existingEntry.replaceNode { resource(url: cat.url, location: cat.location) } } else { resourcesNode.appendNode { resource(url: cat.url, location: cat.location) } } } } rawXml = projectNode } def misc = groovy.xml.XmlUtil.serialize(rawXml) miscFile.write(misc) } } task setupVscode { description = "Configures VS Code settings with runtime directory exclusions" doLast { def settingsFile = file("${rootDir}/.vscode/settings.json") mkdir settingsFile.parentFile def settings = (settingsFile.exists() && settingsFile.length() > 0) ? new groovy.json.JsonSlurper().parseText(settingsFile.text) : [:] // Disable gitignore for search (so we can manually exclude build/runtime dirs) settings['search.useIgnoreFiles'] = false // Add search.exclude if missing if (!settings['search.exclude']) settings['search.exclude'] = [:] // Add exclusion patterns settings['search.exclude']['**/build'] = true settings['search.exclude']['runtime/{log,sessions,txlog,db,elasticsearch,opensearch}'] = true // Write formatted JSON settingsFile.text = groovy.json.JsonOutput.prettyPrint(groovy.json.JsonOutput.toJson(settings)) logger.lifecycle("VS Code settings updated at ${settingsFile}") } } // ========== test task ========== // NOTE1: to run startElasticSearch before the first test task add it as a dependency to all test tasks // NOTE2: to run stopElasticSearch after the last test task make all test tasks finalizedBy stopElasticSearch getTasksByName('test', true).each { if (it.path != ':test') { // logger.lifecycle("Adding dependencies for test task ${it.getPath()}") it.dependsOn(startElasticSearch) it.finalizedBy(stopElasticSearch) } } // ========== check/update tasks ========== task getRuntime { description = "If the runtime directory does not exist get it using settings in myaddons.xml or addons.xml; also check default components in myaddons.xml (addons.@default) and download any missing" doLast { checkRuntimeDirAndDefaults(project.hasProperty('locationType') ? locationType : null) } } task checkRuntime { doLast { if (!file('runtime').exists()) throw new GradleException("Required 'runtime' directory not found. Use 'gradle getRuntime' or 'gradle getComponent' or manually clone the moqui-runtime repository. This must be done in a separate Gradle run before a build so Gradle can find and run build tasks.") } } task gitPullAll { description = "Do a git pull to update moqui, runtime, and each installed component (for each where a .git directory is found)" doLast { // framework and runtime if (file(".git").exists()) { doGitPullWithStatus(file('.').path) } if (file("runtime/.git").exists()) { doGitPullWithStatus(file('runtime').path) } // all directories under runtime/component for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) { doGitPullWithStatus(compDir.path) } } } def doGitPullWithStatus(def gitDir) { try { def curGrgit = Grgit.open(dir: gitDir) logger.lifecycle("\nPulling ${gitDir} (branch:${curGrgit.branch.current()?.name}, tracking:${curGrgit.branch.current()?.trackingBranch?.name})") def beforeHead = curGrgit.head() curGrgit.pull() def afterHead = curGrgit.head() if (beforeHead == afterHead) { logger.lifecycle("Already up-to-date.") } else { List commits = curGrgit.log { range(beforeHead, afterHead) } for (Commit commit in commits) logger.lifecycle("- ${commit.getAbbreviatedId(7)} by ${commit.committer?.name}: ${commit.shortMessage}") } } catch (Throwable t) { logger.error(t.message) } } task gitCheckoutAll { description = "Do a git checkout on moqui, runtime, and each installed component (for each where a .git directory is found); use -Pbranch= (required) to specify a branch, use -Pcreate=true to create branches with the given name" doLast { if (!project.hasProperty('branch')) throw new InvalidUserDataException("No branch property specified (use -Pbranch=...)") String curBranch = branch String curTag = (project.hasProperty('tag') ? tag : null) ?: curBranch boolean createBranch = false if (project.hasProperty('create') && create == 'true') createBranch = true List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) for (String gitDir in gitDirectories) { def curGrgit = Grgit.open(dir: gitDir) def branchList = curGrgit.branch.list(mode: org.ajoberstar.grgit.operation.BranchListOp.Mode.ALL) def tagList = curGrgit.tag.list() def targetBranch = branchList.find({ it.name == curBranch }) def targetTag = tagList.find({ it.name == curTag }) if (targetBranch == null && targetTag == null) { def originBranch = branchList.find({ it.name == 'origin/' + curBranch }) if (originBranch != null) { logger.lifecycle("In ${gitDir} branch ${curBranch} not found but found ${originBranch.name}, creating local branch tracking that branch") targetBranch = curGrgit.branch.add(name: curBranch, startPoint: originBranch, mode: org.ajoberstar.grgit.operation.BranchAddOp.Mode.TRACK) } } if (createBranch || targetBranch != null || targetTag != null) { if (targetTag != null) { if (createBranch && curBranch != curTag) { logger.lifecycle("== Git checkout ${gitDir} tag ${curTag} and create branch ${curBranch}") try { curGrgit.checkout(branch: curBranch, createBranch: true, startPoint: targetTag) } catch (Exception e) { logger.lifecycle("Checkout error", e) } } else { logger.lifecycle("== Git checkout ${gitDir} tag ${curTag}") try { curGrgit.checkout(branch: curTag, createBranch: false) } catch (Exception e) { logger.lifecycle("Checkout error", e) } } } else { logger.lifecycle("== Git checkout ${gitDir} branch ${curBranch} create ${createBranch}") try { curGrgit.checkout(branch: curBranch, createBranch: createBranch) } catch (Exception e) { logger.lifecycle("Checkout error", e) } } } else { logger.lifecycle("* No branch or tag '${curBranch}' in ${gitDir}\nBranches: ${branchList.collect({it.name})}\nTags: ${tagList.collect({it.name})}") } logger.lifecycle("") } } } task gitStatusAll { description = "Do a git status to check moqui, runtime, and each installed component (for each where a .git directory is found)" doLast { List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) for (String gitDir in gitDirectories) { def curGrgit = Grgit.open(dir: gitDir) logger.lifecycle("\nGit status for ${gitDir} (branch:${curGrgit.branch.current()?.name}, tracking:${curGrgit.branch.current()?.trackingBranch?.name})") try { if (curGrgit.remote.list().find({ it.name == 'upstream'})) { def upstreamAhead = curGrgit.log { range curGrgit.resolve.toCommit('refs/remotes/upstream/master'), curGrgit.resolve.toCommit('refs/remotes/origin/master') } if (upstreamAhead) logger.lifecycle("- origin/master ${upstreamAhead.size()} commits ahead of upstream/master") } } catch (Exception e) { logger.error("Error finding commits ahead of upstream", e) } try { def masterLatest = curGrgit.resolve.toCommit('refs/remotes/origin/master') if (masterLatest == null) { logger.error("No origin/master branch exists, can't determine unpushed commits") } else { def unpushed = curGrgit.log { range masterLatest, curGrgit.resolve.toCommit('HEAD') } if (unpushed) logger.lifecycle("--- ${unpushed.size()} commits unpushed (ahead of origin/master)") for (Commit commit in unpushed) logger.lifecycle(" - ${commit.getAbbreviatedId(8)} - ${commit.shortMessage}") } } catch (Exception e) { logger.error("Error finding unpushed commits", e) } def curStatus = curGrgit.status() if (curStatus.isClean()) logger.lifecycle("* nothing to commit, working directory clean") if (curStatus.staged.added || curStatus.staged.modified || curStatus.staged.removed) logger.lifecycle("--- Changes to be committed::") for (String fn in curStatus.staged.added) logger.lifecycle(" added: ${fn}") for (String fn in curStatus.staged.modified) logger.lifecycle(" modified: ${fn}") for (String fn in curStatus.staged.removed) logger.lifecycle(" removed: ${fn}") if (curStatus.unstaged.added || curStatus.unstaged.modified || curStatus.unstaged.removed) logger.lifecycle("--- Changes not staged for commit:") for (String fn in curStatus.unstaged.added) logger.lifecycle(" added: ${fn}") for (String fn in curStatus.unstaged.modified) logger.lifecycle(" modified: ${fn}") for (String fn in curStatus.unstaged.removed) logger.lifecycle(" removed: ${fn}") } } } task gitUpstreamAll { description = "Do a git pull upstream:master for moqui, runtime, and each installed component (for each where a .git directory is found and has a remote called upstream)" doLast { String remoteName = project.hasProperty('remote') ? remote : 'upstream' List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) for (String gitDir in gitDirectories) { def curGrgit = Grgit.open(dir: gitDir) if (curGrgit.remote.list().find({ it.name == remoteName})) { logger.lifecycle("\nGit merge ${remoteName} for ${gitDir}") curGrgit.pull(remote: remoteName, branch: 'master') } else { logger.lifecycle("\nNo ${remoteName} remote for ${gitDir}") } } } } task gitTagAll { description = "Do a git add or remove tag on the currently checked out commit in moqui, runtime, and each installed component" doLast { def tagName = (project.hasProperty('tag')) ? tag : null; def tagMessage = (project.hasProperty('message')) ? message : null; boolean removeTags = (project.hasProperty('remove') && remove == 'true') boolean pushTags = (project.hasProperty('push') && push == 'true') // Users can simply push tags to the remote if (tagName == null && pushTags == false) throw new InvalidUserDataException("No tag property specified (use -Ptag=...) and No push tag specified (use -Ppush=true)") List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) def frameworkDir = gitDirectories.first() for (String gitDir in gitDirectories) { def relativePath = "."+gitDir.minus(frameworkDir) def curGrgit = Grgit.open(dir: gitDir) def branchName = curGrgit.branch.current().name def commit = curGrgit.log(maxCommits: 1).find() if (tagName != null) { def tagList = curGrgit.tag.list() def targetTag = tagList.find({ it.name == tagName }) if (targetTag == null) { if (removeTags) { logger.lifecycle("== Git tag '${tagName}' not found in ${branchName} of ${relativePath} ... skipping") } else { curGrgit.tag.add(name: tagName, message: tagMessage ?: "Tagging version ${tagName}") logger.lifecycle("== Git tagging commit ${commit.abbreviatedId} - '${commit.shortMessage}' by '${commit.author.name}' in ${branchName} of ${relativePath}") } } else { if (removeTags) { curGrgit.tag.remove(names: [tagName]) logger.lifecycle("== Git removing tag '${tagName}' in ${branchName} of ${relativePath}") } else { logger.lifecycle("== Git tag '${tagName}' already exists in ${branchName} of ${relativePath}, skipping...") } } } if (pushTags) { if (removeTags) { curGrgit.push(refsOrSpecs: [':refs/tags/'+tagName]) } else { curGrgit.push(tags: true) } logger.lifecycle("== Git pushing tag changes to remote of ${relativePath}") } } } } task gitDiffTagsAll { description = "Do a git diff between two tags in the currently checked out branch in moqui, runtime, and each installed component" doLast { if (!project.hasProperty('taga') || taga == null) throw new InvalidUserDataException("No taga property specified (use -Ptaga=...)") // If tagb is not passed, we assume HEAD def tagb = (project.hasProperty('tagb') && tagb != null) ? tagb : "HEAD"; logger.lifecycle("== Git diffing tags ${taga} and ${tagb}") List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) def frameworkDir = gitDirectories.first() for (String gitDir in gitDirectories) { def relativePath = "."+gitDir.minus(frameworkDir) def grgit = Grgit.open(dir: gitDir) def tagList = grgit.tag.list() def tagaCommit = tagList.find({ it.name == taga }) def tagbCommit = tagList.find({ it.name == tagb }) logger.lifecycle("${relativePath}") if ((taga == "HEAD" || tagaCommit != null) && (tagb == "HEAD" || tagbCommit != null)) { grgit.log { range taga, tagb }.each { logger.lifecycle(" ${it.abbreviatedId} - ${it.shortMessage}") } } } } } task gitMergeAll { description = "Do a git diff between two tags in the currently checked out branch in moqui, runtime, and each installed component" doLast { def branchName = (project.hasProperty('branch')) ? branch : null; def tagName = (project.hasProperty('tag')) ? tag : null; def mergeMode = (project.hasProperty('mode')) ? mode : null; def mergeMessage = (project.hasProperty('message')) ? message : null; def pushMerge = (project.hasProperty('push')) ? push : null; List gitDirectories = [] if (file(".git").exists()) gitDirectories.add(file('.').path) if (file("runtime/.git").exists()) gitDirectories.add(file('runtime').path) for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) gitDirectories.add(compDir.path) def frameworkDir = gitDirectories.first() for (String gitDir in gitDirectories) { def relativePath = "."+gitDir.minus(frameworkDir) logger.lifecycle("${relativePath}") def grgit = Grgit.open(dir: gitDir) def currentBranch = grgit.branch.current()?.name; if (branchName == currentBranch) continue def doMerge = false; if (branchName && grgit.branch.list().find({ it.name == branchName }) != null) { doMerge = true; } if (tagName && grgit.tag.list().find({ it.name == tagName }) != null) { doMerge = true; } if (doMerge) { grgit.merge(head: branchName ?: tagName, mode: mergeMode, message: mergeMessage) logger.lifecycle(" Merging ${branchName ?: tagName} into ${currentBranch}") } if (pushMerge) { grgit.push(); logger.lifecycle(" Pushing merge") } } } } // ========== run tasks ========== task run(type: JavaExec) { dependsOn checkRuntime, allBuildTasks, cleanTempDir workingDir = '.'; jvmArgs = ['-server', '-XX:-OmitStackTraceInFastThrow'] systemProperties = ['moqui.conf':moquiConfDev, 'moqui.runtime':moquiRuntime] // NOTE: this is a hack, using -jar instead of a class name, and then the first argument is the name of the jar file mainClass = '-jar'; args = [warName] } task runProduction(type: JavaExec) { dependsOn checkRuntime, allBuildTasks, cleanTempDir workingDir = '.'; jvmArgs = ['-server', '-Xms1024M'] systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime] mainClass = '-jar'; args = [warName] } task load(type: JavaExec) { description = "Run Moqui to load data; to specify data types use something like: gradle load -Ptypes=seed,seed-initial,install" dependsOn checkRuntime, allBuildTasks systemProperties = ['moqui.conf':moquiConfDev, 'moqui.runtime':moquiRuntime] workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar' args = [warName, 'load', (project.properties.containsKey('types') ? "types=${types}" : "types=all")] } task loadSeed(type: JavaExec) { dependsOn checkRuntime, allBuildTasks systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime] workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar' args = [warName, 'load', (project.properties.containsKey('types') ? "types=${types}" : "types=seed")] } task loadSeedInitial(type: JavaExec) { dependsOn checkRuntime, allBuildTasks systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime] workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar' args = [warName, 'load', (project.properties.containsKey('types') ? "types=${types}" : "types=seed,seed-initial")] } task loadProduction(type: JavaExec) { dependsOn checkRuntime, allBuildTasks systemProperties = ['moqui.conf':moquiConfProduction, 'moqui.runtime':moquiRuntime] workingDir = '.'; jvmArgs = ['-server']; mainClass = '-jar' args = [warName, 'load', (project.properties.containsKey('types') ? "types=${types}" : "types=seed,seed-initial,install")] } task saveDb { doLast { if (file(moquiRuntime+'/db/derby/moqui').exists()) ant.zip(destfile: 'SaveDerby.zip') { fileset(dir: moquiRuntime+'/db/derby/moqui') { include(name: '**/*') } } if (file(moquiRuntime+'/db/h2').exists()) ant.zip(destfile: 'SaveH2.zip') { fileset(dir: moquiRuntime+'/db/h2') { include(name: '**/*') } } if (file(moquiRuntime+'/db/orientdb/databases').exists()) ant.zip(destfile: 'SaveOrientDb.zip') { fileset(dir: moquiRuntime+'/db/orientdb/databases') { include(name: '**/*') } } File osDir = file(moquiRuntime + '/opensearch') String workDir = moquiRuntime + (osDir.exists() ? '/opensearch' : '/elasticsearch') if (file(workDir+'/data').exists()) { if (file(workDir+'/bin').exists()) { def pidFile = file(workDir+'/pid') if (pidFile.exists()) { String pid = pidFile.getText() logger.lifecycle("ElasticSearch running with pid ${pid}, stopping before saving data then restarting") ['kill', pid].execute(null, file(workDir)).waitFor() ['tail', "--pid=${pid}", '-f', '/dev/null'].execute(null, file(workDir)).waitFor() if (pidFile.exists()) delete pidFile ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } } startSearch(moquiRuntime) } else { logger.lifecycle("Found ${osDir.exists() ? 'OpenSearch' : 'ElasticSearch'} ${workDir}/bin directory but no pid, saving data without stop/start; WARNING if ElasticSearch is running this will cause problems!") ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } } } } else { ant.zip(destfile: (osDir.exists() ? 'SaveOpenSearch.zip' : 'SaveElasticSearch.zip')) { fileset(dir: workDir+'/data') { include(name: '**/*') } } } } } } task loadSave { description = "Clean all, build and load, then save database (H2, Derby), OrientDB, and OpenSearch/ElasticSearch files; to be used before reloadSave" dependsOn cleanAll, load, saveDb } task reloadSave { description = "After a loadSave clean database (H2, Derby), OrientDB, and ElasticSearch files and reload from saved copy" dependsOn cleanTempDir, cleanDb, cleanLog, cleanSessions dependsOn allBuildTasks doLast { if (file('SaveDerby.zip').exists()) copy { from zipTree('SaveDerby.zip'); into file(moquiRuntime+'/db/derby/moqui') } if (file('SaveH2.zip').exists()) copy { from zipTree('SaveH2.zip'); into file(moquiRuntime+'/db/h2') } if (file('SaveOrientDb.zip').exists()) copy { from zipTree('SaveOrientDb.zip'); into file(moquiRuntime+'/db/orientdb/databases') } if (file('SaveElasticSearch.zip').exists()) { String esDir = moquiRuntime+'/elasticsearch' if (file(esDir+'/bin').exists()) { def pidFile = file(esDir+'/pid') if (pidFile.exists()) { String pid = pidFile.getText() logger.lifecycle("ElasticSearch running with pid ${pid}, stopping before restoring data then restarting") ['kill', pid].execute(null, file(esDir)).waitFor() ['tail', "--pid=${pid}", '-f', '/dev/null'].execute(null, file(esDir)).waitFor() copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') } if (pidFile.exists()) delete pidFile ['./bin/elasticsearch', '-d', '-p', 'pid'].execute(null, file(esDir)).waitFor() } else { logger.lifecycle("Found ElasticSearch ${esDir}/bin directory but no pid, saving data without stop/start; WARNING if ElasticSearch is running this will cause problems!") copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') } } } else { copy { from zipTree('SaveElasticSearch.zip'); into file(moquiRuntime+'/elasticsearch/data') } } } if (file('SaveOpenSearch.zip').exists()) { String esDir = moquiRuntime+'/opensearch' if (file(esDir+'/bin').exists()) { def pidFile = file(esDir+'/pid') if (pidFile.exists()) { String pid = pidFile.getText() logger.lifecycle("OpenSearch running with pid ${pid}, stopping before restoring data then restarting") ['kill', pid].execute(null, file(esDir)).waitFor() ['tail', "--pid=${pid}", '-f', '/dev/null'].execute(null, file(esDir)).waitFor() copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') } if (pidFile.exists()) delete pidFile ['./bin/opensearch', '-d', '-p', 'pid'].execute(null, file(esDir)).waitFor() } else { logger.lifecycle("Found OpenSearch ${esDir}/bin directory but no pid, saving data without stop/start; WARNING if OpenSearch is running this will cause problems!") copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') } } } else { copy { from zipTree('SaveOpenSearch.zip'); into file(moquiRuntime+'/opensearch/data') } } } } } // ========== deploy tasks ========== task deployTomcat { doLast { // remove runtime directory, may have been added for logs/etc delete file(tomcatHome + '/runtime') // remove ROOT directory and war to avoid conflicts delete file(tomcatHome + '/webapps/ROOT') delete file(tomcatHome + '/webapps/ROOT.war') // copy the war file to ROOT.war copy { from file(warName); into file(tomcatHome + '/webapps'); rename(warName, 'ROOT.war') } } } task plusRuntimeWarTemp { dependsOn checkRuntime, allBuildTasks doLast { File wartempFile = file('wartemp') if (wartempFile.exists()) delete wartempFile // make version detail files makeVersionDetailFiles() // unzip the "moqui-${version}.war" file to the wartemp directory copy { from zipTree(warName); into wartempFile } // copy runtime directory (with a few exceptions) into a runtime directory in the war copy { from fileTree(dir: '.', include: moquiRuntime+'/**', excludes: ['**/*.jar', '**/build', moquiRuntime+'/classes/**', moquiRuntime+'/lib/**', moquiRuntime+'/log/**', moquiRuntime+'/sessions/**']) into wartempFile } // copy the jar files from runtime/lib copy { from fileTree(dir: moquiRuntime+'/lib', include: '**/*.jar').files into 'wartemp/WEB-INF/lib' } // copy the classpath resource files from runtime/classes copy { from fileTree(dir: moquiRuntime+'/classes', include: '**/*') into 'wartemp/WEB-INF/classes' } // copy the jar files from components copy { from fileTree(dir: moquiRuntime+'/base-component', include: '**/*.jar').files into 'wartemp/WEB-INF/lib' } copy { from fileTree(dir: moquiRuntime+'/component', include: '**/*.jar', exclude: '**/librepo/*.jar').files into 'wartemp/WEB-INF/lib' duplicatesStrategy DuplicatesStrategy.WARN } copy { from fileTree(dir: moquiRuntime+'/ecomponent', include: '**/*.jar', exclude: '**/librepo/*.jar').files into 'wartemp/WEB-INF/lib' duplicatesStrategy DuplicatesStrategy.WARN } // add MoquiInit.properties fresh copy, just in case it was changed copy { from file('MoquiInit.properties') into 'wartemp/WEB-INF/classes' } // add Procfile to root copy { from file('Procfile') into 'wartemp' } // special case: copy elasticsearch plugin/module jars (needed for ES installed in runtime/elasticsearch if (file(moquiRuntime+'/elasticsearch').exists()) copy { from fileTree(dir: '.', include: moquiRuntime+'/elasticsearch/**/*.jar') into wartempFile } // special case: copy opensearch plugin/module jars (needed for ES installed in runtime/opensearch if (file(moquiRuntime+'/opensearch').exists()) copy { from fileTree(dir: '.', include: moquiRuntime+'/opensearch/**/*.jar') into wartempFile } // special case: copy jackrabbit standalone jar (if exists) copy { from fileTree(dir: moquiRuntime + '/jackrabbit', include: 'jackrabbit-standalone-*.jar').files; into 'wartemp/' + moquiRuntime + '/jackrabbit' } // clean up version detail files cleanVersionDetailFiles() } } task addRuntime(type: Zip) { description = "Create moqui-plus-runtime.war file from the moqui.war file and the runtime directory embedded in it" dependsOn checkRuntime, allBuildTasks, plusRuntimeWarTemp archiveFileName = plusRuntimeName destinationDirectory = file('.') from file('wartemp') doFirst { if (file(plusRuntimeName).exists()) delete file(plusRuntimeName) } doLast { delete file('wartemp') } } // don't use this task directly, use addRuntimeTomcat which calls this task deployTomcatRuntime { doLast { delete file(tomcatHome + '/runtime'); delete file(tomcatHome + '/webapps/ROOT'); delete file(tomcatHome + '/webapps/ROOT.war') copy { from file(plusRuntimeName); into file(tomcatHome + '/webapps'); rename(plusRuntimeName, 'ROOT.war') } } } task addRuntimeTomcat { dependsOn addRuntime dependsOn deployTomcatRuntime } // ========== component tasks ========== task getDefaults { description = "Get a component using specified location type, also check/get all components it depends on; requires component property; locationType property optional (defaults to git if there is a .git directory, otherwise to current)" doLast { String curLocationType = file('.git').exists() ? 'git' : 'current' if (project.hasProperty('locationType')) curLocationType = locationType getComponentTop(curLocationType) } } task getComponent { description = "Get a component using specified location type, also check/get all components it depends on; requires component property; locationType property optional (defaults to git if there is a .git directory, otherwise to current)" doLast { String curLocationType = file('.git').exists() ? 'git' : 'current' if (project.hasProperty('locationType')) curLocationType = locationType getComponentTop(curLocationType) } } task createComponent { description = "Create a new component. Set new component name with -Pcomponent=new_component_name (based on the moqui start component here: https://github.com/moqui/start)" doLast { String curLocationType = file('.git').exists() ? 'git' : 'current' if (project.hasProperty('locationType')) curLocationType = locationType if (project.hasProperty('component')) { checkRuntimeDirAndDefaults(curLocationType) Set compsChecked = new TreeSet() def startComponentName = 'start' File componentDir = getComponent(startComponentName, curLocationType, parseAddons(), parseMyaddons(), compsChecked) if (componentDir?.exists()) { logger.lifecycle("Got component start, dependent components checked: ${compsChecked}") def newComponent = file("runtime/component/${component}") def renameSuccessful = componentDir.renameTo(newComponent) if (!renameSuccessful) { logger.error("Failed to rename component start to ${component}. Try removing the existing component directory first or giving this program write permissions.") } else { logger.lifecycle("Renamed component start to ${component}") } print "Updated file: " newComponent.eachFileRecurse(groovy.io.FileType.FILES) { file -> try { // If file name is startComponentName.* rename to component.* if (file.name.startsWith(startComponentName)) { String newFileName = (file.name - startComponentName) newFileName = component + newFileName File newFile = new File(file.parent, newFileName) file.renameTo(newFile) file = newFile print "${file.path - newComponent.path - '/'}, " } String content = file.text if (content.contains(startComponentName)) { content = content.replaceAll(startComponentName, component) file.text = content print "${file.path - newComponent.path - '/'}, " } } catch (IOException e) { println "Error processing file ${file.path}: ${e.message}" } } print "\n\n" println "Select rest api (r), screens (s), or both (B):" def componentInput = System.in.newReader().readLine() if (componentInput == 'r') { new File(newComponent, 'screen').deleteDir() new File(newComponent, 'template').deleteDir() new File(newComponent, 'data/AppSeedData.xml').delete() new File(newComponent, 'MoquiConf.xml').delete() def moquiConf = new File(newComponent, 'MoquiConf.xml') moquiConf.append("\n" + "\n" + "\n" + "") println "Selected rest api so, deleted screen, template, and AppSeedData.xml\n" } else if (componentInput == 's') { new File(newComponent, "services/${component}.rest.xml").delete() new File(newComponent, 'data/ApiSeedData.xml').delete() println "Selected screens so, deleted rest api and ApiSeedData.xml\n" } else if (componentInput == 'b' || componentInput == 'B' || componentInput == '') { println "Selected both rest api and screens\n" } else { println "Invalid input. Try again" newComponent.deleteDir() return } println "Are you going to code or test in groovy or java [y/N]" def codeInput = System.in.newReader().readLine() if (codeInput == 'y' || codeInput == 'Y') { println "Keeping src folder\n" } else if (codeInput == 'n' || codeInput == 'N' || codeInput == '') { new File(newComponent, 'src').deleteDir() new File(newComponent, 'build.grade').delete() println "Selected no so, deleted src and build.grade\n" } else { println "Invalid input. Try again" newComponent.deleteDir() return } println "Setup a git repository [Y/n]" def gitInput = System.in.newReader().readLine() if (gitInput == 'y' || gitInput == 'Y' || gitInput == '') { new File(newComponent, '.git').deleteDir() // Setup git repository def grgit = Grgit.init(dir: newComponent.path) grgit.add(patterns: ['.']) // Can't get signing to work easily. If signing works well then might as well commit // grgit.commit(message: 'Initial commit') println "Selected yes, so git is initialized\n" println "To setup the git remote origin, type the git remote url or enter to skip" def remoteUrl = System.in.newReader().readLine() if (remoteUrl != '') { grgit.remote.add(name: 'origin', url: remoteUrl) println "Run the following to push the git repository:\ncd runtime/component/${component} && git commit -m 'Initial commit' && git push && cd ../../.." } else { println "Run the following to push the git repository:\ncd runtime/component/${component} && git commit -m 'Initial commit' && git remote add origin git@github.com:yourgroup/${component} && git push && cd ../../.." } } else if (gitInput == 'n' || gitInput == 'N') { new File(newComponent, '.git').deleteDir() println "Selected no, so git is not initialized\n" println "Run the following to push the git repository:\ncd runtime/component/${component} && git commit -m 'Initial commit' && git remote add origin git@github.com:yourgroup/${component} && git push && cd ../../.." } else { println "Invalid input. Try again" newComponent.deleteDir() return } println "Add to myaddons.xml [Y/n]" def myaddonsInput = System.in.newReader().readLine() if (myaddonsInput == 'y' || myaddonsInput == 'Y' || myaddonsInput == '') { def myaddonsFile = file('myaddons.xml') if (myaddonsFile.exists()){ // Iterate through myaddons file and delete the lines that are // Read the lines from the file def lines = myaddonsFile.readLines() // Filter out the lines that contain def filteredLines = lines.findAll { !it.contains("") } // Write the filtered lines back to the file myaddonsFile.text = filteredLines.join('\n') } else { println "myaddons.xml not found. Creating one\nEnter repository github (g), github-ssh (GS), bitbucket (b), or bitbucket-ssh (bs)" def repositoryInput = System.in.newReader().readLine() myaddonsFile.append("") } println "Enter the component git repository group" def groupInput = System.in.newReader().readLine() println "Enter the component git repository name" def nameInput = System.in.newReader().readLine() // get git branch def grgit = Grgit.open(dir: newComponent.path) def branch = grgit.branch.current().name myaddonsFile.append("\n ") myaddonsFile.append("\n") } else if (myaddonsInput == 'n' || myaddonsInput == 'N') { println "Selected no, so component not added to myaddons.xml\n" } else { println "Invalid input. Try again" newComponent.deleteDir() return } } } else { throw new InvalidUserDataException("No component property specified") } } } task getCurrent { description = "Get the current archive for a component, also check each component it depends on and if not present get its current archive; requires component property" doLast { getComponentTop('current') } } task getRelease { description = "Get the release archive for a component, also check each component it depends on and if not present get its configured release archive; requires component property" doLast { getComponentTop('release') } } task getBinary { description = "Get the binary release archive for a component, also check each component it depends on and if not present get its configured release archive; requires component property" doLast { getComponentTop('binary') } } task getGit { description = "Clone the git repository for a component, also check each component it depends on and if not present clone its git repository; requires component property" doLast { getComponentTop('git') } } task getDepends { description = "Check/Get all dependencies for all components in runtime/component; locationType property optional (defaults to git if there is a .git directory, otherwise to current)" doLast { String curLocationType = file('.git').exists() ? 'git' : 'current' if (project.hasProperty('locationType')) curLocationType = locationType checkAllComponentDependencies(curLocationType) } } task getComponentSet { description = "Gets all components in the specied componentSet using specified location type, also check/get all components it depends on; requires -Pcomponent property; -PlocationType property optional (defaults to git if there is a .git directory, otherwise to current)" doLast { String curLocationType = file('.git').exists() ? 'git' : 'current' if (project.hasProperty('locationType')) curLocationType = locationType if (!project.hasProperty('componentSet')) throw new InvalidUserDataException("No componentSet property specified") checkRuntimeDirAndDefaults(curLocationType) Set compsChecked = new TreeSet() loadComponentSet((String) componentSet, curLocationType, parseAddons(), parseMyaddons(), compsChecked) logger.lifecycle("Got component-set ${componentSet}, got or checked components: ${compsChecked}") } } task zipComponents { description = "Create a .zip archive file for each component in runtime/component" dependsOn allBuildTasks doLast { for (File compDir in findComponentDirs()) createComponentZip(compDir) } } task zipComponent { description = "Create a .zip archive file a single component in runtime/component; requires component property" dependsOn allBuildTasks doLast { if (!project.hasProperty('component')) throw new InvalidUserDataException("No component property specified") createComponentZip(file('runtime/component/' + component)) } } // ========== utility methods ========== def createComponentZip(File compDir) { File compXmlFile = file("${compDir.path}/component.xml") if (!compXmlFile.exists()) { logger.lifecycle("No component.xml file found at ${compXmlFile.path}, not creating component zip") return } Node compXml = new XmlParser().parse(compXmlFile) File zipFile = file("${compDir.parentFile.path}/${compXml.'@name'}${compXml.'@version' ? '-' + compXml.'@version' : ''}.zip") if (zipFile.exists()) { logger.lifecycle("Deleting existing component zip file: ${zipFile.name}"); zipFile.delete() } // exclude build, src, librepo, build.gradle, defaultexcludes (which includes .git) ant.zip(destfile: zipFile.path) { fileset(dir: compDir.parentFile.path, includes: "${compDir.name}/**", defaultexcludes: 'yes', excludes: "${compDir.name}/build/**,${compDir.name}/src/**,${compDir.name}/librepo/**,${compDir.name}/build.gradle") } logger.lifecycle("Created component zip file: ${zipFile.name}") } def checkRuntimeDirAndDefaults(String locType) { Node addons = parseAddons() Node myaddons = parseMyaddons() if (!locType) locType = file('.git').exists() ? 'git' : 'current' File runtimeDir = file('runtime') if (!runtimeDir.exists()) { Node runtimeNode = myaddons != null && myaddons.runtime ? (Node) myaddons.runtime[0] : null if (runtimeNode == null) runtimeNode = addons != null && addons.runtime ? (Node) addons.runtime[0] : null if (runtimeNode == null) throw new InvalidUserDataException("The runtime directory does not exist and no runtime element found in myaddons.xml or addons.xml") downloadComponent("runtime", locType, runtimeNode, addons, myaddons) } // look for @default in myaddons.xml only if (myaddons?.'@default') { String defaultComps = myaddons.'@default' Set compsChecked = new TreeSet() Set defaultCompsDownloaded = new TreeSet() for (String compName in defaultComps.split(',')) { compName = compName.trim() if (!compName) continue File componentDir = file("runtime/component/${compName}") if (componentDir.exists()) continue getComponent(compName, locType, addons, myaddons, compsChecked) defaultCompsDownloaded.add(compName) } if (defaultCompsDownloaded) logger.lifecycle("Got default components ${defaultCompsDownloaded}, dependent components checked: ${compsChecked}") } } def loadComponentSet(String setName, String curLocationType, Node addons, Node myaddons, Set compsChecked) { Node setNode = null if (myaddons) setNode = myaddons.'component-set'.find({ it."@name" == setName }) if (setNode == null) setNode = addons.'component-set'.find({ it."@name" == setName }) if (setNode == null) throw new InvalidUserDataException("Could not find component-set with name ${setName}") String components = setNode.'@components' if (components) for (String compName in components.split(",")) getComponent(compName, curLocationType, addons, myaddons, compsChecked) String sets = setNode.'@sets' if (sets) for (String subsetName in sets.split(",")) loadComponentSet(subsetName, curLocationType, addons, myaddons, compsChecked) } Collection findComponentDirs() { file('runtime/component').listFiles().findAll({ it.isDirectory() && it.listFiles().find({ it.name == 'component.xml' }) }) } Node parseAddons() { new XmlParser().parse(file('addons.xml')) } Node parseMyaddons() { if (file('myaddons.xml').exists()) { new XmlParser().parse(file('myaddons.xml')) } else { null } } Node parseComponent(project) { new XmlParser().parse(project.file('component.xml')) } def getComponentTop(String locationType) { if (project.hasProperty('component')) { checkRuntimeDirAndDefaults(locationType) Set compsChecked = new TreeSet() File componentDir = getComponent(component, locationType, parseAddons(), parseMyaddons(), compsChecked) if (componentDir?.exists()) logger.lifecycle("Got component ${component}, dependent components checked: ${compsChecked}") } else { throw new InvalidUserDataException("No component property specified") } } File getComponent(String compName, String type, Node addons, Node myaddons, Set compsChecked) { // get the component Node component = myaddons != null ? (Node) myaddons.component.find({ it."@name" == compName }) : null if (component == null) component = (Node) addons.component.find({ it."@name" == compName }) if (component == null) throw new InvalidUserDataException("Component ${compName} not found in myaddons.xml or addons.xml") if (component.'@skip-get' == 'true') { logger.lifecycle("Skipping get component ${compName} (skip-get=true)"); return null } File componentDir = downloadComponent("runtime/component/${compName}", type, component, addons, myaddons) checkComponentDependencies(compName, type, addons, myaddons, compsChecked) return componentDir } File downloadComponent(String targetDirPath, String type, Node component, Node addons, Node myaddons) { String compName = component.'@name' String branch = component.'@branch' // fall back to 'current' (branch-based) if release/binary requested but version is empty if (type in ['release', 'binary'] && !component.'@version') type = 'current' String repositoryName = (component.'@repository' ?: myaddons?.'@default-repository' ?: addons.'@default-repository' ?: 'github') Node repository = myaddons != null ? (Node) myaddons.repository.find({ it."@name" == repositoryName }) : null if (repository == null) repository = (Node) addons.repository.find({ it."@name" == repositoryName }) if (repository == null) throw new InvalidUserDataException("Repository ${repositoryName} not found in myaddons.xml or addons.xml") Node location = (Node) repository.location.find({ it."@type" == type }) if (location == null) throw new InvalidUserDataException("Location for type ${type} now found in repository ${repositoryName}") String url = Eval.me('component', component, '"""' + location.'@url' + '"""') logger.lifecycle("Getting ${compName} (type ${type}) from ${url} at ${branch} to ${targetDirPath}") File targetDir = file(targetDirPath) if (targetDir.exists()) { logger.lifecycle("Component ${compName} already exists at ${targetDir}"); return targetDir } if (type in ['current', 'release', 'binary']) { File zipFile = file("${targetDirPath}.zip") ant.get(src: url, dest: zipFile) // the eachFile closure removes the first path from each file, moving everything up a directory copy { from zipTree(zipFile); into targetDir; eachFile { it.setPath((it.getRelativePath().getSegments() as List).tail().join("/")); return it } } delete zipFile // delete the empty directories left over from zip expansion with first path removed String archiveDirName = compName + '-' if (type == 'current') { archiveDirName += component.'@branch' } else { archiveDirName += component.'@version' } // logger.lifecycle("Deleting dir ${targetDirPath}/${archiveDirName}") delete file("${targetDirPath}/${archiveDirName}") } else if (type == 'git') { Grgit.clone(dir: targetDir, uri: url, refToCheckout: branch) } logger.lifecycle("Downloaded ${compName} to ${targetDirPath}") return targetDir } def checkComponentDependencies(String compName, String type, Node addons, Node myaddons, Set compsChecked) { File componentDir = file("runtime/component/${compName}") if (!componentDir.exists()) return compsChecked.add(compName) File compXmlFile = file("${componentDir.path}/component.xml") if (!compXmlFile.exists()) return Node compXml = new XmlParser().parse(compXmlFile) for (Node dependsOn in compXml.'depends-on') { String depCompName = dependsOn.'@name' if (file("runtime/component/${depCompName}").exists()) { if (!compsChecked.contains(depCompName)) checkComponentDependencies(depCompName, type, addons, myaddons, compsChecked) } else { getComponent(depCompName, type, addons, myaddons, compsChecked) } } } def checkAllComponentDependencies(String type) { Node addons = parseAddons() Node myaddons = parseMyaddons() Set compsChecked = new TreeSet() for (File compDir in findComponentDirs()) { checkComponentDependencies(compDir.name, type, addons, myaddons, compsChecked) } logger.lifecycle("Dependent components checked: ${compsChecked}") } def makeVersionDetailFiles() { if (file(".git").exists()) { def topVersionMap = [framework:getVersionDetailMap(file('.'))] if (file("runtime/.git").exists()) topVersionMap.runtime = getVersionDetailMap(file('runtime')) file('runtime/version.json').write(groovy.json.JsonOutput.toJson(topVersionMap), "UTF-8") } for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == '.git' } }) { def versionMap = getVersionDetailMap(compDir) if (versionMap == null) continue file(compDir.path + '/version.json').write(groovy.json.JsonOutput.toJson(versionMap), "UTF-8") } } Map getVersionDetailMap(File gitDir) { def curGrgit = Grgit.open(dir: gitDir) if (curGrgit == null) return null String trackingName = curGrgit.branch.current()?.trackingBranch?.name String trackingUrl = "" int trackingNameSlash = trackingName ? trackingName.indexOf('/') : -1 if (trackingNameSlash > 0) { String remoteName = trackingName.substring(0, trackingNameSlash) def trackingRemote = curGrgit.remote.list().find({ it.name == remoteName }) if (trackingRemote != null) trackingUrl = trackingRemote.url } try { String headId = curGrgit.head()?.id // tags come in order of oldest first so want to find last in case multiple tags refer to HEAD commit def headTag = curGrgit.tag.list().reverse().find({ it.commit.id == headId }) return [branch:curGrgit.branch.current()?.name, tracking:trackingName, url:trackingUrl, head:headId?.take(10), tag:headTag?.name] } catch (Throwable t) { logger.lifecycle("Error getting git info for directory ${gitDir?.path}", t) return null } } def cleanVersionDetailFiles() { def runtimeVersionFile = file("runtime/version.json") if (runtimeVersionFile.exists()) runtimeVersionFile.delete() for (File compDir in file('runtime/component').listFiles().findAll { it.isDirectory() }) { File versionDetailFile = file(compDir.path + '/version.json') if (versionDetailFile.exists()) versionDetailFile.delete() } } // ========== combined tasks ========== task cleanPullLoad { dependsOn cleanAll, gitPullAll, load } task cleanPullTest { dependsOn cleanAll, gitPullAll, load, allTestTasks } task cleanPullCompTest { dependsOn cleanAll, gitPullAll, load, getComponentTestTasks() } task compTest { dependsOn getComponentTestTasks() } ================================================ FILE: build.xml ================================================ ================================================ FILE: docker/README.md ================================================ # Moqui On Docker This directory contains everything needed to deploy moqui on docker. ## Prerequisites - Docker. - Docker Compose Plugin. - Java 21. - Convenience scripts require bash. ## Deployment Instructions To deploy moqui on docker with all necessary services, follow below: - Choose a docker compose file in `docker/`. For example to deploy moqui on postgres you can choose moqui-postgres-compose.yml. - Find and download a suitable JDBC driver for the target database, download its jar file and place it in `runtime/lib`. - Generate moqui war file `./gradlew build` - Get into docker folder `cd docker` - Build chosen compose file, e.g. `./build-compose-up.sh moqui-postgres-compose.yml` This last step would build the "moqui" image and deploy all services. You can confirm by accessing the system on http://localhost For a more secure and complete deployment, it is recommended to carefully review the compose files and adjust as needed, including changing credentials and other settings such as setting the host names, configuring for letsencrypt, etc ... ## Compose Files There are multiple compose files offered providing different services: - moqui-acme-postgres.yml: Moqui, nginx, postgres and automatically issues SSL certificates from letsencrypt. Requires configuring variables including `VIRTUAL_HOST` and `LETSENCRYPT_HOST` and postgres driver. - moqui-postgres-compose.yml: Moqui with postgres and nginx standard deployment. - moqui-mysql-compose.yml: Moqui with mysql and nginx standard deployment. - mysql-compose.yml: deploys all services with mysql except moqui itself. Useful when deploying moqui elsewhere like on a servlet container. - postgres-compose.yml: Same as mysql-compose but replacing mysql with postgres - moqui-cluster1-compose.yml: Moqui with mysql. Designed to be deployed in a hazelcast cluster for horizontal scaling. Requires preparing moqui with the hazelcast component and mysql driver. ## Helper Scripts - `build-compose-up.sh`: Given a certain compose file, build "moqui" and deploy all services in the chosen yml file. - `clean.sh`: Clean the artifacts generated upon deployment including the database, opensearch and runtime. - `compose-down.sh`: Tear down all services of a certain yml file - `compose-up.sh`: Deploy all services of a certain yml file. If the "moqui" image exists in the yml file and it is not built, this script will fail, and you should use the build-compose-up.sh instead. - `postgres_backup.sh`: Convenience script to create a database dump. Might need adjusting the credentials. ## Moqui Image The actual image "moqui" is generated from the Dockerfile found in `docker/simple/Dockerfile`. All compose files depend on a "moqui" image generated by this file. Note: The deployment and tear down scripts can accept a container image name to override the default name. For example, to use a hardened JDK image, a command like the following can be used: `./build-compose-up.sh moqui-acme-postgres.yml .. eclipse-temurin:21-jdk-ubi10-minimal` ================================================ FILE: docker/build-compose-up.sh ================================================ #! /bin/bash if [[ ! $1 ]]; then echo "Usage: ./build-compose-up.sh [] []" exit 1 fi COMP_FILE="${1}" MOQUI_HOME="${2:-..}" NAME_TAG=moqui RUNTIME_IMAGE="${3:-eclipse-temurin:21-jdk}" if [ -f simple/docker-build.sh ]; then cd simple ./docker-build.sh ../.. $NAME_TAG $RUNTIME_IMAGE # shellcheck disable=SC2103 cd .. fi if [ -f compose-up.sh ]; then ./compose-up.sh $COMP_FILE $MOQUI_HOME $RUNTIME_IMAGE fi ================================================ FILE: docker/clean.sh ================================================ #! /bin/bash search_name=opensearch if [ -d runtime/opensearch/bin ]; then search_name=opensearch; elif [ -d runtime/elasticsearch/bin ]; then search_name=elasticsearch; fi rm -Rf runtime/ rm -Rf runtime1/ rm -Rf runtime2/ rm -Rf db/ rm -Rf $search_name/data/nodes rm -Rf $search_name/data/*.conf rm $search_name/logs/*.log docker rm moqui-server docker rm moqui-database docker rm nginx-proxy ================================================ FILE: docker/compose-down.sh ================================================ #! /bin/bash if [[ ! $1 ]]; then echo "Usage: ./compose-down.sh " exit 1 fi COMP_FILE="${1}" # set the project name to 'moqui', network will be called 'moqui_default' docker compose -f $COMP_FILE -p moqui down ================================================ FILE: docker/compose-up.sh ================================================ #! /bin/bash if [[ ! $1 ]]; then echo "Usage: ./compose-up.sh [] []" exit 1 fi COMP_FILE="${1}" MOQUI_HOME="${2:-..}" NAME_TAG=moqui RUNTIME_IMAGE="${3:-eclipse-temurin:21-jdk}" # Note: If you don't have access to your conf directory while running this: # This will make it so that your docker/conf directory no longer has your configuration files in it. # This is because when docker-compose provisions a volume on the host it applies the host's data before the image's data. # - change docker compose's moqui-server conf volume path from ./runtime/conf to conf # - add a top level volumes: tag with conf: below # - remove the next block of if statements from this file and you should be good to go search_name=opensearch if [ -d runtime/opensearch/bin ]; then search_name=opensearch; elif [ -d runtime/elasticsearch/bin ]; then search_name=elasticsearch; fi if [ ! -e runtime ]; then mkdir runtime; fi if [ ! -e runtime/conf ]; then cp -R $MOQUI_HOME/runtime/conf runtime/; fi if [ ! -e runtime/lib ]; then cp -R $MOQUI_HOME/runtime/lib runtime/; fi if [ ! -e runtime/classes ]; then cp -R $MOQUI_HOME/runtime/classes runtime/; fi if [ ! -e runtime/log ]; then cp -R $MOQUI_HOME/runtime/log runtime/; fi if [ ! -e runtime/txlog ]; then cp -R $MOQUI_HOME/runtime/txlog runtime/; fi if [ ! -e runtime/db ]; then cp -R $MOQUI_HOME/runtime/db runtime/; fi if [ ! -e runtime/$search_name ]; then cp -R $MOQUI_HOME/runtime/$search_name runtime/; fi # set the project name to 'moqui', network will be called 'moqui_default' docker compose -f $COMP_FILE -p moqui up -d ================================================ FILE: docker/elasticsearch/data/README ================================================ This directory must exist for mapping otherwise created as root in container and elasticsearch cannot access it. ================================================ FILE: docker/elasticsearch/moquiconfig/elasticsearch.yml ================================================ # ======================== Elasticsearch Configuration ========================= # # NOTE: Elasticsearch comes with reasonable defaults for most settings. # Before you set out to tweak and tune the configuration, make sure you # understand what are you trying to accomplish and the consequences. # # The primary way of configuring a node is via this file. This template lists # the most important settings you may want to configure for a production cluster. # # Please see the documentation for further information on configuration options: # # # ---------------------------------- Cluster ----------------------------------- # # Use a descriptive name for your cluster: cluster.name: MoquiElasticSearch # ------------------------------------ Node ------------------------------------ # # Use a descriptive name for the node: # NOTE: for cluster use auto generated # node.name: MoquiLocal # Add custom attributes to the node: # #node.attr.rack: r1 node.master: false node.data: false node.ingest: false # ----------------------------------- Paths ------------------------------------ # # Path to directory where to store the data (separate multiple locations by comma): # #path.data: /path/to/data # # Path to log files: # #path.logs: /path/to/logs # # ----------------------------------- Memory ----------------------------------- # # Lock the memory on startup: # #bootstrap.memory_lock: true # # Make sure that the heap size is set to about half the memory available # on the system and that the owner of the process is allowed to use this # limit. # # Elasticsearch performs poorly when the system is swapping the memory. # # ---------------------------------- Network ----------------------------------- # # Set the bind address to a specific IP (IPv4 or IPv6): # By default use _local_ and _site_ for localhost or any local network including docker container virtual network network.host: - _site_ - _local_ # transport.type: local # discovery.type: single-node discovery.type: zen discovery.zen.minimum_master_nodes: 1 # use unicast discovery to find external elasticsearch server, multicast doesn't seem to work with docker bridge network discovery.zen.ping.unicast.hosts: elasticsearch transport.host: 0.0.0.0 transport.tcp.port: 9300 # CORS settings for local testing only # http.cors.enabled: true # http.cors.allow-origin: '*' # Set a port for HTTP (9200 is the default, or with no port specified looks at subsequent ports to find one open): http.port: 9200 # For more information, see the documentation at: # # --------------------------------- Discovery ---------------------------------- # # Pass an initial list of hosts to perform discovery when new node is started: # The default list of hosts is ["127.0.0.1", "[::1]"] # #discovery.zen.ping.unicast.hosts: ["host1", "host2"] # # Prevent the "split brain" by configuring the majority of nodes (total number of nodes / 2 + 1): # #discovery.zen.minimum_master_nodes: 3 # # For more information, see the documentation at: # # # ---------------------------------- Gateway ----------------------------------- # # Block initial recovery after a full cluster restart until N nodes are started: # #gateway.recover_after_nodes: 3 # # For more information, see the documentation at: # # # ---------------------------------- Various ----------------------------------- # # Disable starting multiple nodes on a single system: # #node.max_local_storage_nodes: 1 # # Require explicit names when deleting indices: # #action.destructive_requires_name: true # ---------------------------------- Script ------------------------------------ # see: https://www.elastic.co/guide/en/elasticsearch/reference/current/modules-scripting-security.html ================================================ FILE: docker/elasticsearch/moquiconfig/log4j2.properties ================================================ status = info # log action execution errors for easier debugging logger.action.name = org.elasticsearch.action logger.action.level = debug appender.console.type = Console appender.console.name = console appender.console.layout.type = PatternLayout appender.console.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%m%n appender.rolling.type = RollingFile appender.rolling.name = rolling appender.rolling.fileName = ${sys:es.logs}.log appender.rolling.layout.type = PatternLayout appender.rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%.10000m%n appender.rolling.filePattern = ${sys:es.logs}-%d{yyyy-MM-dd}.log appender.rolling.policies.type = Policies appender.rolling.policies.time.type = TimeBasedTriggeringPolicy appender.rolling.policies.time.interval = 1 appender.rolling.policies.time.modulate = true rootLogger.level = info rootLogger.appenderRef.console.ref = console rootLogger.appenderRef.rolling.ref = rolling appender.deprecation_rolling.type = RollingFile appender.deprecation_rolling.name = deprecation_rolling appender.deprecation_rolling.fileName = ${sys:es.logs}_deprecation.log appender.deprecation_rolling.layout.type = PatternLayout appender.deprecation_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c{1.}] %marker%.10000m%n appender.deprecation_rolling.filePattern = ${sys:es.logs}_deprecation-%i.log.gz appender.deprecation_rolling.policies.type = Policies appender.deprecation_rolling.policies.size.type = SizeBasedTriggeringPolicy appender.deprecation_rolling.policies.size.size = 1GB appender.deprecation_rolling.strategy.type = DefaultRolloverStrategy appender.deprecation_rolling.strategy.max = 4 logger.deprecation.name = org.elasticsearch.deprecation logger.deprecation.level = warn logger.deprecation.appenderRef.deprecation_rolling.ref = deprecation_rolling logger.deprecation.additivity = false appender.index_search_slowlog_rolling.type = RollingFile appender.index_search_slowlog_rolling.name = index_search_slowlog_rolling appender.index_search_slowlog_rolling.fileName = ${sys:es.logs}_index_search_slowlog.log appender.index_search_slowlog_rolling.layout.type = PatternLayout appender.index_search_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] %marker%.10000m%n appender.index_search_slowlog_rolling.filePattern = ${sys:es.logs}_index_search_slowlog-%d{yyyy-MM-dd}.log appender.index_search_slowlog_rolling.policies.type = Policies appender.index_search_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy appender.index_search_slowlog_rolling.policies.time.interval = 1 appender.index_search_slowlog_rolling.policies.time.modulate = true logger.index_search_slowlog_rolling.name = index.search.slowlog logger.index_search_slowlog_rolling.level = trace logger.index_search_slowlog_rolling.appenderRef.index_search_slowlog_rolling.ref = index_search_slowlog_rolling logger.index_search_slowlog_rolling.additivity = false appender.index_indexing_slowlog_rolling.type = RollingFile appender.index_indexing_slowlog_rolling.name = index_indexing_slowlog_rolling appender.index_indexing_slowlog_rolling.fileName = ${sys:es.logs}_index_indexing_slowlog.log appender.index_indexing_slowlog_rolling.layout.type = PatternLayout appender.index_indexing_slowlog_rolling.layout.pattern = [%d{ISO8601}][%-5p][%-25c] %marker%.10000m%n appender.index_indexing_slowlog_rolling.filePattern = ${sys:es.logs}_index_indexing_slowlog-%d{yyyy-MM-dd}.log appender.index_indexing_slowlog_rolling.policies.type = Policies appender.index_indexing_slowlog_rolling.policies.time.type = TimeBasedTriggeringPolicy appender.index_indexing_slowlog_rolling.policies.time.interval = 1 appender.index_indexing_slowlog_rolling.policies.time.modulate = true logger.index_indexing_slowlog.name = index.indexing.slowlog.index logger.index_indexing_slowlog.level = trace logger.index_indexing_slowlog.appenderRef.index_indexing_slowlog_rolling.ref = index_indexing_slowlog_rolling logger.index_indexing_slowlog.additivity = false ================================================ FILE: docker/kibana/kibana.yml ================================================ # Kibana is served by a back end server. This setting specifies the port to use. server.port: 5601 # Specifies the address to which the Kibana server will bind. IP addresses and host names are both valid values. # The default is 'localhost', which usually means remote machines will not be able to connect. # To allow connections from remote users, set this parameter to a non-loopback address. server.host: "kibana" # Enables you to specify a path to mount Kibana at if you are running behind a proxy. This only affects # the URLs generated by Kibana, your proxy is expected to remove the basePath value before forwarding requests # to Kibana. This setting cannot end in a slash. server.basePath: "/kibana" # The maximum payload size in bytes for incoming server requests. #server.maxPayloadBytes: 1048576 # The Kibana server's name. This is used for display purposes. #server.name: "your-hostname" # The URL of the Elasticsearch instance to use for all your queries. elasticsearch.url: "http://moqui-server:9200" # When this setting's value is true Kibana uses the hostname specified in the server.host # setting. When the value of this setting is false, Kibana uses the hostname of the host # that connects to this Kibana instance. #elasticsearch.preserveHost: true # Kibana uses an index in Elasticsearch to store saved searches, visualizations and # dashboards. Kibana creates a new index if the index doesn't already exist. #kibana.index: ".kibana" # The default application to load. #kibana.defaultAppId: "discover" # If your Elasticsearch is protected with basic authentication, these settings provide # the username and password that the Kibana server uses to perform maintenance on the Kibana # index at startup. Your Kibana users still need to authenticate with Elasticsearch, which # is proxied through the Kibana server. #elasticsearch.username: "user" #elasticsearch.password: "pass" # Enables SSL and paths to the PEM-format SSL certificate and SSL key files, respectively. # These settings enable SSL for outgoing requests from the Kibana server to the browser. #server.ssl.enabled: false #server.ssl.certificate: /path/to/your/server.crt #server.ssl.key: /path/to/your/server.key # Optional settings that provide the paths to the PEM-format SSL certificate and key files. # These files validate that your Elasticsearch backend uses the same key files. #elasticsearch.ssl.certificate: /path/to/your/client.crt #elasticsearch.ssl.key: /path/to/your/client.key # Optional setting that enables you to specify a path to the PEM file for the certificate # authority for your Elasticsearch instance. #elasticsearch.ssl.certificateAuthorities: [ "/path/to/your/CA.pem" ] # To disregard the validity of SSL certificates, change this setting's value to 'none'. #elasticsearch.ssl.verificationMode: full # Time in milliseconds to wait for Elasticsearch to respond to pings. Defaults to the value of # the elasticsearch.requestTimeout setting. #elasticsearch.pingTimeout: 1500 # Time in milliseconds to wait for responses from the back end or Elasticsearch. This value # must be a positive integer. #elasticsearch.requestTimeout: 30000 # List of Kibana client-side headers to send to Elasticsearch. To send *no* client-side # headers, set this value to [] (an empty list). #elasticsearch.requestHeadersWhitelist: [ authorization ] # Header names and values that are sent to Elasticsearch. Any custom headers cannot be overwritten # by client-side headers, regardless of the elasticsearch.requestHeadersWhitelist configuration. #elasticsearch.customHeaders: {} # Time in milliseconds for Elasticsearch to wait for responses from shards. Set to 0 to disable. #elasticsearch.shardTimeout: 0 # Time in milliseconds to wait for Elasticsearch at Kibana startup before retrying. #elasticsearch.startupTimeout: 5000 # Specifies the path where Kibana creates the process ID file. #pid.file: /var/run/kibana.pid # Enables you specify a file where Kibana stores log output. #logging.dest: stdout # Set the value of this setting to true to suppress all logging output. #logging.silent: false # Set the value of this setting to true to suppress all logging output other than error messages. #logging.quiet: false # Set the value of this setting to true to log all events, including system usage information # and all requests. #logging.verbose: false # Set the interval in milliseconds to sample system and process performance # metrics. Minimum is 100ms. Defaults to 5000. #ops.interval: 5000 # The default locale. This locale can be used in certain circumstances to substitute any missing # translations. #i18n.defaultLocale: "en" ================================================ FILE: docker/moqui-acme-postgres.yml ================================================ # A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hostnames. # Run with something like this for detached mode: # $ docker compose -f moqui-postgres-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh moqui-postgres-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui.local" localhost/Login # To run an additional instance of moqui run something like this (but with # many more arguments for volume mapping, db setup, etc): # $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui # To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this: # $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginxproxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 labels: com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - /etc/localtime:/etc/localtime:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'acetousk.com.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/vhost.d:/etc/nginx/vhost.d - ./nginx/html:/usr/share/nginx/html environment: # change this for the default host to use when accessing directly by IP, etc - DEFAULT_HOST=moqui.local # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 networks: - proxy-tier acme-companion: image: nginxproxy/acme-companion container_name: acme-companion restart: always volumes: - /var/run/docker.sock:/var/run/docker.sock:ro - /etc/localtime:/etc/localtime:ro - ./certs:/etc/nginx/certs - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/vhost.d:/etc/nginx/vhost.d - ./nginx/html:/usr/share/nginx/html - ./acme.sh:/etc/acme.sh networks: - proxy-tier environment: # TODO: For production change this to your email - DEFAULT_EMAIL=mail@yourdomain.tld # TODO: For production change this to false - LETSENCRYPT_TEST=true depends_on: - nginx-proxy moqui-server: image: moqui container_name: moqui-server command: conf=conf/MoquiProductionConf.xml port=80 no-run-es restart: always links: - moqui-database - moqui-search volumes: - /etc/localtime:/etc/localtime:ro - ./runtime/conf:/opt/moqui/runtime/conf - ./runtime/lib:/opt/moqui/runtime/lib - ./runtime/classes:/opt/moqui/runtime/classes - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent - ./runtime/log:/opt/moqui/runtime/log - ./runtime/txlog:/opt/moqui/runtime/txlog - ./runtime/sessions:/opt/moqui/runtime/sessions - ./runtime/db:/opt/moqui/runtime/db - ./runtime/opensearch:/opt/moqui/runtime/opensearch environment: - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m" - instance_purpose=production - entity_ds_db_conf=postgres - entity_ds_host=moqui-database - entity_ds_port=5432 - entity_ds_database=moqui - entity_ds_schema=public - entity_ds_user=moqui - entity_ds_password='MOQUI_CHANGE_ME!!!' - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!' # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build - elasticsearch_url=https://moqui-search:9200 # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names - elasticsearch_index_prefix=default_ - elasticsearch_user=admin - elasticsearch_password=MoquiElasticChangeMe@2026 # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy # this can be a comma separate list of hosts like 'example.com,www.example.com' - VIRTUAL_HOST=moqui.local - LETSENCRYPT_HOST=moqui.local # moqui will accept traffic from other hosts but these are the values used for URL writing when specified: # - webapp_http_host=moqui.local - webapp_http_port=80 - webapp_https_port=443 - webapp_https_enabled=true # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for - webapp_client_ip_header=X-Real-IP - default_locale=en_US - default_time_zone=UTC networks: - proxy-tier - default moqui-database: image: postgres:18.1 container_name: moqui-database restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:5432:5432 volumes: - /etc/localtime:/etc/localtime:ro # edit these as needed to map configuration and data storage - ./db/postgres:/var/lib/postgresql environment: - POSTGRES_DB=moqui - POSTGRES_DB_SCHEMA=public - POSTGRES_USER=moqui - POSTGRES_PASSWORD='MOQUI_CHANGE_ME!!!' # PGDATA, POSTGRES_INITDB_ARGS networks: default: moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: - /etc/localtime:/etc/localtime:ro # edit these as needed to map configuration and data storage - ./opensearch/data/nodes:/usr/share/opensearch/data/nodes # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 networks: proxy-tier: opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards volumes: - /etc/localtime:/etc/localtime:ro links: - moqui-search ports: - 127.0.0.1:5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' networks: default: proxy-tier: networks: proxy-tier: ================================================ FILE: docker/moqui-cluster1-compose.yml ================================================ # NOTE: ElasticSearch uses odd user and directory setup for externally mapped data, etc directories, see: # https://www.elastic.co/guide/en/elasticsearch/reference/current/docker.html # Make sure vm.max_map_count=262144 is set in /etc/sysctl.conf on host (on live system run 'sudo sysctl -w vm.max_map_count=262144') # A Docker Compose application with 2 moqui instances, mysql, elasticsearch, kibana, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hosts. # The 'moqui' image should be prepared with the MySQL JDBC jar and the moqui-hazelcast and moqui-elasticsearch components. # This does virtual hosting instead of load balancing so that each moqui instance can be accessed consistently (moqui1.local, moqui2.local). # Run with something like this for detached mode: # $ docker-compose -f moqui-cluster1-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh moqui-cluster1-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui1.local" localhost/Login services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginx-proxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf environment: # change this for the default host to use when accessing directly by IP, etc - DEFAULT_HOST=moqui1.local # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 moqui-server1: image: moqui container_name: moqui-server1 command: conf=conf/MoquiProductionConf.xml port=80 restart: always links: - moqui-database - moqui-search volumes: - ./runtime/conf:/opt/moqui/runtime/conf - ./runtime/lib:/opt/moqui/runtime/lib - ./runtime/classes:/opt/moqui/runtime/classes - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent - ./runtime/log:/opt/moqui/runtime/log - ./runtime/txlog:/opt/moqui/runtime/txlog - ./runtime/sessions:/opt/moqui/runtime/sessions - ./runtime/db:/opt/moqui/runtime/db - ./runtime/opensearch:/opt/moqui/runtime/opensearch environment: - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m" - instance_purpose=production - entity_ds_db_conf=mysql8 - entity_ds_host=moqui-database - entity_ds_port=3306 - entity_ds_database=moqui # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly - entity_ds_user=root - entity_ds_password=moquiroot - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!' # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build - elasticsearch_url=https://moqui-search:9200 # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names - elasticsearch_index_prefix=default_ - elasticsearch_user=admin - elasticsearch_password=MoquiElasticChangeMe@2026 # settings for kibana proxy - kibana_host=opensearch-dashboards # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy # this can be a comma separate list of hosts like 'example.com,www.example.com' - VIRTUAL_HOST=moqui1.local # moqui will accept traffic from other hosts but these are the values used for URL writing when specified: - webapp_http_host=moqui1.local - webapp_http_port=80 - webapp_https_port=443 - webapp_https_enabled=true # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for - webapp_client_ip_header=X-Real-IP - default_locale=en_US - default_time_zone=UTC # hazelcast multicast setup - hazelcast_multicast_enabled=true - hazelcast_multicast_group=224.2.2.3 - hazelcast_multicast_port=54327 - hazelcast_group_name=test - hazelcast_group_password=test-pass moqui-server2: image: moqui container_name: moqui-server2 command: conf=conf/MoquiProductionConf.xml port=80 restart: always links: - moqui-database - moqui-search volumes: - ./runtime/conf:/opt/moqui/runtime/conf - ./runtime/lib:/opt/moqui/runtime/lib - ./runtime/classes:/opt/moqui/runtime/classes - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent - ./runtime/log:/opt/moqui/runtime/log - ./runtime/txlog:/opt/moqui/runtime/txlog - ./runtime/sessions:/opt/moqui/runtime/sessions # this one isn't needed when not using H2/etc: - ./runtime/db:/opt/moqui/runtime/db environment: - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m" - instance_purpose=production - entity_ds_db_conf=mysql8 - entity_ds_host=moqui-database - entity_ds_port=3306 - entity_ds_database=moqui # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly - entity_ds_user=root - entity_ds_password=moquiroot - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!' # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build - elasticsearch_url=https://moqui-search:9200 # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names - elasticsearch_index_prefix=default_ - elasticsearch_user=admin - elasticsearch_password=MoquiElasticChangeMe@2026 # settings for kibana proxy - kibana_host=opensearch-dashboards # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy # this can be a comma separate list of hosts like 'example.com,www.example.com' - VIRTUAL_HOST=moqui2.local # moqui will accept traffic from other hosts but these are the values used for URL writing when specified: - webapp_http_host=moqui2.local - webapp_http_port=80 - webapp_https_port=443 - webapp_https_enabled=true # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for - webapp_client_ip_header=X-Real-IP - default_locale=en_US - default_time_zone=UTC # hazelcast multicast setup - hazelcast_multicast_enabled=true - hazelcast_multicast_group=224.2.2.3 - hazelcast_multicast_port=54327 - hazelcast_group_name=test - hazelcast_group_password=test-pass moqui-database: image: mysql:9.5 container_name: moqui-database restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:3306:3306 volumes: # edit these as needed to map configuration and data storage - ./db/mysql/data:/var/lib/mysql # - /my/mysql/conf.d:/etc/mysql/conf.d environment: - MYSQL_ROOT_PASSWORD=moquiroot - MYSQL_DATABASE=moqui - MYSQL_USER=moqui - MYSQL_PASSWORD=moqui moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: # edit these as needed to map configuration and data storage - ./opensearch/data:/usr/share/opensearch/data # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards links: - moqui-search ports: - 5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' ================================================ FILE: docker/moqui-mysql-compose.yml ================================================ # A Docker Compose application with Moqui, MySQL, OpenSearch, OpenSearch Dashboards, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hostnames. # Run with something like this for detached mode: # $ docker compose -f moqui-mysql-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh moqui-mysql-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui.local" localhost/Login # To run an additional instance of moqui run something like this (but with # many more arguments for volume mapping, db setup, etc): # $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui # To import data from the docker host using port 3306 mapped for 127.0.0.1 only use something like this: # $ mysql -p -u root -h 127.0.0.1 moqui < mysql-export.sql services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginxproxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 labels: com.github.jrcs.letsencrypt_nginx_proxy_companion.nginx_proxy: "true" volumes: - /var/run/docker.sock:/tmp/docker.sock:ro - /etc/localtime:/etc/localtime:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/conf.d:/etc/nginx/conf.d - ./nginx/vhost.d:/etc/nginx/vhost.d - ./nginx/html:/usr/share/nginx/html environment: # change this for the default host to use when accessing directly by IP, etc - DEFAULT_HOST=moqui.local # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 moqui-server: image: moqui container_name: moqui-server command: conf=conf/MoquiProductionConf.xml port=80 restart: always links: - moqui-database - moqui-search volumes: - ./runtime/conf:/opt/moqui/runtime/conf - ./runtime/lib:/opt/moqui/runtime/lib - ./runtime/classes:/opt/moqui/runtime/classes - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent - ./runtime/log:/opt/moqui/runtime/log - ./runtime/txlog:/opt/moqui/runtime/txlog - ./runtime/sessions:/opt/moqui/runtime/sessions - ./runtime/db:/opt/moqui/runtime/db - ./runtime/opensearch:/opt/moqui/runtime/opensearch environment: - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m" - instance_purpose=production - entity_ds_db_conf=mysql8 - entity_ds_host=moqui-database - entity_ds_port=3306 - entity_ds_database=moqui # NOTE: using root user because for TX recovery MySQL requires the 'XA_RECOVER_ADMIN' and in version 8 that must be granted explicitly - entity_ds_user=root - entity_ds_password=moquiroot - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!' # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build - elasticsearch_url=https://moqui-search:9200 # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names - elasticsearch_index_prefix=default_ - elasticsearch_user=admin - elasticsearch_password=MoquiElasticChangeMe@2026 # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy # this can be a comma separate list of hosts like 'example.com,www.example.com' - VIRTUAL_HOST=moqui.local # moqui will accept traffic from other hosts but these are the values used for URL writing when specified: # - webapp_http_host=moqui.local - webapp_http_port=80 - webapp_https_port=443 - webapp_https_enabled=true # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for - webapp_client_ip_header=X-Real-IP - default_locale=en_US - default_time_zone=UTC moqui-database: image: mysql:9.5 container_name: moqui-database restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:3306:3306 volumes: # edit these as needed to map configuration and data storage - ./db/mysql/data:/var/lib/mysql # - /my/mysql/conf.d:/etc/mysql/conf.d environment: - MYSQL_ROOT_PASSWORD=moquiroot - MYSQL_DATABASE=moqui - MYSQL_USER=moqui - MYSQL_PASSWORD=moqui moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: # edit these as needed to map configuration and data storage - ./opensearch/data:/usr/share/opensearch/data # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards links: - moqui-search ports: - 5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' ================================================ FILE: docker/moqui-postgres-compose.yml ================================================ # A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hostnames. # Run with something like this for detached mode: # $ docker compose -f moqui-postgres-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh moqui-postgres-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui.local" localhost/Login # To run an additional instance of moqui run something like this (but with # many more arguments for volume mapping, db setup, etc): # $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui # To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this: # $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginx-proxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf environment: # change this for the default host to use when accessing directly by IP, etc - DEFAULT_HOST=moqui.local # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 moqui-server: image: moqui container_name: moqui-server command: conf=conf/MoquiProductionConf.xml port=80 restart: always links: - moqui-database - moqui-search volumes: - ./runtime/conf:/opt/moqui/runtime/conf - ./runtime/lib:/opt/moqui/runtime/lib - ./runtime/classes:/opt/moqui/runtime/classes - ./runtime/ecomponent:/opt/moqui/runtime/ecomponent - ./runtime/log:/opt/moqui/runtime/log - ./runtime/txlog:/opt/moqui/runtime/txlog - ./runtime/sessions:/opt/moqui/runtime/sessions - ./runtime/db:/opt/moqui/runtime/db - ./runtime/opensearch:/opt/moqui/runtime/opensearch environment: - "JAVA_TOOL_OPTIONS=-Xms1024m -Xmx1024m" - instance_purpose=production - entity_ds_db_conf=postgres - entity_ds_host=moqui-database - entity_ds_port=5432 - entity_ds_database=moqui - entity_ds_schema=public - entity_ds_user=moqui - entity_ds_password=moqui - entity_ds_crypt_pass='DEFAULT_CHANGE_ME!!!' # configuration for ElasticFacade.ElasticClient, make sure the old moqui-elasticsearch component is NOT included in the Moqui build - elasticsearch_url=https://moqui-search:9200 # prefix for index names, use something distinct and not 'moqui_' or 'mantle_' which match the beginning of OOTB index names - elasticsearch_index_prefix=default_ - elasticsearch_user=admin - elasticsearch_password=MoquiElasticChangeMe@2026 # CHANGE ME - note that VIRTUAL_HOST is for nginx-proxy so it picks up this container as one it should reverse proxy # this can be a comma separate list of hosts like 'example.com,www.example.com' - VIRTUAL_HOST=moqui.local # moqui will accept traffic from other hosts but these are the values used for URL writing when specified: # - webapp_http_host=moqui.local - webapp_http_port=80 - webapp_https_port=443 - webapp_https_enabled=true # nginx-proxy populates X-Real-IP with remote_addr by default, better option for outer proxy than X-Forwarded-For which defaults to proxy_add_x_forwarded_for - webapp_client_ip_header=X-Real-IP - default_locale=en_US - default_time_zone=UTC moqui-database: image: postgres:18.1 container_name: moqui-database restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:5432:5432 volumes: # edit these as needed to map configuration and data storage - ./db/postgres:/var/lib/postgresql environment: - POSTGRES_DB=moqui - POSTGRES_DB_SCHEMA=public - POSTGRES_USER=moqui - POSTGRES_PASSWORD=moqui # PGDATA, POSTGRES_INITDB_ARGS moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: # edit these as needed to map configuration and data storage - ./opensearch/data:/usr/share/opensearch/data # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards links: - moqui-search ports: - 5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' ================================================ FILE: docker/moqui-run.sh ================================================ #! /bin/bash echo "Usage: moqui-run.sh [] [] []" echo MOQUI_HOME="${1:-..}" NAME_TAG="${2:-moqui}" RUNTIME_IMAGE="${3:-eclipse-temurin:21-jdk}" search_name=opensearch if [ -d "$MOQUI_HOME/runtime/opensearch/bin" ]; then search_name=opensearch; elif [ -d "$MOQUI_HOME/runtime/elasticsearch/bin" ]; then search_name=elasticsearch; fi if [ -f simple/docker-build.sh ]; then cd simple ./docker-build.sh ../.. $NAME_TAG $RUNTIME_IMAGE # shellcheck disable=SC2103 cd .. fi if [ ! -e runtime ]; then mkdir runtime; fi if [ ! -e runtime/conf ]; then cp -R $MOQUI_HOME/runtime/conf runtime/; fi if [ ! -e runtime/lib ]; then cp -R $MOQUI_HOME/runtime/lib runtime/; fi if [ ! -e runtime/classes ]; then cp -R $MOQUI_HOME/runtime/classes runtime/; fi if [ ! -e runtime/log ]; then cp -R $MOQUI_HOME/runtime/log runtime/; fi if [ ! -e runtime/txlog ]; then cp -R $MOQUI_HOME/runtime/txlog runtime/; fi if [ ! -e runtime/db ]; then cp -R $MOQUI_HOME/runtime/db runtime/; fi if [ ! -e runtime/$search_name ]; then cp -R $MOQUI_HOME/runtime/$search_name runtime/; fi docker run --rm -p 80:80 -v $PWD/runtime/conf:/opt/moqui/runtime/conf -v $PWD/runtime/lib:/opt/moqui/runtime/lib \ -v $PWD/runtime/classes:/opt/moqui/runtime/classes -v $PWD/runtime/log:/opt/moqui/runtime/log \ -v $PWD/runtime/txlog:/opt/moqui/runtime/txlog -v $PWD/runtime/db:/opt/moqui/runtime/db \ -v $PWD/runtime/$search_name:/opt/moqui/runtime/$search_name \ --name moqui-server $NAME_TAG # docker run -d -p 80:80 $NAME_TAG # docker run --rm -p 80:80 $NAME_TAG ================================================ FILE: docker/mysql-compose.yml ================================================ # A Docker Compose application with Moqui, MySQL, OpenSearch, OpenSearch Dashboards, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hostnames. # Run with something like this for detached mode: # $ docker compose -f mysql-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh mysql-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui.local" localhost/Login # To run an additional instance of moqui run something like this (but with # many more arguments for volume mapping, db setup, etc): # $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui # To import data from the docker host using port 3306 mapped for 127.0.0.1 only use something like this: # $ mysql -p -u root -h 127.0.0.1 moqui < mysql-export.sql services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginx-proxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf environment: # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 moqui-database: image: mysql:9.5 container_name: moqui-database restart: always # expose the port for use outside other containers, needed for external management (like Moqui Instance Management) ports: - 127.0.0.1:3306:3306 # edit these as needed to map configuration and data storage volumes: - ./db/mysql/data:/var/lib/mysql # - /my/mysql/conf.d:/etc/mysql/conf.d environment: - MYSQL_ROOT_PASSWORD=moquiroot - MYSQL_DATABASE=moqui - MYSQL_USER=moqui - MYSQL_PASSWORD=moqui moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: # edit these as needed to map configuration and data storage - ./opensearch/data:/usr/share/opensearch/data # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards links: - moqui-search ports: - 5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' ================================================ FILE: docker/nginx/my_proxy.conf ================================================ client_max_body_size 20M; proxy_connect_timeout 3600s; proxy_read_timeout 3600s; proxy_send_timeout 3600s; # NOTE: this always sets X-Forwarded-For to the remote_addr instead of appending it. # The default behavior in nginx-proxy is to use $proxy_add_x_forwarded_for which # appends the current upstream IP to any in an existing X-Forwarded-For header # If nginx-proxy is used and it is there is another reverse proxy in front of it # (such as CloudFlare, AWS CloudFront, etc) this needs to be changed back to # $proxy_add_x_forwarded_for or it will always pick up the other reverse # proxy's IP address instead of the client IP address! # In other words, only the first proxy a client hits should set X-Forwarded-For # this way, all others should append. proxy_set_header X-Forwarded-For $remote_addr; underscores_in_headers on; ================================================ FILE: docker/opensearch/data/nodes/README ================================================ This directory must exist for mapping otherwise created as root in container and opensearch cannot access it. ================================================ FILE: docker/postgres-compose.yml ================================================ # A Docker Compose application with Moqui, Postgres, OpenSearch, OpenSearch Dashboards, and virtual hosting through # nginx-proxy supporting multiple moqui instances on different hostnames. # Run with something like this for detached mode: # $ docker compose -f postgres-compose.yml -p moqui up -d # Or to copy runtime directories for mounted volumes, set default settings, etc use something like this: # $ ./compose-run.sh postgres-compose.yml # This sets the project/app name to 'moqui' and the network will be 'moqui_default', to be used by external moqui containers # Test locally by adding the virtual host to /etc/hosts or with something like: # $ curl -H "Host: moqui.local" localhost/Login # To run an additional instance of moqui run something like this (but with # many more arguments for volume mapping, db setup, etc): # $ docker run -e VIRTUAL_HOST=moqui2.local --name moqui2_local --network moqui_default moqui # To import data from the docker host using port 5432 mapped for 127.0.0.1 only use something like this: # $ psql -h 127.0.0.1 -p 5432 -U moqui -W moqui < pg-dump.sql services: nginx-proxy: # For documentation on SSL and other settings see: # https://github.com/nginx-proxy/nginx-proxy image: nginxproxy/nginx-proxy container_name: nginx-proxy restart: always ports: - 80:80 - 443:443 volumes: - /var/run/docker.sock:/tmp/docker.sock:ro # note: .crt, .key, and .dhparam.pem files start with the domain name in VIRTUAL_HOST (ie 'moqui.local.*') or use CERT_NAME env var - ./certs:/etc/nginx/certs - ./nginx/my_proxy.conf:/etc/nginx/conf.d/my_proxy.conf environment: # use SSL_POLICY to disable TLSv1.0, etc in nginx-proxy - SSL_POLICY=AWS-TLS-1-1-2017-01 moqui-database: image: postgres:18.1 container_name: moqui-database restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:5432:5432 volumes: # edit these as needed to map configuration and data storage - ./db/postgres:/var/lib/postgresql environment: - POSTGRES_DB=moqui - POSTGRES_DB_SCHEMA=public - POSTGRES_USER=moqui - POSTGRES_PASSWORD=moqui # PGDATA, POSTGRES_INITDB_ARGS moqui-search: image: opensearchproject/opensearch:3.4.0 container_name: moqui-search restart: always ports: # change this as needed to bind to any address or even comment to not expose port outside containers - 127.0.0.1:9200:9200 - 127.0.0.1:9300:9300 volumes: # edit these as needed to map configuration and data storage - ./opensearch/data:/usr/share/opensearch/data # - ./opensearch/config/opensearch.yml:/usr/share/opensearch/config/opensearch.yml # - ./opensearch/logs:/usr/share/opensearch/logs environment: - "OPENSEARCH_JAVA_OPTS=-Xms512m -Xmx512m" - OPENSEARCH_INITIAL_ADMIN_PASSWORD=MoquiElasticChangeMe@2026 - discovery.type=single-node - network.host=_site_ ulimits: memlock: soft: -1 hard: -1 nofile: soft: 65536 hard: 65536 opensearch-dashboards: image: opensearchproject/opensearch-dashboards:3.4.0 container_name: opensearch-dashboards links: - moqui-search ports: - 5601:5601 environment: OPENSEARCH_HOSTS: '["https://moqui-search:9200"]' ================================================ FILE: docker/postgres_backup.sh ================================================ #!/bin/bash # This is a simple script to do a rotating backup of PostgreSQL (default once per day, retain 30 days) # For a complete backup solution these backup files would be copied to a remote site, potentially with a different retention pattern # Database info user="moqui" host="localhost" db_name="moqui" # Other options # a full path from root should be used for backup_path or there will be issues running via crontab backup_path="/opt/pgbackups" date=$(date +"%Y%m%d") backup_file=$backup_path/$db_name-$date.sql.gz # for password for cron job one option is to use a .pgpass file in home directory, see: https://www.postgresql.org/docs/current/libpq-pgpass.html # each line in .pgpass should be like: hostname:port:database:username:password # for example: localhost:5432:moqui:moqui:CHANGEME # note that ~/.pgpass must have u=rw (0600) permission or less (or psql, pg_dump, etc will refuse to use it) # Remove file for same day if exists if [ -e $backup_file ]; then rm $backup_file; fi # Set default file permissions umask 177 # Dump database into SQL file pg_dump -h $host -p 5432 -U $user -w $db_name | gzip > $backup_file # Remove all files not within 7 days, most recent per month for 6 months, or most recent of the year echo "removing:" ls "$backup_path"/moqui-[0-9][0-9][0-9][0-9][0-9][0-9][0-9][0-9].sql.gz | awk -v now_epoch="$(date +%s)" ' { date_string = substr($0, index($0,"-")+1, 8) command = "date -d \"" date_string "\" +%s" command | getline file_epoch close(command) files[NR] = $0 file_epoch_by_name[$0] = file_epoch year_month = substr(date_string,1,6) year_only = substr(date_string,1,4) age_in_months = int((now_epoch - file_epoch) / 2592000) if (age_in_months < 6 && (!(year_month in newest_month_epoch) || file_epoch > newest_month_epoch[year_month])) { newest_month_epoch[year_month] = file_epoch newest_month_file[year_month] = $0 } if (!(year_only in newest_year_epoch) || file_epoch > newest_year_epoch[year_only]) { newest_year_epoch[year_only] = file_epoch newest_year_file[year_only] = $0 } } END { for (i in files) { file_name = files[i] file_epoch = file_epoch_by_name[file_name] date_string = substr(file_name, index(file_name,"-")+1, 8) year_month = substr(date_string,1,6) year_only = substr(date_string,1,4) if (now_epoch - file_epoch <= 7*86400) continue if (file_name == newest_month_file[year_month]) continue if (file_name == newest_year_file[year_only]) continue printf "%s\0", file_name } }' | xargs -0 --no-run-if-empty rm -v # update cloned test instance database using backup file from production/main database # docker stop moqui-test # dropdb -h localhost -p 5432 -U moqui -w moqui-test # createdb -h localhost -p 5432 -U moqui -w moqui-test # gunzip < $backup_file | psql -h localhost -p 5432 -U moqui -w moqui-test # docker start moqui-test # example for crontab (safe edit using: 'crontab -e'), each day at midnight: 00 00 * * * /opt/moqui/postgres_backup.sh ================================================ FILE: docker/simple/Dockerfile ================================================ # Builds a minimal docker image with openjdk and moqui with various volumes for configuration and persisted data outside the container # NOTE: add components, build and if needed load data before building a docker image with this ARG RUNTIME_IMAGE=eclipse-temurin:21-jdk FROM ${RUNTIME_IMAGE} MAINTAINER Moqui Framework WORKDIR /opt/moqui # for running from the war directly, preffered approach unzips war in advance (see docker-build.sh that does this) #COPY moqui.war . # copy files from unzipped moqui.war file COPY WEB-INF WEB-INF COPY META-INF META-INF COPY *.class ./ COPY execlib execlib # always want the runtime directory COPY runtime runtime # create user for search and chown corresponding files ARG search_name=opensearch RUN if [ -d runtime/opensearch/bin ]; then echo "Installing OpenSearch User"; \ search_name=opensearch; \ groupadd -g 1000 opensearch 2>/dev/null || echo "group 1000 already exists" && \ useradd -u 1000 -g 1000 -G 0 -d /opt/moqui/runtime/opensearch opensearch 2>/dev/null || echo "user 1000 already exists" && \ chmod 0775 /opt/moqui/runtime/opensearch && \ chown -R 1000:0 /opt/moqui/runtime/opensearch; \ elif [ -d runtime/elasticsearch/bin ]; then echo "Installing ElasticSearch User"; \ search_name=elasticsearch; \ groupadd -r elasticsearch && \ useradd --no-log-init -r -g elasticsearch -d /opt/moqui/runtime/elasticsearch elasticsearch && \ chown -R elasticsearch:elasticsearch runtime/elasticsearch; \ fi # exposed as volumes for configuration purposes VOLUME ["/opt/moqui/runtime/conf", "/opt/moqui/runtime/lib", "/opt/moqui/runtime/classes", "/opt/moqui/runtime/ecomponent"] # exposed as volumes to persist data outside the container, recommended VOLUME ["/opt/moqui/runtime/log", "/opt/moqui/runtime/txlog", "/opt/moqui/runtime/sessions", "/opt/moqui/runtime/db", "/opt/moqui/runtime/$search_name"] # Main Servlet Container Port EXPOSE 80 # Search HTTP Port EXPOSE 9200 # Search Cluster (TCP Transport) Port EXPOSE 9300 # Hazelcast Cluster Port EXPOSE 5701 # this is to run from the war file directly, preferred approach unzips war file in advance # ENTRYPOINT ["java", "-jar", "moqui.war"] ENTRYPOINT ["java", "-cp", ".", "MoquiStart"] HEALTHCHECK --interval=30s --timeout=600ms --start-period=120s CMD curl -f -H "X-Forwarded-Proto: https" -H "X-Forwarded-Ssl: on" http://localhost/status || exit 1 # specify this as a default parameter if none are specified with docker exec/run, ie run production by default CMD ["conf=conf/MoquiProductionConf.xml", "port=80"] ================================================ FILE: docker/simple/docker-build.sh ================================================ #! /bin/bash echo "Usage: docker-build.sh [] [] []" MOQUI_HOME="${1:-../..}" NAME_TAG="${2:-moqui}" RUNTIME_IMAGE="${3:-eclipse-temurin:21-jdk}" if [ ! "$1" ]; then echo "Usage: docker-build.sh [] [] []" else echo "Running: docker-build.sh $MOQUI_HOME $NAME_TAG $RUNTIME_IMAGE" fi echo if [ -f $MOQUI_HOME/moqui-plus-runtime.war ] then echo "Building docker image from moqui-plus-runtime.war" echo unzip -q $MOQUI_HOME/moqui-plus-runtime.war elif [ -f $MOQUI_HOME/moqui.war ] then echo "Building docker image from moqui.war and runtime directory" echo "NOTE: this includes everything in the runtime directory, it is better to run 'gradle addRuntime' first and use the moqui-plus-runtime.war file for the docker image" echo unzip -q $MOQUI_HOME/moqui.war cp -R $MOQUI_HOME/runtime . else echo "Could not find $MOQUI_HOME/moqui-plus-runtime.war or $MOQUI_HOME/moqui.war" echo "Build moqui first, for example 'gradle build addRuntime' or 'gradle load addRuntime'" echo exit 1 fi docker build -t $NAME_TAG --build-arg RUNTIME_IMAGE=$RUNTIME_IMAGE . if [ -d META-INF ]; then rm -Rf META-INF; fi if [ -d WEB-INF ]; then rm -Rf WEB-INF; fi if [ -d execlib ]; then rm -Rf execlib; fi rm *.class if [ -d runtime ]; then rm -Rf runtime; fi if [ -f Procfile ]; then rm Procfile; fi ================================================ FILE: framework/build.gradle ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ plugins { id 'java-library' id 'groovy' id 'war' } version = '4.0.0' repositories { flatDir name: 'localLib', dirs: projectDir.absolutePath + '/lib' mavenCentral() } java { sourceCompatibility = 21 targetCompatibility = 21 } base { archivesName.set('moqui') } sourceSets { start execWar } groovydoc { docTitle = "Moqui Framework ${version}" source = sourceSets.main.allSource } //tasks.withType(JavaCompile) { options.compilerArgs << "-Xlint:unchecked" } //tasks.withType(JavaCompile) { options.compilerArgs << "-Xlint:deprecation" } //tasks.withType(GroovyCompile) { options.compilerArgs << "-Xlint:unchecked" } //tasks.withType(GroovyCompile) { options.compilerArgs << "-Xlint:deprecation" } // Log4J has annotation processors, disable to avoid warning tasks.withType(JavaCompile) { options.compilerArgs << "-proc:none" } tasks.withType(GroovyCompile) { options.compilerArgs << "-proc:none" } // NOTE: for dependency types and 'api' definition see: https://docs.gradle.org/current/userguide/java_library_plugin.html dependencies { // Groovy api 'org.apache.groovy:groovy:5.0.3' // Apache 2.0 api 'org.apache.groovy:groovy-dateutil:5.0.3' // Apache 2.0 api 'org.apache.groovy:groovy-json:5.0.3' // Apache 2.0 api 'org.apache.groovy:groovy-templates:5.0.3' // Apache 2.0 api 'org.apache.groovy:groovy-xml:5.0.3' // Apache 2.0 // Bitronix Transaction Manager, a modernized fork api 'org.moqui:btm:4.0.1' // Apache 2.0 // Apache Commons api 'org.apache.commons:commons-csv:1.14.1' // Apache 2.0 api('org.apache.commons:commons-email2-jakarta:2.0.0-M1') { exclude group: 'com.sun.mail', module: 'jakarta.mail' exclude group: 'com.sun.activation', module: 'jakarta.activation' } api 'org.apache.commons:commons-collections4:4.5.0' // Apache 2.0 api 'org.apache.commons:commons-fileupload2-jakarta-servlet6:2.0.0-M4' // Apache 2.0 api 'commons-codec:commons-codec:1.20.0' // Apache 2.0 api 'commons-io:commons-io:2.21.0' // Apache 2.0 api 'commons-logging:commons-logging:1.3.5' // Apache 2.0 api 'commons-validator:commons-validator:1.10.1' // Apache 2.0 // Cron Utils api 'com.cronutils:cron-utils:9.2.1' // Apache 2.0 // Flexmark (markdown) api 'com.vladsch.flexmark:flexmark:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-tables:0.64.8' api 'com.vladsch.flexmark:flexmark-ext-toc:0.64.8' // Freemarker // Remember to change the version number in FtlTemplateRenderer and MNode class when upgrading api 'org.freemarker:freemarker:2.3.34' // Apache 2.0 // H2 Database api 'com.h2database:h2:2.4.240' // MPL 2.0, EPL 1.0 // Java Specifications api 'jakarta.transaction:jakarta.transaction-api:2.0.1' api 'javax.cache:cache-api:1.1.1' api 'javax.jcr:jcr:2.0' api('jakarta.xml.bind:jakarta.xml.bind-api:4.0.4') { transitive = false } // EPL 2.0 api 'jakarta.activation:jakarta.activation-api:2.1.4' // activation api api 'org.eclipse.angus:angus-activation:2.0.3' // activation implementation api 'jakarta.websocket:jakarta.websocket-api:2.2.0' api 'jakarta.websocket:jakarta.websocket-client-api:2.2.0' // servlet-api needed during both compile and test compileOnlyApi 'jakarta.servlet:jakarta.servlet-api:6.1.0' testImplementation 'jakarta.servlet:jakarta.servlet-api:6.1.0' // Java TOTP api 'dev.samstevens.totp:totp:1.7.1' // MIT // dev.samstevens.totp:totp depends on com.google.zxing:javase which depends on com.beust:jcommander, but an older version with a CVE, so specify latest to fix api 'com.beust:jcommander:1.82' // Jackson Databind (JSON, etc) api 'com.fasterxml.jackson.core:jackson-databind:2.20.1' // Jetty HTTP Client and Proxy Servlet api 'org.eclipse.jetty:jetty-client:12.1.5' // Apache 2.0 api 'org.eclipse.jetty.ee11:jetty-ee11-proxy:12.1.5' // Apache 2.0 api 'org.eclipse.jetty:jetty-jndi:12.1.5' // Apache 2.0 // jakarta.mail api 'jakarta.mail:jakarta.mail-api:2.1.5' // mail api api 'org.eclipse.angus:angus-mail:2.0.5' // mail implementation // JSoup (HTML parser, cleaner) api 'org.jsoup:jsoup:1.21.2' // MIT // Apache Shiro api('org.apache.shiro:shiro-core:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-web:2.0.6:jakarta') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-lang:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-cache:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-event:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-config-core:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-config-ogdl:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-crypto-core:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-crypto-hash:2.0.6') { transitive = false } // Apache 2.0 api('org.apache.shiro:shiro-crypto-cipher:2.0.6') { transitive = false } // Apache 2.0 api('org.owasp.encoder:encoder:1.4.0') // BSD - transitive dependency of shiro-web // SLF4J, Log4j 2 (note Log4j 2 is used by various libraries, best not to replace it even if mostly possible with SLF4J) api 'org.slf4j:slf4j-api:2.0.17' implementation 'org.apache.logging.log4j:log4j-core:2.25.2' implementation 'org.apache.logging.log4j:log4j-api:2.25.2' runtimeOnly 'org.apache.logging.log4j:log4j-jcl:2.25.2' runtimeOnly 'org.apache.logging.log4j:log4j-slf4j2-impl:2.25.2' // SubEtha SMTP, depends on jakarta.mail-api which is provided api("com.github.davidmoten:subethasmtp:7.2.0") { transitive = false } // Snake YAML api 'org.yaml:snakeyaml:2.5' // Apache 2.0 // Apache Jackrabbit - uncomment here or include elsewhere when Jackrabbit repository configurations are used // api 'org.apache.jackrabbit:jackrabbit-jcr-rmi:2.12.1' // Apache 2.0 // api 'org.apache.jackrabbit:jackrabbit-jcr2dav:2.12.1' // Apache 2.0 // Apache Commons JCS - Only needed when using JCSCacheToolFactory // api 'org.apache.commons:commons-jcs-jcache:2.0-beta-1' // Apache 2.0 // Liquibase (for future reference, not used yet) // api 'org.liquibase:liquibase-core:3.4.2' // Apache 2.0 // ========== test dependencies ========== // junit-platform-launcher is a dependency from spock-core, included explicitly to get more recent version as needed testImplementation 'org.junit.platform:junit-platform-launcher:6.0.1' // junit-platform-suite required for test suites to specify test class order, etc testImplementation 'org.junit.platform:junit-platform-suite:6.0.1' // junit-jupiter-api for using JUnit directly, not generally needed for Spock based tests testImplementation 'org.junit.jupiter:junit-jupiter-api:6.0.1' // Spock Framework testImplementation platform('org.spockframework:spock-bom:2.4-groovy-5.0') // Apache 2.0 testImplementation 'org.spockframework:spock-core:2.4-groovy-5.0' // Apache 2.0 testImplementation 'org.spockframework:spock-junit4:2.4-groovy-5.0' // Apache 2.0 testImplementation 'org.hamcrest:hamcrest-core:3.0' // BSD 3-Clause // ========== executable war dependencies ========== // Jetty execWarRuntimeOnly 'org.eclipse.jetty:jetty-server:12.1.5' // Apache 2.0 execWarRuntimeOnly 'org.eclipse.jetty.ee11:jetty-ee11-webapp:12.1.5' // Apache 2.0 execWarRuntimeOnly 'org.eclipse.jetty.ee11.websocket:jetty-ee11-websocket-jakarta-server:12.1.5' // Apache 2.0 } // setup task dependencies to make sure the start sourceSets always get run compileJava.dependsOn startClasses compileTestGroovy.dependsOn classes sourceSets.test.compileClasspath += files(sourceSets.main.output.classesDirs) // by default the Java plugin runs test on build, change to not do that (only run test if explicit task) // no longer works as of gradle 4.8 or possibly earlier, use clear() instead: check.dependsOn.remove(test) check.dependsOn.clear() test { useJUnitPlatform() testLogging { events "passed", "skipped", "failed" } testLogging.showStandardStreams = true; testLogging.showExceptions = true maxParallelForks = 1 dependsOn cleanTest include '**/*MoquiSuite.class' systemProperty 'moqui.runtime', '../runtime' systemProperty 'moqui.conf', 'conf/MoquiDevConf.xml' systemProperty 'moqui.init.static', 'true' classpath += files(sourceSets.main.output.classesDirs); classpath += files(projectDir.absolutePath) // filter out classpath entries that don't exist (gradle adds a bunch of these), or ElasticSearch JarHell will blow up classpath = classpath.filter { it.exists() } beforeTest { descriptor -> logger.lifecycle("Running test: ${descriptor}") } } jar { // this is necessary otherwise jar won't build when war plugin is applied enabled = true archiveBaseName = 'moqui-framework' manifest { attributes 'Implementation-Title': 'Moqui Framework', 'Implementation-Version': version, 'Implementation-Vendor': 'Moqui Ecosystem' } from sourceSets.main.output // get all of the "resources" that are in component-standard directories instead of src/main/resources from fileTree(dir: projectDir.absolutePath, includes: ['data/**', 'entity/**', 'screen/**', 'service/**', 'template/**']) // 'xsd/**' } tasks.test { inputs.files(tasks.jar) } war { dependsOn jar // put the war file in the parent directory, ie the moqui dir instead of the framework dir destinationDirectory.set(projectDir.parentFile) archiveFileName = 'moqui.war' // add MoquiInit.properties to the WEB-INF/classes dir for the deployed war mode of operation from(fileTree(dir: projectDir.parentFile, includes: ['MoquiInit.properties'])) { into 'WEB-INF/classes' } // this excludes the classes in sourceSets.main.output (better to have the jar file built above) classpath = configurations.runtimeClasspath - configurations.providedCompile classpath jar.archiveFile.get().asFile // put start classes and Jetty jars in the root of the war file for the executable war/jar mode of operation from sourceSets.start.output from(files(configurations.execWarRuntimeClasspath)) { into 'execlib' } // TODO some sort of config for Jetty? from file(projectDir.absolutePath + '/jetty/jetty.xml') // setup the manifest for the executable war/jar mode manifest { attributes 'Implementation-Title': 'Moqui Start', 'Implementation-Vendor': 'Moqui Ecosystem', 'Implementation-Version': version, 'Main-Class': 'MoquiStart' } } task copyDependencies { doLast { delete file(projectDir.absolutePath + '/dependencies') copy { from configurations.runtime; into file(projectDir.absolutePath + '/dependencies') } copy { from configurations.testCompile; into file(projectDir.absolutePath + '/dependencies') } } } ================================================ FILE: framework/data/CommonL10nData.xml ================================================ ================================================ FILE: framework/data/CurrencyData.xml ================================================ ================================================ FILE: framework/data/EntityTypeData.xml ================================================ ================================================ FILE: framework/data/GeoCountryData.xml ================================================ ================================================ FILE: framework/data/MoquiSetupData.xml ================================================ ================================================ FILE: framework/data/SecurityTypeData.xml ================================================ ================================================ FILE: framework/data/UnitData.xml ================================================ ================================================ FILE: framework/entity/BasicEntities.xml ================================================ Usage depends on enum type such as an ID format/mask Indicator flag with meaning depending on enum type General info about the member, usage is specific to the enum group For getting EnumGroup members by enumGroupEnumId Optional. If specified uses status items with this type. If true can be an initial status in this flow. Not currently supported, may be removed, issue with what is the context for the expression No FK in order to allow arbitrary permissions (ie not pre-configured). The factor is multiplied first, then the offset is added. When converting in the reverse direction the offset is subtracted first, then divided by the factor. For threaded messages, this points to the message that started the thread. For threaded messages, this points to the previous message in the thread. The plain text variation of the body For outgoing messages that came from an EmailTemplate. Defaults to INBOX Comma separated list of domain names to allow (like: moqui.org, dejc.com), matched by ends with; if no value specified all domains allowed Comma separated list of reply to email addresses Comma separated list of CC email addresses Comma separated list of BCC email addresses ResourceFacade location for attachment content or screen (if screenRenderMode specified) Alternative to attachmentLocation as a location for the screen rendered on its own. If specified is used to determine the screen by path from the root screen looked up using the webappName and webHostName values from EmailTemplate. Used to determine the MIME/content type, and which screen render template to use. Can be used to generate XSL:FO that is transformed to a PDF and attached to the email with screenRenderMode=xsl-fo. If empty the content at attachmentLocation will be sent over without rendering and its MIME type will be based on its extension. A Groovy expression that evaluates to a Collection in the context to iterate over and add an attachment for each. If entries are Map objects puts all entries in context for each (pushed/isolated context), otherwise puts Collection entry in 'forEachEntry' field. Only applicable if screenRenderMode is specified so that there is a render of the attachment. Optional Groovy expression evaluated as a boolean, if specified and evaluates to false attachment will be skipped. Defaults to 631 Leave empty to use default printer on print server ================================================ FILE: framework/entity/EntityEntities.xml ================================================ The name of the field corresponding to the joinFromAlias. The name of the field corresponding to the entityAlias, ie the related field. For ElasticSearch compatibility, and as a general good practice for use as a dynamic view-entity, must follow entity name convention of camel case and starting with a capital letter. The name of the document for display in search results and such. Is generally expanded on display and may use any field in the DataDocument. A title for each document instance for display in search results or other places. Meant to be string expanded using a "flattened" version of the document (see the CollectionUtilities.flattenNestedMap() method). This should be specified for documents that will be indexed by ElasticSearch and must be lower-case (ElasticSearch requires all lower-case). Because of changes in ElasticSearch 5 this is no longer the actual index name and is instead an alias for each index from a DataDocument. Name of a service to call to get additional data to include in the document. This service should implement the org.moqui.EntityServices.add#ManualDocumentData interface. Name of a service to call to alter the generated elasticsearch mapping for the data document. This service should implement the org.moqui.EntityServices.transform#DocumentMapping interface. String formatted like "RelationshipName:RelationshipName:fieldName" with zero or more relationship names. If there is no relationship name the field is on the primary entity. More than one relationship names means follow that path of relationships to get to the field. This may also contain a Groovy expression using other fields in the current Map/Object in the document by the path or any parent Map/Object above it in the document. When an expression is used a fieldNameAlias is required. Alias to put in document output for field name (ie final part of fieldPath only). Defaults to final part of fieldPath. Must be unique within the document and can be used in EntityCondition passed into the EntityFacade.findDataDocuments() method. The ElasticSearch field type to use, default is based on entity field type or for expression fields defaults to 'double'. Indicates the field should be sortable. This is needed because in ElasticSearch we have two string types to work with: text (tokenized for search, not sortable) and keyword (sortable but not tokenized for search). In ElasticSearch this adds [field name].keyword field of type keyword to sort on if the entity field is a 'text' type ElasticSearch field. Fields displayed by default, set to N to not display in output. If specific the field is queried with the given function. Must be one of the functions available in the view-entity.alias.@function attribute. The name of a relationship used in any fieldPath to be aliased in the output document. Alias to put in document output instead of the full relationship name. This is a very simple sort of condition to constrain data document output. Must be a valid value like those in the econdition.@operator attribute. Ignored if postQuery=Y. Defaults (like that attribute) to 'equals'. If Y condition is applied after the query is done instead of being added to the query as a condition. Must match at least one nested field with the specified fieldNameAlias. The fieldValue String will be compared to the Object from the database field after conversion using the Groovy asType() method. Associate links with a DataDocument to use in applications for links to details, edit screens, etc for search results. Must match an option for the XML Screen link.@url-type attribute. Defaults to 'plain'. Use this entity to allow a user group access to the DataDocument (for reports, etc). For all users use userGroupId="ALL_USERS". If Y index the feed on start if the index does not yet exist (for servers where ES data not persisted between restarts) The service named here should implement the org.moqui.EntityServices.receive#DataFeed interface; defaults in some cases to 'org.moqui.search.SearchServices.index#DataDocuments' The service named here should implement the org.moqui.EntityServices.receive#DataFeedDelete interface; defaults in some cases to 'org.moqui.search.SearchServices.delete#DataDocument' Used only for periodic feeds. Keep retrieving time splits until the number of records is greater then this threshold. Newer retrieve records newer than this many milliseconds in the past (leave a delay buffer for transactions in progress). For sending to via file the path and filename to use, or path/filename pattern using Groovy string expand If Y this record tracks data pulled from a remote system, otherwise it tracks data pushed from this system. Associates a set of entities through ArtifactGroupMember records associated with an ArtifactGroup. ArtifactGroupMember records may have filterMap value and may have nameIsPattern=Y. The filterMap is ignored when the application type is Exclude, it simply excludes the entity altogether. To exclude records use a filterMap on an include. If there are multiple ArtifactGroupMember records with filterMap value for an entity it will OR them together. If include and exclude filters create condition with combined include AND NOT combined exclude. Only entity artifacts (artifactTypeEnumId=AT_ENTITY) will be used, all others ignored. If Y also include dependents of records, will apply to all records for applicable entities. ================================================ FILE: framework/entity/OlapEntities.xml ================================================ Date Day Dimension. The natural key is [dateValue] Format: YYYY-MM-DD Format: YYYY-MM Currency Dimension. The natural key is [currencyId] ================================================ FILE: framework/entity/ResourceEntities.xml ================================================ Each record represents a single blog article, grouped as needed by categories (WikiBlogCategory) The date/time a blog post within a category was sent by email or other means. ================================================ FILE: framework/entity/Screen.eecas.xml ================================================ ================================================ FILE: framework/entity/ScreenEntities.xml ================================================ For scheduled screen renders to send by email and/or write to a resource location. Primarily intended for use on report screens with a form-list and saved-finds enabled, referencing the formListFindId for saved columns, parameters, etc. Defaults to 'csv', can also use 'xsl-fo' with PDF rendering, 'xlsx' if moqui-poi component in place Set to Y to abort (not send or write) if there are no results in form-list on screen Expandable String for resource location to save to, only save to location if specified EmailTemplate to use to send by email, generally of type EMT_SCREEN_RENDER, for default use the Default Screen Render template (set to 'SCREEN_RENDER'); only sends email if specified Send email to UserAccount.emailAddress for the user Send email to UserAccount.emailAddress for each user in the group Runtime data for a scheduled ServiceJob (with a cronExpression), managed automatically by the service job runner. DEPRECATED. While still supported, to control access to subscreens use ArtifactAuthz and related records instead. The title to show for this subscreen in the menu. Can be used to override subscreen titles in the screen.default-menu-title attribute and the subscreens-item.menu-title attribute. Insert this item in subscreens menu at this index (1-based). Defaults to Y. Set to N to not include in the menu for the subscreens. This can be used to hide subscreens from the directory structure or even explicitly declared in the Screen XML file. If Y will be set at the default subscreen (replacing screen.subscreens.@default-item) If Y the sub-screens of the sub-screen may be referenced directly under this screen, skipping the screen path element for the sub-screen Part of the key (used to reference within a screen) and for sort order The location, name or other value description the resource. The position (row for form-single, column for form-list) number to put the field in The sequence within the row or column Structured to have a single FormConfig per form and user. Structured to have a single FormConfig per form and user. Has fields for the various options in search-form-inputs/searchFormInputs()/searchFormMap() Per-User default FormListFind by screen location and not form location because must be handled very early in screen rendering so parameters are available to actions, etc The screen location and form name (separated by a hash/pound sign) of XML Screen Form to modify. Only show this field if the condition evaluates to true (Groovy expression) Field type for presentation, validation; always stored as plain text FormResponseAnswer Defaults to 1, ie one page/section for all fields if nothing higher than 1 is specified. Used to provide attribute values. For a reference of attributes available for each field type, see the corresponding element in the xml-form-?.xsd file. These settings are for a UserGroup. To apply to all users just use the ALL_USERS UserGroup. ================================================ FILE: framework/entity/SecurityEntities.xml ================================================ Full artifact location/name, or a pattern if nameIsPattern=Y. If Y then the user will have authorization for anything called by the artifact. If N user will need to have authorization for anything else called by the artifact. Note that in some cases (like in screen-sets) inheritance in the other direction is on by default, or in other words permission to access an artifact implies permission to access everything needed to get to that artifact. Defaults to Y. A Groovy expression that evaluates to a Map that will be used to constrain if the member is part of the group based on fields/parameters for Entity operations and Service calls. If an artifact in the group specified is accessed by any user in the AuthzGroup maxHitsCount times in maxHitsDuration seconds then the user/artifact will be blocked fir tarputDuration seconds. If specified this service will be called and it should return a authzTypeEnumId with the result of the authorization. Will try to pass the following fields to this service: userId, authzActionEnumId, artifactTypeEnumId, and artifactName. The service will also have access to the ArtifactExecutionFacade (ec.artifactExecution) which you can use to get the current artifact stack, etc. The service must return an authzTypeEnumId (AUTHZT_ALLOW, AUTHZT_ALWAYS, or AUTHZT_DENY). No FK in order to allow externally authenticated users. Groovy boolean (if) expression, if specified checked before applying filters in the set Groovy boolean (if) expression, if specified checked before applying filters in the set By default if a filterMap refers to a field not aliased in a view-entity there will be an error, set this to Y to do the query anyway The name of the entity to filter when queried. May be queried directly or as part of a view-entity. A Groovy expression that evaluates to a Map that will be added to queries to filter/constrain visible data. Values can be constants or variables that come from user context (ec.user.context) or execution context (ec.context). It is up to code to set values for use by these filters. If the value evaluates to a Collection the default comparison operator is IN, otherwise default is EQUALS. If a Collection is empty results in a false constraint, unless using a NOT* comparison operator. If Y then OR filterMap entries, default AND. If an artifact in the group specified is accessed by any user in the UserGroup maxHitsCount times in maxHitsDuration seconds then the user/artifact will be blocked for tarpitDuration seconds. Deny access to the artifact for the user until releaseDateTime is reached. No FK in order to allow externally authenticated users. The username used along with the password to login User's first, middle, last, etc name NOTE: not an encrypted field because one way hash encryption used for it Set to random password for password reset, can be used only to update password Set to Y is currentPassword Base64 encoded, defaults to Hex encoded RSA public key for key based authentication Set to Y when user logs out and to N when user logs in. If user is session authenticated on request and this is Y then treat as if user not authenticated. If set then user may not login after this date, and no notifications will be sent after this date. The email address to use for forgot password emails and other system messages. If specified only allow login from matching IP4 address. Comma separated patterns where each pattern has 4 dot separated segments each segment may be number, '*' for wildcard, or '-' separate number range (like '0-31'). This entity is for recording User Authentication Factors. Use varies based on factor type: TOTP is shared secret, Single Use is the code, Email Code is email address No FK in order to allow externally authenticated users. See UserAccout.ipAllowed No FK in order to allow externally authenticated users. No FK in order to allow arbitrary permissions (ie not pre-configured). A login key is an alternate way to authenticate a user, generally issued for temporary use sort of like a session. NOTE: not an encrypted field because one way hash encryption used for it (uses login-key.@encrypt-hash-type, no salt, hash before lookup on verify) No FK in order to allow externally authenticated users. No FK in order to allow externally authenticated users. Use this entity for user-specific preferences (or properties). For default preferences use userId="_NA_". No FK in order to allow externally authenticated users. No FK because any key can be used whether or not there is an Enumeration record for it. Use this entity for user group preferences (or properties). For all users use userGroupId="ALL_USERS". For deciding which value to use when a user is a member of multiple groups with preferences with the same key. No FK in order to allow externally authenticated users. No FK because any key can be used whether or not there is an Enumeration record for it. No FK in order to allow externally authenticated users. No FK in order to allow externally authenticated users. If populated used when type=danger One of: info, success, warning, danger If Y user must be associated to see it or receive notifications. For each User if there is no NotificationTopicUser.receiveNotifications value then use this as the default, this defaults to N. For each User if there is no NotificationTopicUser.emailNotifications value then use this as the default, this defaults to N. If is specified use this template to send a notification email to each user with emailNotifications=Y If notification sent to user only actually notify if this is Y If Y user receives all notifications on topic even if not sent directly If Y sends an email to user using UserAccount.emailAddress ================================================ FILE: framework/entity/ServerEntities.xml ================================================ The name of the artifact hit. For XML Screen request it is "${webapp-name}.${screen-path}" Total (sum) of the squared running times for calculating incremental standard deviation. After 100 hits count of hits more that 2.6 standard deviations above average (both avg and std dev adjusted incrementally). If instance accesses DB by different address specify here ================================================ FILE: framework/entity/ServiceEntities.xml ================================================ For ad-hoc (explicitly run) or scheduled service jobs. If cronExpression is null the job will only be run ad-hoc, when explicitly called using ServiceCallJob. If a topic is specified results will be sent to the topic (can be configured using a NotificationTopic record) as a NotificationMessage to the user that called the job explicitly (if applicable) and to users associated with ServiceJobUser records. On completion send a notification to this topic If Y this will be run local only. By default runs on any server in a cluster listening for async distributed services (if an async distributed executor is configured). An extended cron expression like Unix crontab but with extended syntax options (L, W, etc) similar to Quartz Scheduler. See: http://cron-parser.com http://www.quartz-scheduler.org/documentation/quartz-2.2.x/tutorials/tutorial-lesson-06.html Only run scheduled after this date/time. Ignored for ad-hoc/explicit runs. Only run scheduled before this date/time. Ignored for ad-hoc/explicit runs. If specified only run this many times. Must specify a cronExpression for the job to repeat. When this count is reached thruDate will be set to now and paused set to Y. Ignored for ad-hoc/explicit runs. If Y this job is inactive and won't be run on a schedule even if cronExpression is not null. Ignored for ad-hoc/explicit runs. Ignore lock and run anyway after this many minutes. This should generally be much greater than the longest time the service is expected to run. This is the mechanism for recovering jobs after a run failed in a way that did not clean up the ServiceJobRunLock record. Defaults to 24 hours (1440 minutes) to make sure jobs get recovered. Minimum time between retries after an error (based on most recent ServiceJobRun record), in minutes Job execution priority, lower numbers run first among jobs that need to be run regardless of scheduled start time Parameters automatically added when the job service is called. Always stored as a String and will be converted based on the corresponding service in-parameter.parameter.@type attribute (just as with any service call). The user that initiated the job run Runtime data for a scheduled ServiceJob (with a cronExpression), managed automatically by the service job runner. If not null this is the currently running job instance. May be semaphore-name if that attribute is used, otherwise is service name Reference to the SystemMessageRemote record for the remote system this message came from for incoming messages or should be sent to for outgoing messages. For incoming the received date, for outgoing the produced date For incoming the consumed date, for outgoing the sent date If a received message is split this is the original message The message received or sent to acknowledge this message For messages to/from another Moqui system, the systemMessageId on the remote system; may also be used for other system level message IDs (as opposed to messageId which is for the ID in the envelope of the message) ID of the sender (for OAGIS may be broken down into logicalId, component, task, referenceId; for EDI X12 this is ISA06) ID of the receiver (for OAGIS may also be broken down; for EDI X12 this is ISA08) ID of the message; this may be globally unique (like the OAGIS BODID, a GUID) or only unique relative to the senderId and the receiverId (like EDI X12 ISA13 in the context of ISA06, ISA08), and may only be unique within a certain time period (ID may be reused since in EDI X12 limited to 9 digits) Date/time from message (for EDI X12 this is GS04 (date) and GS05 (time)) For OAGIS the BSR Noun; For X12 GS01 (functional ID code) For OAGIS the BSR Verb; For X12 ST01 (tx set ID code) Control number of the message when applicable (such as GS06 in EDI X12 messages) Sub-Control number of the message when applicable (such as ST02 in EDI X12 messages) The document version (for OAGIS BSR Revision, for X12 GS08 (version/revision)) Active visit when SystemMessage triggered (mainly produced) to track the user who did so; independent of the message transport which could have separate remote system and other Visit-like data. Not used in automated processing, but useful for documentation and tools in some cases. The service to call after a message is received to consume it. Should implement the org.moqui.impl.SystemMessageServices.consume#SystemMessage interface (just a systemMessageId in-parameter). Used by the consume#ReceivedSystemMessage service. The service to call to produce an async acknowledgement of a message. Should implement the org.moqui.impl.SystemMessageServices.produce#AckSystemMessage. Once the message is produced should call the org.moqui.impl.SystemMessageServices.queue#SystemMessage service. The service to call to send queued messages. Should implement the org.moqui.impl.SystemMessageServices.send#SystemMessage interface (just a systemMessageId in-parameter and remoteMessageId out-parameter). Used by the send#ProducedSystemMessage service, and for that service must be specified or will result in an error. The service to call to save a received message. Should implement the org.moqui.impl.SystemMessageServices.receive#SystemMessage interface. If not specified receive#IncomingSystemMessage just saves the message directly. When applicable, used by the send service as the service on the remote server to call to receive the message. Where to look for files on a remote server, syntax is implementation specific Regular expression to match filenames in receivePath (optional) After successful receive move file to this path if receiveResponseEnumId = MsgRrMove Where to put files on a remote server, syntax is implementation specific and may include both path and a filename pattern May be useful for other transports, for SFTP servers that do not support setting file attributes after put/upload set to N Override for SystemMessageType.sendServiceName Username for basic auth when sending to the remote system. This user needs permission to run the remote service or whatever on the remote system receives the message. Note: For a Moqui remote server the user needs authz for the org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage service, ie the user should be in a group that has authz for the SystemMessageServices ArtifactGroup such as the SYSMSG_RECEIVE user group (see SecurityTypeData.xml). Username for basic auth when sending to the remote system. Public Key for key based authentication, generally RSA PEM format Private Key for key based authentication, generally RSA PEM PKCS #8 format like OpenSSH Remote System's Public Key for decryption, signature validation, etc; generally RSA PEM or X.509 Certificate format Shared secret for auth on receive and/or sign on send. Shared secret for auth on send if different from secret used to authorize on receive. If send and receive auth mechanisms are different specify send auth method here Optional. May be used when this remote is for one type of message. Sender (outgoing) or receiver (incoming) ID (EDI: in ISA06/08; OAGIS in ApplicationArea.Sender/Receiver.ID) Application code (EDI: in GS02/03; OAGIS: in ApplicationArea.Sender/Receiver elements, split among sub-elements) Sender (incoming) or receiver (outgoing) ID (EDI: in ISA06/08; OAGIS in ApplicationArea.Sender/Receiver.ID) Application code (EDI: in GS02/03; OAGIS: in ApplicationArea.Sender/Receiver elements, split among sub-elements) Request acknowledgement? Possible values dependent on message standard. Used for production versus test/etc. Possible values dependent on message standard. Remote system related to this remote system but for pre-auth purposes, like a separate single sign on server For runtime configurable enum mappings for a particular remote system. For bi-directional integrations enumerated value mappings should be one to one or round trip results will be inconsistent. The PK structure forces one mapped value for each enumId. ================================================ FILE: framework/entity/TestEntities.xml ================================================ ================================================ FILE: framework/screen/AddedEmailAuthcFactor.xml ================================================ ]]> ]]> ================================================ FILE: framework/screen/EmailAuthcFactorSent.xml ================================================ ]]> ]]> ================================================ FILE: framework/screen/NotificationEmail.xml ================================================

${topicDescription!""} Notification (${type!"info"})

${title}

<#if linkUrl?has_content>

<#if linkUrl?starts_with("http")>${linkUrl}<#else>${linkUrl}

<#if notificationMessageId?has_content>Notification Message ID ${notificationMessageId} Sent ${ec.l10n.format(sentDate, "")} to topic '${topic}'

]]>
Notification Message ID ${notificationMessageId} Sent ${ec.l10n.format(sentDate, '')} to topic '${topic}' ]]>
================================================ FILE: framework/screen/PasswordReset.xml ================================================ ]]>
]]>
================================================ FILE: framework/screen/ScreenRenderEmail.xml ================================================

${title}

<#if curScreenUrl?has_content>

${curScreenUrl}

<#list bodyParmKeys as parmName> <#assign parmValue = bodyParameters.get(parmName)!> <#if parmValue?has_content>
${parmName}: ${parmValue}
]]>
${curScreenUrl} <#list bodyParmKeys as parmName> <#assign parmValue = bodyParameters.get(parmName)!> <#if parmValue?has_content> ${parmName}: ${parmValue} ]]>
================================================ FILE: framework/screen/SingleUseCode.xml ================================================ ]]> ]]> ================================================ FILE: framework/service/org/moqui/EmailServices.xml ================================================ Send Email with settings in EmailTemplate entity record Comma separated list of to email addresses Comma separated list of CC email addresses Comma separated list of BCC email addresses From the Message-ID email header field. If createEmailMessage=true the ID of the EmailMessage record. Defines input parameters matching what is available when an Email ECA rule is called. List of Map for each body part. If the message is not multi-part will have a single entry. All header names (keys) are converted to lower case for consistency. If multiple values for a header name are found they will be put in a List. ================================================ FILE: framework/service/org/moqui/EntityServices.xml ================================================ Services named in the DataFeed.feedReceiveServiceName field should implement this interface. The combined PK field values of the primary entity in the DataDocument. If there is more than one PK field the values are separated with a double-colon ("::"). The DataDocument.dataDocumentId that defines the document structure, etc. From DataDocument.indexName, if specified. Document timestamp in the format yyyy-MM-dd'T'HH:mm:ss The combined PK field values of the primary entity in the DataDocument. If there is more than one PK field the values are separated with a double-colon ("::"). Services named in the DataDocument.manualDataServiceName field should implement this interface. For details see document parameter in receive#DataFeed. For details see document parameter in receive#DataFeed. Services named in the DataDocument.manualMappingServiceName field should implement this interface. ================================================ FILE: framework/service/org/moqui/SmsServices.xml ================================================ If there is a single string for phone number pass it here, may include a country code if begins with '+' ================================================ FILE: framework/service/org/moqui/impl/BasicServices.xml ================================================ For current Find Options will be only geoId to include in output Key in results is toStatusId. Recommended display expression is "${transitionName} (${description})". Converts amount and returns convertedAmount. The factor is multiplied first, then the offset is added. If no UomConversion record is found for uomId and toUomId tries the reverse. When converting in the reverse direction the offset is subtracted first, then divided by the factor. ================================================ FILE: framework/service/org/moqui/impl/ElFinderServices.xml ================================================ See elFinder API docs at: https://github.com/Studio-42/elFinder/wiki/Client-Server-API-2.0 ================================================ FILE: framework/service/org/moqui/impl/EmailServices.xml ================================================ NOTE: this service is meant for internal use and authentication is not required. Do not export or allow this service to be called remotely. Poll an email server (IMAP or POP3) to receive messages. Each new message is processed using the Email-ECA rules. Messages are flagged as seen (if supported). Messages are deleted if the storeDelete flag is set on the moqui.basic.email.EmailServer record. This is meant to be called as a scheduled service, run as often as you want to poll for new messages on a particular server (configured in the corresponding moqui.basic.email.EmailServer record). Add a job in the quartz_data.xml file for this service to run this scheduled. Comma separated list of to email addresses Comma separated list of CC email addresses Comma separated list of BCC email addresses ================================================ FILE: framework/service/org/moqui/impl/EntityServices.xml ================================================ This service gets the latest documents for a DataFeed based on DataFeed.lastFeedStamp, and updates lastFeedStamp to the current time. Send a NotificationMessage for each in documentList. Finds all userId fields nested somewhere in the document. Only sent to users where a userId is found, and only sent if userId values are found. The topic will be the dataDocumentId (document._type). If no NotificationTopic record is found for the topic will set the NotificationMessage title to DataDocument.documentTitle and will find the first DataDocumentLink and set NotificationMessage link to its linkUrl. To fully update data documents and feeds from seed data: run this, import 'seed' data, run feed indexes. If any custom data documents (not in seed data) are in place they will be lost. ================================================ FILE: framework/service/org/moqui/impl/EntitySyncServices.xml ================================================ List of Maps to be ORed together List of Maps to be ORed together ================================================ FILE: framework/service/org/moqui/impl/GoogleServices.xml ================================================ reCAPTCHA token required, confirm you are not a robot ================================================ FILE: framework/service/org/moqui/impl/InstanceServices.xml ================================================ Checked database ${envMap.entity_ds_database} at ${adminMap.entity_ds_host}: connect ${dbConnectSuccess ? 'successful' : 'failed'}, database ${databaseExists ? 'exists' : 'does not exist'}, user ${envMap.entity_ds_user} ${dbUserExists ? 'exists' : 'does not exist'} ================================================ FILE: framework/service/org/moqui/impl/PrintServices.xml ================================================ Get printers from print server and create a moqui.basic.print.NetworkPrinter record for each. Create a moqui.basic.print.PrintJob record and send it to the specified NetworkPrinter The document may be passed in this parameter as an InputStream or in the serialBlob field as a wrapped byte[]. Use SerialBlob as a wrapper for byte[]. Groovy expression that evaluates to a Map Gets known local job details (from PrintJob record) job details/attributes from the print server, updating PrintJob record for status and just returning the rest. ================================================ FILE: framework/service/org/moqui/impl/ScreenServices.xml ================================================ Updates existing FormResponseAnswer or adds new ones as needed. Note that this doesn't work with fields that have multiple responses (it will update the first response). ================================================ FILE: framework/service/org/moqui/impl/ServerServices.xml ================================================ Gets data from freegeoip.net for client IP address and populates in Visit record ================================================ FILE: framework/service/org/moqui/impl/ServiceServices.xml ================================================ ================================================ FILE: framework/service/org/moqui/impl/SystemMessageServices.xml ================================================ If not specified comes from SystemMessage.systemMessageRemoteId Queue an outgoing message. Creates a SystemMessage record for the outgoing message in the Produced status. If sendNow=true (default) will attempt to send it immediately (though asynchronously), otherwise the message will be picked up the next time the send#ProducedSystemMessages service runs. Sequenced if null, may be passed in (sequenced value determined in advance) because sometimes this is needed as a reference ID inside a message. Required if the send service (SystemMessageType.sendServiceName) requires it. The send#SystemMessageJsonRpc service does require it. Call the service to produce an async acknowledgement message (SystemMessageType.produceAckServiceName). If sendNow=true (default) will attempt to send it immediately (though asynchronously), otherwise the message will be picked up the next time the send#ProducedSystemMessages service runs. Calls the send service (SystemMessageType.sendServiceName). Sets the SystemMessage status to SmsgSending while sending, then to SmsgSent if successful or back to original status if not. If the initial status is not SmsgProduced or SmsgError returns an error (generally means message already sent). If you want to resend a message that is in a later status, first change the status to SmsgProduced. Call to receive a message (often through a remote interface). If there is a SystemMessageType.receiveServiceName calls that service to save the message, otherwise creates a SystemMessage record for the incoming message (in the Received status). Either way after saving asynchronously calls the consume service based on the message type. Calls the consume service (SystemMessageType.consumeServiceName). Sets the SystemMessage status to SmsgConsuming while consuming, then to SmsgConsumed if successful or back to original status if not. If the initial status is not SmsgReceived or SmsgError returns an error (generally means message already consumed). If you want to resend a message that is in a later status, first change the status to SmsgReceived. This uses a transaction timeout of 1800 seconds (30 minutes) as the default for the service and as the default for the consume service configured on the SystemMessageType. For incoming messages that require even more processing time it is best to break up the processing in a separate ServiceJob or other async service calls. Meant to be run scheduled, this service tries to send outgoing (isOutgoing=Y) messages in the SmsgProduced status. After retryLimit attempts will change the status to SmsgError. Consume incoming (isOutgoing=N) SystemMessage records not already consumed (in the SmsgReceived status). Messages in this state will normally have had an error in consuming. After retryLimit attempts will change the status to SmsgError. ================================================ FILE: framework/service/org/moqui/impl/UserServices.xml ================================================ Authentication code is not valid Account created with username ${username} Because of password issues not creating account with username ${username} Set a user's password. The userId must match the current user and the oldPassword must match the user's currentPassword or special permission is required, or user has already pre-authenticated and specified an authz code. Defaults to the current userId in the ExecutionContext. May be used instead of userId to identify user. Ignored if user has password admin permissions. Second factor authentication, required if second factor required for user (via group or authc factors configured) Password updated for user ${userAccount.username} New Password and New Password Verify do not match Password shorter than ${minLength} characters Password needs ${minDigits} digit/number characters Password needs ${minOthers} other characters (not letter or digit) New password is same as current password Password was used in last ${historyLimit} passwords Enable a disabled account (set disabled=N, disabledDateTime=null, successiveFailedLogins=0) Disable an account (set disabled=Y, disabledDateTime=now) May be used instead of userId to identify user. Could not find account with username or email address ${username} Account with username ${username} does not have an email address A reset password was sent to the email of username ${userAccount.username}. This password may only be used to change your password. Your current password is still valid. You must change your password before login. This service is the definition of when a 2nd factor is required for a user, used in MoquiShiroRealm and elsewhere Determine whether a user inputted code is valid based on the current UserAuthcFactor entries for that user. Create multiple single use authentication factors. Create a single use authentication factor. Send authentication code for the factorId, looks up service to use based on factorTypeEnumId For public access (outside an admin app) this should be called from send#AuthcCode which does validation, including verifying factor is owned by authc username in session. Service to send an email with a single use code in it for verifying an email. Authentication code sent to ${emailAddress} Authentication code sent to ${emailAddress} For public access (outside an admin app) this should be called from send#AuthcCode which does validation, including verifying factor is owned by authc username in session. Service to send a SMS message with a single use code in it. Authentication code sent to ${contactNumber} Creates a UserAuthcFactor entry for email User to enable Factor Method Creates a UserAuthcFactor entry for SMS User to enable Factor Method Create an Authenticator App Factor. Invalidate a factorId and any UserAuthcFactor entries that are dependant on that factorId. ================================================ FILE: framework/service/org/moqui/impl/WikiServices.xml ================================================ Get the published version of a wiki page by its space and path. If there is no published version behaves as if no page was found. Alternative to pagePath when caller has a list of path elements (instead of forward slash separated string) Meant for testing, get this version instead of published. To eliminate exposure of non-published versions explicitly set this to null. Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space. If not specified last segment of pagePath will be used Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space. Optional if pagePath is a wikiPageId or the first segment is a wikiSpaceId Not required, is empty for the space root page. Can be a wikiPageId or the page path within the space. If not specified last segment of pagePath will be used Optional, existing pages normally looked up by pagePath, use to refer to a specific existing page Defaults to parentPath/pageName (both may be empty, resulting in empty pagePath). To update a pageName of an existing page this must be specified along with the new pageName. This is required for better usability. If pageName == wikiSpaceId is treated as the root page. If WikiSpace.allowAnyHtml = Y will be stored as-is, otherwise filtered like parameter.allow-html=safe. Source/base wiki space Destination/target wiki space Source page path Path of parent page in target space (wikiSpaceId), if not specified use the same pagePath as the source Parent page not found at ${parentPath}, copying under Root Page Not copying wiki page alias ${baseWikiPageAlias.aliasPath}, already exists in space ${wikiSpaceId} Out pagePath is path of parent or empty If specified all pages starting with this path will be excluded If specified then flat list contains only items with this category assigned. ================================================ FILE: framework/service/org/moqui/search/ElasticSearchServices.xml ================================================ ================================================ FILE: framework/service/org/moqui/search/SearchServices.xml ================================================ Index all documents associated with the feed within the date range. Recommend calling through the IndexDataFeedDocuments service job. Indexed ${documentsIndexed} documents for feed ${dataFeedId} in ${System.currentTimeMillis() - startTime}ms Found and indexed ${allChildFileFlatList.size()} pages in Wiki Space ${wikiSpaceId}, created DB records for ${recordsCreated}. The queryString format is the ElasticSearch supported one, based on the Lucene query strings which are documented here: https://www.elastic.co/guide/en/elasticsearch/reference/current/query-dsl-query-string-query.html Sort options are described here: https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html#request-body-search-sort The ElasticSearch document type. For DataDocument based docs this is the dataDocumentId. For explicit field constraints and such; key is path and may be null for a general query; value is a query JSON String (parsed to Map) or a query Map object The total count of hits, not just the limited number returned. For DataFeed compatibility supports dataDocumentId that if specified is converted to valid ElasticSearch index name instead of using indexName parameter ================================================ FILE: framework/src/main/groovy/org/moqui/impl/actions/XmlAction.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.actions; import freemarker.core.Environment; import groovy.lang.Script; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.runtime.InvokerHelper; import org.moqui.BaseArtifactException; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.util.MNode; import org.moqui.util.StringUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.StringWriter; import java.io.Writer; import java.util.HashMap; import java.util.Map; public class XmlAction { private static final Logger logger = LoggerFactory.getLogger(XmlAction.class); private static final boolean isDebugEnabled = logger.isDebugEnabled(); protected final ExecutionContextFactoryImpl ecfi; private final MNode xmlNode; protected final String location; /** The Groovy class compiled from the script transformed from the XML actions text using the FTL template. */ private Class groovyClassInternal = null; public XmlAction(ExecutionContextFactoryImpl ecfi, MNode xmlNode, String location) { this.ecfi = ecfi; this.xmlNode = xmlNode; this.location = location; } public XmlAction(ExecutionContextFactoryImpl ecfi, String xmlText, String location) { this.ecfi = ecfi; this.location = location; if (xmlText != null && !xmlText.isEmpty()) { xmlNode = MNode.parseText(location, xmlText); } else { xmlNode = MNode.parseText(location, ecfi.resourceFacade.getLocationText(location, false)); } } /** Run the XML actions in the current context of the ExecutionContext */ public Object run(ExecutionContextImpl eci) { Class curClass = getGroovyClass(); if (curClass == null) throw new IllegalStateException("No Groovy class in place for XML actions, look earlier in log for the error in init"); if (isDebugEnabled) logger.debug("Running groovy script: \n" + writeGroovyWithLines() + "\n"); Script script = InvokerHelper.createScript(curClass, eci.contextBindingInternal); try { return script.run(); } catch (Throwable t) { // NOTE: not logging full stack trace, only needed when lots of threads are running to pin down error (always logged later) String tString = t.toString(); if (!tString.contains("org.eclipse.jetty.io.EofException")) logger.error("Error running groovy script (" + t.toString() + "): \n" + writeGroovyWithLines() + "\n"); throw t; } } public boolean checkCondition(ExecutionContextImpl eci) { Object result = run(eci); if (result == null) return false; return DefaultGroovyMethods.asType(run(eci), Boolean.class); } // used in tools screens, must be public public String writeGroovyWithLines() { String groovyString = getGroovyString(); StringBuilder groovyWithLines = new StringBuilder(); int lineNo = 1; for (String line : groovyString.split("\n")) groovyWithLines.append(lineNo++).append(" : ").append(line).append("\n"); return groovyWithLines.toString(); } public Class getGroovyClass() { if (groovyClassInternal != null) return groovyClassInternal; return makeGroovyClass(); } protected synchronized Class makeGroovyClass() { if (groovyClassInternal != null) return groovyClassInternal; String curGroovy = getGroovyString(); // if (logger.isTraceEnabled()) logger.trace("Xml Action [${location}] groovyString: ${curGroovy}") try { groovyClassInternal = ecfi.compileGroovy(curGroovy, StringUtilities.cleanStringForJavaName(location)); } catch (Throwable t) { groovyClassInternal = null; logger.error("Error parsing groovy String at [" + location + "]:\n" + writeGroovyWithLines() + "\n"); throw t; } return groovyClassInternal; } public String getGroovyString() { // transform XML to groovy String groovyString; try { Map root = new HashMap<>(1); root.put("xmlActionsRoot", xmlNode); Writer outWriter = new StringWriter(); Environment env = ecfi.resourceFacade.getXmlActionsScriptRunner().getXmlActionsTemplate().createProcessingEnvironment(root, outWriter); env.process(); groovyString = outWriter.toString(); } catch (Exception e) { logger.error("Error reading XML actions from [" + location + "], text: " + xmlNode.toString()); throw new BaseArtifactException("Error reading XML actions from [" + location + "]", e); } if (logger.isTraceEnabled()) logger.trace("XML actions at [" + location + "] produced groovy script:\n" + groovyString + "\nFrom xmlNode:" + xmlNode.toString()); return groovyString; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ArtifactExecutionFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.moqui.entity.EntityException import org.moqui.impl.entity.EntityConditionFactoryImpl import org.moqui.impl.entity.condition.EntityConditionImplBase import java.sql.Timestamp import org.moqui.BaseException import org.moqui.context.ArtifactAuthorizationException import org.moqui.context.ArtifactExecutionFacade import org.moqui.context.ArtifactExecutionInfo import org.moqui.context.ArtifactTarpitException import org.moqui.entity.EntityCondition import org.moqui.entity.EntityCondition.ComparisonOperator import org.moqui.entity.EntityCondition.JoinOperator import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactAuthzCheck import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ArtifactExecutionFacadeImpl implements ArtifactExecutionFacade { protected final static Logger logger = LoggerFactory.getLogger(ArtifactExecutionFacadeImpl.class) protected ExecutionContextImpl eci private ArrayDeque artifactExecutionInfoStack = new ArrayDeque(10) private ArrayList artifactExecutionInfoHistory = new ArrayList(50) private ArrayList aeiStackCache = (ArrayList) null // this is used by ScreenUrlInfo.isPermitted() which is called a lot, but that is transient so put here to have one per EC instance protected Map screenPermittedCache = null protected boolean authzDisabled = false protected boolean tarpitDisabled = false protected boolean entityEcaDisabled = false protected boolean entityAuditLogDisabled = false protected boolean entityFkCreateDisabled = false protected boolean entityDataFeedDisabled = false ArtifactExecutionFacadeImpl(ExecutionContextImpl eci) { this.eci = eci } Map getScreenPermittedCache() { if (screenPermittedCache == null) screenPermittedCache = new HashMap<>() return screenPermittedCache } @Override ArtifactExecutionInfo peek() { return this.artifactExecutionInfoStack.peekFirst() } @Override ArtifactExecutionInfo push(String name, ArtifactExecutionInfo.ArtifactType typeEnum, ArtifactExecutionInfo.AuthzAction actionEnum, boolean requiresAuthz) { ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(name, typeEnum, actionEnum, "") pushInternal(aeii, requiresAuthz, true) return aeii } @Override void push(ArtifactExecutionInfo aei, boolean requiresAuthz) { ArtifactExecutionInfoImpl aeii = (ArtifactExecutionInfoImpl) aei pushInternal(aeii, requiresAuthz, true) } void pushInternal(ArtifactExecutionInfoImpl aeii, boolean requiresAuthz, boolean countTarpit) { ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst() // always do this regardless of the authz checks, etc; keep a history of artifacts run if (lastAeii != null) { lastAeii.addChild(aeii); aeii.setParent(lastAeii) } else artifactExecutionInfoHistory.add(aeii) // if ("AT_XML_SCREEN" == aeii.typeEnumId) logger.warn("TOREMOVE artifact push ${username} - ${aeii}") if (!isPermitted(aeii, lastAeii, requiresAuthz, countTarpit, true, null)) { Deque curStack = getStack() StringBuilder warning = new StringBuilder() warning.append("User ${eci.user.username ?: eci.user.userId ?: '[No User]'} is not authorized for ${aeii.getActionDescription()} on ${aeii.getTypeDescription()} ${aeii.getName()}") ArtifactAuthorizationException e = new ArtifactAuthorizationException(warning.toString(), aeii, curStack) // end users see this message in vuet mode so better not to add all of this to the main message: warning.append("\nCurrent artifact info: ${aeii.toString()}\n") warning.append("Current artifact stack:") for (ArtifactExecutionInfo warnAei in curStack) warning.append("\n").append(warnAei.toString()) logger.warn("Artifact authorization failed: " + warning.toString()) throw e } // set the moquiTxId for all that make it onto the stack aeii.setMoquiTxId(eci.transactionFacade.getTxStackInfo().moquiTxId) // NOTE: if needed the isPermitted method will set additional info in aeii this.artifactExecutionInfoStack.addFirst(aeii) this.aeiStackCache = (ArrayList) null } @Override ArtifactExecutionInfo pop(ArtifactExecutionInfo aei) { try { ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.removeFirst() this.aeiStackCache = (ArrayList) null // removed this for performance reasons, generally just checking the name is adequate // || aei.typeEnumId != lastAeii.typeEnumId || aei.actionEnumId != lastAeii.actionEnumId if (aei != null && !lastAeii.nameInternal.equals(aei.getName())) { String popMessage = "Popped artifact (${aei.name}:${aei.getTypeDescription()}:${aei.getActionDescription()}) did not match top of stack (${lastAeii.name}:${lastAeii.getTypeDescription()}:${lastAeii.getActionDescription()}:${lastAeii.actionDetail})" logger.warn(popMessage, new BaseException("Pop Error Location")) //throw new IllegalArgumentException(popMessage) } // set end time lastAeii.setEndTime() // count artifact hit (now done here instead of by each caller) // NOTE DEJ 20191229 removed condition where only artifacts requiring authz are counted: && lastAeii.internalAuthzWasRequired if (lastAeii.trackArtifactHit && lastAeii.isAccess) eci.ecfi.countArtifactHit(lastAeii.internalTypeEnum, lastAeii.actionDetail, lastAeii.nameInternal, lastAeii.parameters, lastAeii.startTimeMillis, lastAeii.getRunningTimeMillisDouble(), lastAeii.outputSize) return lastAeii } catch(NoSuchElementException e) { logger.warn("Tried to pop from an empty ArtifactExecutionInfo stack", e) return null } } @Override Deque getStack() { return new ArrayDeque(this.artifactExecutionInfoStack) } @Override ArrayList getStackArray() { if (aeiStackCache != null) return aeiStackCache aeiStackCache = new ArrayList(this.artifactExecutionInfoStack) return aeiStackCache } String getStackNameString() { StringBuilder sb = new StringBuilder() Iterator i = this.artifactExecutionInfoStack.iterator() while (i.hasNext()) { ArtifactExecutionInfo aei = (ArtifactExecutionInfo) i.next() sb.append(aei.name) if (i.hasNext()) sb.append(', ') } return sb.toString() } @Override List getHistory() { List newHistList = new ArrayList<>() newHistList.addAll(this.artifactExecutionInfoHistory) return newHistList } String printHistory() { StringWriter sw = new StringWriter() for (ArtifactExecutionInfo aei in artifactExecutionInfoHistory) aei.print(sw, 0, true) return sw.toString() } ArtifactExecutionInfoImpl.ArtifactTypeStats getArtifactTypeStats() { return ArtifactExecutionInfoImpl.getArtifactTypeStats(artifactExecutionInfoHistory) } void logProfilingDetail() { if (!logger.isInfoEnabled()) return StringWriter sw = new StringWriter() sw.append("========= Hot Spots by Own Time =========\n") sw.append("[{time}:{timeMin}:{timeAvg}:{timeMax}][{count}] {type} {action} {actionDetail} {name}\n") List> ownHotSpotList = ArtifactExecutionInfoImpl.hotSpotByTime(artifactExecutionInfoHistory, true, "-time") ArtifactExecutionInfoImpl.printHotSpotList(sw, ownHotSpotList) logger.info(sw.toString()) sw = new StringWriter() sw.append("========= Hot Spots by Total Time =========\n") sw.append("[{time}:{timeMin}:{timeAvg}:{timeMax}][{count}] {type} {action} {actionDetail} {name}\n") List> totalHotSpotList = ArtifactExecutionInfoImpl.hotSpotByTime(artifactExecutionInfoHistory, false, "-time") ArtifactExecutionInfoImpl.printHotSpotList(sw, totalHotSpotList) logger.info(sw.toString()) /* leave this out by default, sometimes interesting, but big sw = new StringWriter() sw.append("========= Consolidated Artifact List =========\n") sw.append("[{time}:{thisTime}:{childrenTime}][{count}] {type} {action} {actionDetail} {name}\n") List consolidatedList = ArtifactExecutionInfoImpl.consolidateArtifactInfo(artifactExecutionInfoHistory) ArtifactExecutionInfoImpl.printArtifactInfoList(sw, consolidatedList, 0) logger.info(sw.toString()) */ } void setAnonymousAuthorizedAll() { ArtifactExecutionInfoImpl aeii = artifactExecutionInfoStack.peekFirst() aeii.authorizationInheritable = true aeii.authorizedUserId = eci.getUser().getUserId() ?: "_NA_" if (aeii.authorizedAuthzType != ArtifactExecutionInfo.AUTHZT_ALWAYS) aeii.authorizedAuthzType = ArtifactExecutionInfo.AUTHZT_ALLOW aeii.internalAuthorizedActionEnum = ArtifactExecutionInfo.AUTHZA_ALL } void setAnonymousAuthorizedView() { ArtifactExecutionInfoImpl aeii = artifactExecutionInfoStack.peekFirst() aeii.authorizationInheritable = true aeii.authorizedUserId = eci.getUser().getUserId() ?: "_NA_" if (aeii.authorizedAuthzType != ArtifactExecutionInfo.AUTHZT_ALWAYS) aeii.authorizedAuthzType = ArtifactExecutionInfo.AUTHZT_ALLOW if (aeii.authorizedActionEnum != ArtifactExecutionInfo.AUTHZA_ALL) aeii.authorizedActionEnum = ArtifactExecutionInfo.AUTHZA_VIEW } boolean disableAuthz() { boolean alreadyDisabled = authzDisabled; authzDisabled = true; return alreadyDisabled } void enableAuthz() { authzDisabled = false } boolean getAuthzDisabled() { return authzDisabled } boolean disableTarpit() { boolean alreadyDisabled = tarpitDisabled; tarpitDisabled = true; return alreadyDisabled } void enableTarpit() { tarpitDisabled = false } // boolean getTarpitDisabled() { return tarpitDisabled } boolean disableEntityEca() { boolean alreadyDisabled = entityEcaDisabled; entityEcaDisabled = true; return alreadyDisabled } void enableEntityEca() { entityEcaDisabled = false } boolean entityEcaDisabled() { return entityEcaDisabled } boolean disableEntityAuditLog() { boolean alreadyDisabled = entityAuditLogDisabled; entityAuditLogDisabled = true; return alreadyDisabled } void enableEntityAuditLog() { entityAuditLogDisabled = false } boolean entityAuditLogDisabled() { return entityAuditLogDisabled } boolean disableEntityFkCreate() { boolean alreadyDisabled = entityFkCreateDisabled; entityFkCreateDisabled = true; return alreadyDisabled } void enableEntityFkCreate() { entityFkCreateDisabled = false } boolean entityFkCreateDisabled() { return entityFkCreateDisabled } boolean disableEntityDataFeed() { boolean alreadyDisabled = entityDataFeedDisabled; entityDataFeedDisabled = true; return alreadyDisabled } void enableEntityDataFeed() { entityDataFeedDisabled = false } boolean entityDataFeedDisabled() { return entityDataFeedDisabled } /** Checks to see if username is permitted to access given resource. * * @param resourceAccess Formatted as: "${typeEnumId}:${actionEnumId}:${name}" * @param nowTimestamp * @param eci */ static boolean isPermitted(String resourceAccess, ExecutionContextImpl eci) { int firstColon = resourceAccess.indexOf(":") int secondColon = resourceAccess.indexOf(":", firstColon + 1) if (firstColon == -1 || secondColon == -1) throw new ArtifactAuthorizationException("Resource access string does not have two colons (':'), must be formatted like: \"\${typeEnumId}:\${actionEnumId}:\${name}\"", null, null) ArtifactExecutionInfo.ArtifactType typeEnum = ArtifactExecutionInfo.ArtifactType.valueOf(resourceAccess.substring(0, firstColon)) ArtifactExecutionInfo.AuthzAction actionEnum = ArtifactExecutionInfo.AuthzAction.valueOf(resourceAccess.substring(firstColon + 1, secondColon)) String name = resourceAccess.substring(secondColon + 1) return eci.artifactExecutionFacade.isPermitted(new ArtifactExecutionInfoImpl(name, typeEnum, actionEnum, ""), null, true, true, false, null) } boolean isPermitted(ArtifactExecutionInfoImpl aeii, ArtifactExecutionInfoImpl lastAeii, boolean requiresAuthz, boolean countTarpit, boolean isAccess, ArrayDeque currentStack) { ArtifactExecutionInfo.ArtifactType artifactTypeEnum = aeii.internalTypeEnum boolean isEntity = ArtifactExecutionInfo.AT_ENTITY.is(artifactTypeEnum) // right off record whether authz is required and is access aeii.setAuthzReqdAndIsAccess(requiresAuthz, isAccess) // never do this for entities when disableAuthz, as we might use any below and would cause infinite recursion // for performance reasons if this is an entity and no authz required don't bother looking at tarpit, checking for deny/etc if ((!requiresAuthz || this.authzDisabled) && isEntity) { if (lastAeii != null && lastAeii.authorizationInheritable) aeii.copyAuthorizedInfo(lastAeii) return true } // if ("AT_XML_SCREEN" == aeii.typeEnumId) logger.warn("TOREMOVE artifact isPermitted after authzDisabled ${aeii}") ExecutionContextFactoryImpl ecfi = eci.ecfi UserFacadeImpl ufi = eci.userFacade if (!isEntity && countTarpit && !tarpitDisabled && Boolean.TRUE.is((Boolean) ecfi.artifactTypeTarpitEnabled.get(artifactTypeEnum)) && (requiresAuthz || (!ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) && !ArtifactExecutionInfo.AT_REST_PATH.is(artifactTypeEnum)))) { checkTarpit(aeii) } // if last was an always allow, then don't bother checking for deny/etc - this is a common case if (lastAeii != null && lastAeii.internalAuthorizationInheritable && ArtifactExecutionInfo.AUTHZT_ALWAYS.is(lastAeii.internalAuthorizedAuthzType) && (ArtifactExecutionInfo.AUTHZA_ALL.is(lastAeii.internalAuthorizedActionEnum) || aeii.internalActionEnum.is(lastAeii.internalAuthorizedActionEnum))) { // NOTE: used to also check userId.equals(lastAeii.internalAuthorizedUserId), but rare if ever that could even happen aeii.copyAuthorizedInfo(lastAeii) // if ("AT_XML_SCREEN" == aeii.typeEnumId && aeii.getName().contains("FOO")) // logger.warn("TOREMOVE artifact isPermitted already authorized for user ${userId} - ${aeii}") return true } // tarpit enabled already checked, if authz not enabled return true immediately // NOTE: do this after the check above as authz is normally enabled so this doesn't normally save is any time if (!Boolean.TRUE.is((Boolean) ecfi.artifactTypeAuthzEnabled.get(artifactTypeEnum))) { if (lastAeii != null) aeii.copyAuthorizedInfo(lastAeii) return true } // search entire list for deny and allow authz, then check for allow with no deny after ArtifactAuthzCheck denyAacv = (ArtifactAuthzCheck) null ArtifactAuthzCheck allowAacv = (ArtifactAuthzCheck) null // see if there is a UserAccount for the username, and if so get its userId as a more permanent identifier String userId = ufi.getUserId() if (userId == null) userId = "" // don't check authz for these queries, would cause infinite recursion boolean alreadyDisabled = disableAuthz() try { // don't make a big condition for the DB to filter the list, or EntityList.filterByCondition from bigger // cached list, both are slower than manual iterate and check fields explicitly ArrayList aacvList = new ArrayList<>() ArrayList origAacvList = ufi.getArtifactAuthzCheckList() int origAacvListSize = origAacvList.size() for (int i = 0; i < origAacvListSize; i++) { ArtifactAuthzCheck aacv = (ArtifactAuthzCheck) origAacvList.get(i) if (artifactTypeEnum.is(aacv.artifactType) && (ArtifactExecutionInfo.AUTHZA_ALL.is(aacv.authzAction) || aeii.internalActionEnum.is(aacv.authzAction)) && (aacv.nameIsPattern || aeii.nameInternal.equals(aacv.artifactName))) { aacvList.add(aacv) } } // if ((ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) || ArtifactExecutionInfo.AT_XML_SCREEN_TRANS.is(artifactTypeEnum)) && aeii.getName().contains("recordChange")) // logger.warn("TOREMOVE for aeii [${aeii}] artifact isPermitted\naacvList: ${aacvList}\norigAacvList: ${origAacvList.join("\n")}") int aacvListSize = aacvList.size() for (int i = 0; i < aacvListSize; i++) { ArtifactAuthzCheck aacv = (ArtifactAuthzCheck) aacvList.get(i) // check the name if (aacv.nameIsPattern && !aeii.getName().matches(aacv.artifactName)) continue // check the filterMap if (aacv.filterMap != null && aeii.parameters != null) { Map filterMapObj = (Map) eci.getResource().expression(aacv.filterMap, null) boolean allMatches = true for (Map.Entry filterEntry in filterMapObj.entrySet()) { if (filterEntry.getValue() != aeii.parameters.get(filterEntry.getKey())) allMatches = false } if (!allMatches) continue } ArtifactExecutionInfo.AuthzType authzType = aacv.authzType String authzServiceName = aacv.authzServiceName if (authzServiceName != null && authzServiceName.length() > 0) { Map result = eci.getService().sync().name(authzServiceName) .parameters([userId:userId, authzActionEnumId:aeii.getActionEnum().name(), artifactTypeEnumId:artifactTypeEnum.name(), artifactName:aeii.getName()]).call() if (result?.authzTypeEnumId) authzType = ArtifactExecutionInfo.AuthzType.valueOf((String) result.authzTypeEnumId) } // if ("AT_XML_SCREEN" == aeii.typeEnumId && aeii.getName().contains("FOO")) // logger.warn("TOREMOVE found authz record for aeii [${aeii}]: ${aacv}") if (ArtifactExecutionInfo.AUTHZT_DENY.is(authzType)) { // we already know last was not always allow (checked above), so keep going in loop just in case // we find an always allow in the query denyAacv = aacv } else if (ArtifactExecutionInfo.AUTHZT_ALWAYS.is(authzType)) { aeii.copyAacvInfo(aacv, userId, true) // if ("AT_XML_SCREEN" == aeii.typeEnumId) // logger.warn("TOREMOVE artifact isPermitted found always allow for user ${userId} - ${aeii}") return true } else if (denyAacv == null && ArtifactExecutionInfo.AUTHZT_ALLOW.is(authzType)) { // see if there are any denies in AEIs on lower on the stack boolean ancestorDeny = false for (ArtifactExecutionInfoImpl ancestorAeii in (currentStack ?: artifactExecutionInfoStack)) if (ArtifactExecutionInfo.AUTHZT_DENY.is(ancestorAeii.getAuthorizedAuthzType())) ancestorDeny = true if (!ancestorDeny) allowAacv = aacv } } } finally { if (!alreadyDisabled) enableAuthz() } if (denyAacv != null) { // record that this was an explicit deny (for push or exception in case something catches and handles it) aeii.copyAacvInfo(denyAacv, userId, false) if (!requiresAuthz || this.authzDisabled) { // if no authz required, just return true even though it was a failure // if ("AT_XML_SCREEN" == aeii.typeEnumId && aeii.getName().contains("FOO")) // logger.warn("TOREMOVE artifact isPermitted (in deny) doesn't require authz or authzDisabled for user ${userId} - ${aeii}") return true } else { StringBuilder warning = new StringBuilder() warning.append("User [${userId}] is not authorized for ${aeii.getTypeDescription()} [${aeii.getName()}] because of a deny record [type:${artifactTypeEnum.name()},action:${aeii.getActionEnum().name()}], here is the current artifact stack:") for (warnAei in this.stack) warning.append("\n").append(warnAei.toString()) logger.warn(warning.toString()) eci.getService().sync().name("create", "moqui.security.ArtifactAuthzFailure").parameters( [artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(), authzActionEnumId:aeii.getActionEnum().name(), userId:userId, failureDate:new Timestamp(System.currentTimeMillis()), isDeny:"Y"]).disableAuthz().call() return false } } else if (allowAacv != null) { aeii.copyAacvInfo(allowAacv, userId, true) // if ("AT_XML_SCREEN" == aeii.typeEnumId && aeii.getName().contains("FOO")) // logger.warn("TOREMOVE artifact isPermitted allow with no deny for user ${userId} - ${aeii}") return true } else { // no perms found for this, only allow if the current AEI has inheritable auth and same user, and (ALL action or same action) // NOTE: this condition allows any user to be authenticated and allow inheritance if the last artifact was // logged in anonymously (ie userId="_NA_"); consider alternate approaches; an alternate approach is // in place when no user is logged in, but when one is this is the only solution so far if (lastAeii != null && lastAeii.internalAuthorizationInheritable && ("_NA_".equals(lastAeii.internalAuthorizedUserId) || lastAeii.internalAuthorizedUserId == userId) && (ArtifactExecutionInfo.AUTHZA_ALL.is(lastAeii.internalAuthorizedActionEnum) || aeii.internalActionEnum.is(lastAeii.internalAuthorizedActionEnum)) && !ArtifactExecutionInfo.AUTHZT_DENY.is(lastAeii.internalAuthorizedAuthzType)) { aeii.copyAuthorizedInfo(lastAeii) // if ("AT_XML_SCREEN" == aeii.typeEnumId) // logger.warn("TOREMOVE artifact isPermitted inheritable and same user and ALL or same action for user ${userId} - ${aeii}") return true } } if (!requiresAuthz || this.authzDisabled) { // if no authz required, just push it even though it was a failure if (lastAeii != null && lastAeii.internalAuthorizationInheritable) aeii.copyAuthorizedInfo(lastAeii) // if ("AT_XML_SCREEN" == aeii.typeEnumId) // logger.warn("TOREMOVE artifact isPermitted doesn't require authz or authzDisabled for user ${userId} - ${aeii}") return true } else { // if we got here no authz found, so not granted (denied) aeii.setAuthorizationWasGranted(false) if (logger.isDebugEnabled()) { StringBuilder warning = new StringBuilder() warning.append("User [${userId}] is not authorized for ${aeii.getTypeDescription()} [${aeii.getName()}] because of no allow record [type:${artifactTypeEnum.name()},action:${aeii.getActionEnum().name()}]\nlastAeii=[${lastAeii}]\nHere is the artifact stack:") for (warnAei in this.stack) warning.append("\n").append(warnAei) logger.debug(warning.toString()) } if (isAccess) { alreadyDisabled = disableAuthz() try { // NOTE: this is called sync because failures should be rare and not as performance sensitive, and // because this is still in a disableAuthz block (if async a service would have to be written for that) eci.service.sync().name("create", "moqui.security.ArtifactAuthzFailure").parameters( [artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(), authzActionEnumId:aeii.getActionEnum().name(), userId:userId, failureDate:new Timestamp(System.currentTimeMillis()), isDeny:"N"]).call() } finally { if (!alreadyDisabled) enableAuthz() } } return false } // if ("AT_XML_SCREEN" == aeii.typeEnumId) logger.warn("TOREMOVE artifact isPermitted got to end for user ${userId} - ${aeii}") // return true } protected void checkTarpit(ArtifactExecutionInfoImpl aeii) { // logger.warn("Count tarpit ${aeii.toBasicString()}", new BaseException("loc")) ExecutionContextFactoryImpl ecfi = eci.ecfi UserFacadeImpl ufi = eci.userFacade ArtifactExecutionInfo.ArtifactType artifactTypeEnum = aeii.internalTypeEnum ArrayList> artifactTarpitCheckList = ufi.getArtifactTarpitCheckList(artifactTypeEnum) if (artifactTarpitCheckList == null || artifactTarpitCheckList.size() == 0) return boolean alreadyDisabled = disableAuthz() try { // record and check velocity limit (tarpit) boolean recordHitTime = false long lockForSeconds = 0L long checkTime = System.currentTimeMillis() // if (artifactTypeEnumId == "AT_XML_SCREEN") // logger.warn("TOREMOVE about to check tarpit [${tarpitKey}], userGroupIdSet=${userGroupIdSet}, artifactTarpitList=${artifactTarpitList}") // see if there is a UserAccount for the username, and if so get its userId as a more permanent identifier String userId = ufi.getUserId() if (userId == null) userId = "" String tarpitKey = userId + '@' + artifactTypeEnum.name() + ':' + aeii.getName() ArrayList hitTimeList = (ArrayList) null int artifactTarpitCheckListSize = artifactTarpitCheckList.size() for (int i = 0; i < artifactTarpitCheckListSize; i++) { Map artifactTarpit = (Map) artifactTarpitCheckList.get(i) if (('Y'.equals(artifactTarpit.nameIsPattern) && aeii.nameInternal.matches((String) artifactTarpit.artifactName)) || aeii.nameInternal.equals(artifactTarpit.artifactName)) { recordHitTime = true if (hitTimeList == null) hitTimeList = (ArrayList) eci.tarpitHitCache.get(tarpitKey) long maxHitsDuration = artifactTarpit.maxHitsDuration as long // count hits in this duration; start with 1 to count the current hit long hitsInDuration = 1L if (hitTimeList != null && hitTimeList.size() > 0) { // copy the list to avoid a ConcurrentModificationException // NOTE: a better approach to concurrency that won't ever miss hits would be better ArrayList hitTimeListCopy = new ArrayList(hitTimeList) for (int htlInd = 0; htlInd < hitTimeListCopy.size(); htlInd++) { Long hitTime = (Long) hitTimeListCopy.get(htlInd) if (hitTime != null && ((hitTime - checkTime) < maxHitsDuration)) hitsInDuration++ } } // logger.warn("TOREMOVE artifact [${tarpitKey}], now has ${hitsInDuration} hits in ${maxHitsDuration} seconds") if (hitsInDuration > (artifactTarpit.maxHitsCount as long) && (artifactTarpit.tarpitDuration as long) > lockForSeconds) { lockForSeconds = artifactTarpit.tarpitDuration as long logger.warn("User [${userId}] exceeded ${artifactTarpit.maxHitsCount} in ${maxHitsDuration} seconds for artifact [${tarpitKey}], locking for ${lockForSeconds} seconds") } } } if (recordHitTime) { if (hitTimeList == null) { hitTimeList = new ArrayList(); eci.tarpitHitCache.put(tarpitKey, hitTimeList) } hitTimeList.add(System.currentTimeMillis()) // logger.warn("TOREMOVE recorded hit time for [${tarpitKey}], now has ${hitTimeList.size()} hits") // check the ArtifactTarpitLock for the current artifact attempt before seeing if there is a new lock to create // NOTE: this only runs if we are recording a hit time for an artifact, so no performance impact otherwise EntityFacadeImpl efi = ecfi.entityFacade EntityList tarpitLockList = efi.find('moqui.security.ArtifactTarpitLock') .condition([userId:userId, artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name()] as Map) .useCache(true).list() .filterByCondition(efi.getConditionFactory().makeCondition('releaseDateTime', ComparisonOperator.GREATER_THAN, ufi.getNowTimestamp()), true) if (tarpitLockList.size() > 0) { Timestamp releaseDateTime = tarpitLockList.get(0).getTimestamp('releaseDateTime') int retryAfterSeconds = ((releaseDateTime.getTime() - System.currentTimeMillis())/1000).intValue() throw new ArtifactTarpitException("User ${userId} has accessed ${aeii.getTypeDescription()} ${aeii.getName()} too many times and may not again until ${eci.l10nFacade.format(releaseDateTime, 'yyyy-MM-dd HH:mm:ss')} (retry after ${retryAfterSeconds} seconds)".toString(), retryAfterSeconds) } } // record the tarpit lock if (lockForSeconds > 0L) { eci.getService().sync().name('create', 'moqui.security.ArtifactTarpitLock').parameters( [userId:userId, artifactName:aeii.getName(), artifactTypeEnumId:artifactTypeEnum.name(), releaseDateTime:(new Timestamp(checkTime + ((lockForSeconds as BigDecimal) * 1000).intValue()))]).call() eci.tarpitHitCache.remove(tarpitKey) } } finally { if (!alreadyDisabled) enableAuthz() } } static class AuthzFilterInfo { String entityFilterSetId EntityValue entityFilterSet EntityValue entityFilter Map> memberFieldAliases AuthzFilterInfo(EntityValue entityFilterSet, EntityValue entityFilter, Map> memberFieldAliases) { this.entityFilterSet = entityFilterSet entityFilterSetId = (String) entityFilterSet?.getNoCheckSimple("entityFilterSetId") this.entityFilter = entityFilter this.memberFieldAliases = memberFieldAliases } } ArrayList getFindFiltersForUser(String findEntityName) { EntityDefinition findEd = eci.entityFacade.getEntityDefinition(findEntityName) return getFindFiltersForUser(findEd, null) } ArrayList getFindFiltersForUser(EntityDefinition findEd, Set entityAliasUsedSet) { // do nothing if authz disabled if (authzDisabled) return null // NOTE: look for filters in all unique aacv in stack? shouldn't be needed, most recent auth is the valid one ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst() ArtifactAuthzCheck aacv = lastAeii.internalAacv if (aacv == null) return null String findEntityName = findEd.getFullEntityName() // skip all Moqui Framework entities; note that this skips moqui.example too... if (findEntityName.startsWith("moqui.")) return null // find applicable EntityFilter records EntityList artifactAuthzFilterList = eci.entityFacade.find("moqui.security.ArtifactAuthzFilter") .condition("artifactAuthzId", aacv.artifactAuthzId).disableAuthz().useCache(true).list() if (artifactAuthzFilterList == null) return null int authzFilterSize = artifactAuthzFilterList.size() if (authzFilterSize == 0) return null ArrayList authzFilterInfoList = (ArrayList) null for (int i = 0; i < authzFilterSize; i++) { EntityValue artifactAuthzFilter = (EntityValue) artifactAuthzFilterList.get(i) String entityFilterSetId = (String) artifactAuthzFilter.getNoCheckSimple("entityFilterSetId") String authzApplyCond = (String) artifactAuthzFilter.getNoCheckSimple("applyCond") EntityValue entityFilterSet = eci.entityFacade.find("moqui.security.EntityFilterSet") .condition("entityFilterSetId", entityFilterSetId).disableAuthz().useCache(true).one() String setApplyCond = (String) entityFilterSet.getNoCheckSimple("applyCond") boolean hasAuthzCond = authzApplyCond != null && !authzApplyCond.isEmpty() boolean hasSetCond = setApplyCond != null && !setApplyCond.isEmpty() if (hasAuthzCond || hasSetCond) { // for evaluating apply conditions add user context to ec.context // this might be more efficient outside the loop, or perhaps even expect it to be in place outside this method // (fine for filterFindForUser(), cumbersome for other uses of this method) eci.contextStack.push(eci.userFacade.context) try { if (hasAuthzCond && !eci.resourceFacade.condition(authzApplyCond, null)) continue if (hasSetCond && !eci.resourceFacade.condition(setApplyCond, null)) continue } finally { eci.contextStack.pop() } } // NOTE: at this level the results could be cached, but worth it? EntityFilter entity list cached already, // some processing for view-entity but mostly only if entityAliasUsedSet, and could only cache if !entityAliasUsedSet EntityList entityFilterList = eci.entityFacade.find("moqui.security.EntityFilter") .condition("entityFilterSetId", entityFilterSetId).disableAuthz().useCache(true).list() if (entityFilterList == null) continue int entFilterSize = entityFilterList.size() if (entFilterSize == 0) continue for (int j = 0; j < entFilterSize; j++) { EntityValue entityFilter = entityFilterList.get(j) String filterEntityName = (String) entityFilter.getNoCheckSimple("entityName") if (filterEntityName == null) continue // see if there if any filter entities match the current entity or if it is a view then a member entity Map> memberFieldAliases = (Map>) null if (!filterEntityName.equals(findEd.getFullEntityName())) { if (findEd.isViewEntity) { memberFieldAliases = findEd.getMemberFieldAliases(filterEntityName) if (memberFieldAliases == null) continue } else { continue } } if (memberFieldAliases != null && entityAliasUsedSet != null) { // trim memberFieldAliases by entity aliases actually used Map> newFieldAliases = (Map>) null for (Map.Entry> aliasesEntry in memberFieldAliases.entrySet()) { ArrayList aliasList = aliasesEntry.getValue() if (aliasList == null) continue // should never happen, buy yeah ArrayList newAliasList = (ArrayList) null int aliasListSize = aliasList.size() for (int ali = 0; ali < aliasListSize; ali++) { MNode aliasNode = (MNode) aliasList.get(ali) String entityAlias = aliasNode.attribute("entity-alias") if (entityAliasUsedSet.contains(entityAlias)) { // is used, copy over if (newAliasList == null) { newAliasList = new ArrayList<>() if (newFieldAliases == null) newFieldAliases = new LinkedHashMap<>() newFieldAliases.put(aliasesEntry.getKey(), newAliasList) } newAliasList.add(aliasNode) } } } // if nothing added then nothing to filter on for this entity if (newFieldAliases == (Map>) null) continue memberFieldAliases = newFieldAliases } // if we got to this point we found a matching filter if (authzFilterInfoList == (ArrayList) null) authzFilterInfoList = new ArrayList<>() authzFilterInfoList.add(new AuthzFilterInfo(entityFilterSet, entityFilter, memberFieldAliases)) } } return authzFilterInfoList } ArrayList filterFindForUser(EntityDefinition findEd, Set entityAliasUsedSet) { ArrayList authzFilterInfoList = getFindFiltersForUser(findEd, entityAliasUsedSet) if (authzFilterInfoList == null) return null int authzFilterInfoListSize = authzFilterInfoList.size() if (authzFilterInfoListSize == 0) return null // for evaluating filter Maps add user context to ec.context eci.contextStack.push(eci.userFacade.context) ArrayList condList = (ArrayList) null try { for (int i = 0; i < authzFilterInfoListSize; i++) { AuthzFilterInfo authzFilterInfo = (AuthzFilterInfo) authzFilterInfoList.get(i) EntityValue entityFilter = authzFilterInfo.entityFilter Map> memberFieldAliases = authzFilterInfo.memberFieldAliases // NOTE: this expression eval must be done for the current context, with eci.userFacade.context added Object filterMapObjEval = eci.resourceFacade.expression((String) entityFilter.getNoCheckSimple('filterMap'), null) Map filterMapObj if (filterMapObjEval instanceof Map) { filterMapObj = filterMapObjEval as Map } else { logger.error("EntityFiler filterMap did not evaluate to a Map: ${entityFilter.getString('filterMap')}") continue } // logger.warn("===== ${findEd.getFullEntityName()} filterMapObj: ${filterMapObj}") EntityConditionFactoryImpl conditionFactory = eci.entityFacade.conditionFactoryImpl String efComparisonEnumId = (String) entityFilter.getNoCheckSimple('comparisonEnumId') ComparisonOperator compOp = efComparisonEnumId != null && efComparisonEnumId.length() > 0 ? conditionFactory.comparisonOperatorFromEnumId(efComparisonEnumId) : null JoinOperator joinOp = "Y".equals(entityFilter.getNoCheckSimple('joinOr')) ? EntityCondition.OR : EntityCondition.AND // use makeCondition(Map) instead of breaking down here try { EntityConditionImplBase entCond = conditionFactory.makeCondition(filterMapObj, compOp, joinOp, findEd, memberFieldAliases, true) if (entCond == (EntityConditionImplBase) null) continue // add the condition to the list to return if (condList == (ArrayList) null) condList = new ArrayList<>() condList.add(entCond) // logger.info("Query on ${findEntityName} added authz filter conditions: ${entCond}") // logger.info("Query on ${findEntityName} find: ${efb.toString()}") } catch (Exception e) { String entityFilterId = (String) entityFilter.getNoCheckSimple("entityFilterId") logger.error("Error adding authz entity filter ${entityFilterId} condition: ${e.toString()}") if (!"Y".equals(authzFilterInfo.entityFilterSet.getNoCheckSimple("allowMissingAlias"))) throw new ArtifactAuthorizationException("Could not apply authorized data filter so not doing query, required field alias missing", e) } } } finally { eci.contextStack.pop() } // if (condList) logger.warn("Filters for ${findEd.getFullEntityName()}: ${condList}") return condList } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ArtifactExecutionInfoImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.impl.entity.EntityValueBase; import org.moqui.util.CollectionUtilities; import org.moqui.util.StringUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.StringWriter; import java.io.Writer; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.*; public class ArtifactExecutionInfoImpl implements ArtifactExecutionInfo { protected final static Logger logger = LoggerFactory.getLogger(ArtifactExecutionInfoImpl.class); // NOTE: these need to be in a Map instead of the DB because Enumeration records may not yet be loaded private final static Map artifactTypeDescriptionMap = new EnumMap<>(ArtifactType.class); private final static Map artifactActionDescriptionMap = new EnumMap<>(AuthzAction.class); static { artifactTypeDescriptionMap.put(AT_XML_SCREEN, "Screen"); artifactTypeDescriptionMap.put(AT_XML_SCREEN_TRANS, "Transition"); artifactTypeDescriptionMap.put(AT_XML_SCREEN_CONTENT, "Screen Content"); artifactTypeDescriptionMap.put(AT_SERVICE, "Service"); artifactTypeDescriptionMap.put(AT_ENTITY, "Entity"); artifactTypeDescriptionMap.put(AT_REST_PATH, "REST Path"); artifactTypeDescriptionMap.put(AT_OTHER, "Other"); artifactActionDescriptionMap.put(AUTHZA_VIEW, "View"); artifactActionDescriptionMap.put(AUTHZA_CREATE, "Create"); artifactActionDescriptionMap.put(AUTHZA_UPDATE, "Update"); artifactActionDescriptionMap.put(AUTHZA_DELETE, "Delete"); artifactActionDescriptionMap.put(AUTHZA_ALL, "All"); } public final String nameInternal; public final ArtifactType internalTypeEnum; public final AuthzAction internalActionEnum; public final String actionDetail; protected Map parameters = null; public String internalAuthorizedUserId = null; public AuthzType internalAuthorizedAuthzType = null; public AuthzAction internalAuthorizedActionEnum = null; public boolean internalAuthorizationInheritable = false; public boolean internalAuthzWasRequired = false; public boolean isAccess = false; public boolean trackArtifactHit = true; private boolean internalAuthzWasGranted = false; public ArtifactAuthzCheck internalAacv = null; public Long moquiTxId = null; //protected Exception createdLocation = null private ArtifactExecutionInfoImpl parentAeii = (ArtifactExecutionInfoImpl) null; public final long startTimeMillis; public final long startTimeNanos; private long endTimeNanos = 0; public Long outputSize = null; private ArrayList childList = (ArrayList) null; private long childrenRunningTime = 0; public ArtifactExecutionInfoImpl(String name, ArtifactType typeEnum, AuthzAction actionEnum, String detail) { nameInternal = name; internalTypeEnum = typeEnum; internalActionEnum = actionEnum != null ? actionEnum : AUTHZA_ALL; actionDetail = detail; //createdLocation = new Exception("Create AEII location for ${name}, type ${typeEnumId}, action ${actionEnumId}") startTimeMillis = System.currentTimeMillis(); startTimeNanos = System.nanoTime(); } public ArtifactExecutionInfoImpl setParameters(Map parameters) { this.parameters = parameters; return this; } @Override public String getName() { return nameInternal; } @Override public ArtifactType getTypeEnum() { return internalTypeEnum; } @Override public String getTypeDescription() { String desc = artifactTypeDescriptionMap.get(internalTypeEnum); return desc != null ? desc : internalTypeEnum.name(); } @Override public AuthzAction getActionEnum() { return internalActionEnum; } @Override public String getActionDescription() { String desc = artifactActionDescriptionMap.get(internalActionEnum); return desc != null ? desc : internalActionEnum.name(); } @Override public String getAuthorizedUserId() { return internalAuthorizedUserId; } void setAuthorizedUserId(String authorizedUserId) { this.internalAuthorizedUserId = authorizedUserId; } @Override public AuthzType getAuthorizedAuthzType() { return internalAuthorizedAuthzType; } void setAuthorizedAuthzType(AuthzType authorizedAuthzType) { this.internalAuthorizedAuthzType = authorizedAuthzType; } @Override public AuthzAction getAuthorizedActionEnum() { return internalAuthorizedActionEnum; } void setAuthorizedActionEnum(AuthzAction authorizedActionEnum) { this.internalAuthorizedActionEnum = authorizedActionEnum; } @Override public boolean isAuthorizationInheritable() { return internalAuthorizationInheritable; } void setAuthorizationInheritable(boolean isAuthorizationInheritable) { this.internalAuthorizationInheritable = isAuthorizationInheritable; } @Override public boolean getAuthorizationWasRequired() { return internalAuthzWasRequired; } public ArtifactExecutionInfoImpl setAuthzReqdAndIsAccess(boolean authzReqd, boolean isAccess) { internalAuthzWasRequired = authzReqd; this.isAccess = isAccess; return this; } public ArtifactExecutionInfoImpl setTrackArtifactHit(boolean tah) { trackArtifactHit = tah; return this; } @Override public boolean getAuthorizationWasGranted() { return internalAuthzWasGranted; } void setAuthorizationWasGranted(boolean value) { internalAuthzWasGranted = value ? Boolean.TRUE : Boolean.FALSE; } public Long getMoquiTxId() { return moquiTxId; } void setMoquiTxId(Long txId) { moquiTxId = txId; } ArtifactAuthzCheck getAacv() { return internalAacv; } public void copyAacvInfo(ArtifactAuthzCheck aacv, String userId, boolean wasGranted) { internalAacv = aacv; internalAuthorizedUserId = userId; internalAuthorizedAuthzType = aacv.authzType; internalAuthorizedActionEnum = aacv.authzAction; internalAuthorizationInheritable = aacv.inheritAuthz; internalAuthzWasGranted = wasGranted; } public void copyAuthorizedInfo(ArtifactExecutionInfoImpl aeii) { internalAacv = aeii.internalAacv; internalAuthorizedUserId = aeii.internalAuthorizedUserId; internalAuthorizedAuthzType = aeii.internalAuthorizedAuthzType; internalAuthorizedActionEnum = aeii.internalAuthorizedActionEnum; internalAuthorizationInheritable = aeii.internalAuthorizationInheritable; // NOTE: don't copy internalAuthzWasRequired, always set in isPermitted() internalAuthzWasGranted = aeii.internalAuthzWasGranted; } void setEndTime() { this.endTimeNanos = System.nanoTime(); } @Override public long getRunningTime() { return endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0; } public double getRunningTimeMillisDouble() { return (endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0) / 1000000.0; } public long getRunningTimeMillisLong() { return Math.round((endTimeNanos != 0 ? endTimeNanos - startTimeNanos : 0) / 1000000.0); } private void calcChildTime(boolean recurse) { childrenRunningTime = 0; if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) { childrenRunningTime += aeii.getRunningTime(); if (recurse) aeii.calcChildTime(true); } } @Override public long getThisRunningTime() { return getRunningTime() - getChildrenRunningTime(); } @Override public long getChildrenRunningTime() { if (childrenRunningTime == 0) calcChildTime(false); return childrenRunningTime; } public BigDecimal getRunningTimeMillis() { return new BigDecimal(getRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); } public BigDecimal getThisRunningTimeMillis() { return new BigDecimal(getThisRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); } public BigDecimal getChildrenRunningTimeMillis() { return new BigDecimal(getChildrenRunningTime()).movePointLeft(6).setScale(2, RoundingMode.HALF_UP); } void setParent(ArtifactExecutionInfoImpl parentAeii) { this.parentAeii = parentAeii; } @Override public ArtifactExecutionInfo getParent() { return parentAeii; } @Override public BigDecimal getPercentOfParentTime() { return parentAeii != null && endTimeNanos != 0 ? new BigDecimal((getRunningTime() / parentAeii.getRunningTime()) * 100).setScale(2, RoundingMode.HALF_UP) : BigDecimal.ZERO; } void addChild(ArtifactExecutionInfoImpl aeii) { if (childList == null) childList = new ArrayList<>(); childList.add(aeii); } @Override public List getChildList() { List newChildList = new ArrayList<>(); newChildList.addAll(childList); return newChildList; } public void print(Writer writer, int level, boolean children) { try { for (int i = 0; i < (level * 2); i++) writer.append(" "); writer.append("[").append(parentAeii != null ? StringUtilities.paddedString(getPercentOfParentTime().toPlainString(), 5, false) : " ").append("%]"); writer.append("[").append(StringUtilities.paddedString(getRunningTimeMillis().toPlainString(), 5, false)).append("]"); writer.append("[").append(StringUtilities.paddedString(getThisRunningTimeMillis().toPlainString(), 3, false)).append("]"); writer.append("[").append(childList != null ? StringUtilities.paddedString(getChildrenRunningTimeMillis().toPlainString(), 3, false) : " ").append("] "); writer.append(StringUtilities.paddedString(getTypeDescription(), 10, true)).append(" "); writer.append(StringUtilities.paddedString(getActionDescription(), 7, true)).append(" "); writer.append(StringUtilities.paddedString(actionDetail, 5, true)).append(" "); writer.append(nameInternal).append("\n"); if (children && childList != null) for (ArtifactExecutionInfoImpl aeii: childList) aeii.print(writer, level + 1, true); } catch (IOException e) { e.printStackTrace(); } } private String getKeyString() { return nameInternal + ":" + internalTypeEnum.name() + ":" + internalActionEnum.name() + ":" + actionDetail; } private String getKeyStringNoName() { return internalTypeEnum.name() + ":" + internalActionEnum.name() + ":" + actionDetail; } public static class ArtifactTypeStats { public int screenCount = 0, screenTransCount = 0, screenContentCount = 0, restPathCount = 0, serviceViewCount = 0, serviceOtherCount = 0, entityFindOneCount = 0, entityFindListCount = 0, entityFindIteratorCount = 0, entityFindCountCount = 0, entityCreateCount = 0, entityUpdateCount = 0, entityDeleteCount = 0; public long screenTime = 0, screenTransTime = 0, screenContentTime = 0, restPathTime = 0, serviceViewTime = 0, serviceOtherTime = 0, entityFindOneTime = 0, entityFindListTime = 0, entityFindIteratorTime = 0, entityFindCountTime = 0, entityCreateTime = 0, entityUpdateTime = 0, entityDeleteTime = 0; public void add(ArtifactTypeStats that) { if (that == null) return; screenCount += that.screenCount; screenTransCount += that.screenTransCount; screenContentCount += that.screenContentCount; restPathCount += that.restPathCount; serviceViewCount += that.serviceViewCount; serviceOtherCount += that.serviceOtherCount; entityFindOneCount += that.entityFindOneCount; entityFindListCount += that.entityFindListCount; entityFindIteratorCount += that.entityFindIteratorCount; entityFindCountCount += that.entityFindCountCount; entityCreateCount += that.entityCreateCount; entityUpdateCount += that.entityUpdateCount; entityDeleteCount += that.entityDeleteCount; screenTime += that.screenTime; screenTransTime += that.screenTransTime; screenContentTime += that.screenContentTime; restPathTime += that.restPathTime; serviceViewTime += that.serviceViewTime; serviceOtherTime += that.serviceOtherTime; entityFindOneTime += that.entityFindOneTime; entityFindListTime += that.entityFindListTime; entityFindIteratorTime += that.entityFindIteratorTime; entityFindCountTime += that.entityFindCountTime; entityCreateTime += that.entityCreateTime; entityUpdateTime += that.entityUpdateTime; entityDeleteTime += that.entityDeleteTime; } public ArtifactTypeStats cloneStats(ArtifactTypeStats that) { ArtifactTypeStats newStats = new ArtifactTypeStats(); newStats.add(that); return newStats; } } static ArtifactTypeStats getArtifactTypeStats(ArrayList aeiiList) { ArtifactTypeStats stats = new ArtifactTypeStats(); addArtifactTypeStats(aeiiList, stats); return stats; } static void addArtifactTypeStats(ArrayList aeiiList, ArtifactTypeStats stats) { if (aeiiList == null) return; int aeiiListSize = aeiiList.size(); for (int i = 0; i < aeiiListSize; i++) { ArtifactExecutionInfoImpl aeii = aeiiList.get(i); // tight loop, use switch instead of if on these enums for much better performance; run fast for use in on the fly accumulators switch (aeii.internalTypeEnum) { case AT_ENTITY: switch (aeii.internalActionEnum) { case AUTHZA_VIEW: if (aeii.actionDetail != null && !aeii.actionDetail.isEmpty()) { char first = aeii.actionDetail.charAt(0); switch (first) { case 'o': // one case 'r': // refresh stats.entityFindOneCount++; stats.entityFindOneTime += aeii.getRunningTime(); break; case 'l': // list stats.entityFindListCount++; stats.entityFindListTime += aeii.getRunningTime(); break; case 'i': // iterator stats.entityFindIteratorCount++; stats.entityFindIteratorTime += aeii.getRunningTime(); break; case 'c': // count stats.entityFindCountCount++; stats.entityFindCountTime += aeii.getRunningTime(); break; } } else { logger.warn("entity view with no detail " + aeii.toBasicString()); } break; case AUTHZA_CREATE: stats.entityCreateCount++; stats.entityCreateTime += aeii.getRunningTime(); break; case AUTHZA_UPDATE: stats.entityUpdateCount++; stats.entityUpdateTime += aeii.getRunningTime(); break; case AUTHZA_DELETE: stats.entityDeleteCount++; stats.entityDeleteTime += aeii.getRunningTime(); break; } break; case AT_SERVICE: if (aeii.internalActionEnum == AUTHZA_VIEW) { stats.serviceViewCount++; stats.serviceViewTime += aeii.getRunningTime(); } else { stats.serviceOtherCount++; stats.serviceOtherTime += aeii.getRunningTime(); } break; case AT_XML_SCREEN: stats.screenCount++; stats.screenTime += aeii.getRunningTime(); break; case AT_XML_SCREEN_TRANS: stats.screenTransCount++; stats.screenTransTime += aeii.getRunningTime(); break; case AT_XML_SCREEN_CONTENT: stats.screenContentCount++; stats.screenContentTime += aeii.getRunningTime(); break; case AT_REST_PATH: stats.restPathCount++; stats.restPathTime += aeii.getRunningTime(); break; } // this aeii is done, how about children? addArtifactTypeStats(aeii.childList, stats); } } @SuppressWarnings("unchecked") static List> hotSpotByTime(List aeiiList, boolean ownTime, String orderBy) { Map> timeByArtifact = new LinkedHashMap<>(); for (ArtifactExecutionInfoImpl aeii: aeiiList) aeii.addToMapByTime(timeByArtifact, ownTime); List> hotSpotList = new LinkedList<>(); hotSpotList.addAll(timeByArtifact.values()); // in some cases we get REALLY long times before the system is warmed, knock those out for (Map val: hotSpotList) { int knockOutCount = 0; List newTimes = new LinkedList<>(); BigDecimal timeAvg = (BigDecimal) val.get("timeAvg"); for (BigDecimal time: (List) val.get("times")) { // this ain"t no standard deviation, but consider 3 times average to be abnormal if (time.floatValue() > (timeAvg.floatValue() * 3)) { knockOutCount++; } else { newTimes.add(time); } } if (knockOutCount > 0 && newTimes.size() > 0) { // calc new average, add knockOutCount times to fill in gaps, calc new time total BigDecimal newTotal = BigDecimal.ZERO; BigDecimal newMax = BigDecimal.ZERO; for (BigDecimal time: newTimes) { newTotal = newTotal.add(time); if (time.compareTo(newMax) > 0) newMax = time; } BigDecimal newAvg = newTotal.divide(new BigDecimal(newTimes.size()), 2, RoundingMode.HALF_UP); // long newTimeAvg = newAvg.setScale(0, RoundingMode.HALF_UP) newTotal = newTotal.add(newAvg.multiply(new BigDecimal(knockOutCount))); val.put("time", newTotal); val.put("timeMax", newMax); val.put("timeAvg", newAvg); } } List obList = new LinkedList<>(); if (orderBy != null && orderBy.length() > 0) obList.add(orderBy); else obList.add("-time"); CollectionUtilities.orderMapList(hotSpotList, obList); return hotSpotList; } @SuppressWarnings("unchecked") private void addToMapByTime(Map> timeByArtifact, boolean ownTime) { String key = getKeyString(); Map val = timeByArtifact.get(key); BigDecimal curTime = ownTime ? getThisRunningTimeMillis() : getRunningTimeMillis(); if (val == null) { Map newMap = new LinkedHashMap<>(); List timesList = new LinkedList<>(); timesList.add(curTime); newMap.put("times", timesList); newMap.put("time", curTime); newMap.put("timeMin", curTime); newMap.put("timeMax", curTime); newMap.put("timeAvg", curTime); newMap.put("count", BigDecimal.ONE); newMap.put("name", nameInternal); newMap.put("actionDetail", actionDetail); newMap.put("type", getTypeDescription()); newMap.put("action", getActionDescription()); timeByArtifact.put(key, newMap); } else { val = timeByArtifact.get(key); BigDecimal newCount = ((BigDecimal) val.get("count")).add(BigDecimal.ONE); val.put("count", newCount); if (newCount.intValue() == 2 && ((List) val.get("times")).get(0).compareTo(curTime.multiply(new BigDecimal(3))) > 0) { // if the first is much higher than the 2nd, use the 2nd for both List timesList = new LinkedList<>(); timesList.add(curTime); timesList.add(curTime); val.put("times", timesList); val.put("time", curTime.add(curTime)); val.put("timeMin", curTime); val.put("timeMax", curTime); val.put("timeAvg", curTime); } else { ((List) val.get("times")).add(curTime); val.put("time", ((BigDecimal) val.get("time")).add(curTime)); val.put("timeMin", ((BigDecimal) val.get("timeMin")).compareTo(curTime) > 0 ? curTime : (BigDecimal) val.get("timeMin")); val.put("timeMax", ((BigDecimal) val.get("timeMax")).compareTo(curTime) > 0 ? (BigDecimal) val.get("timeMax") : curTime); val.put("timeAvg", ((BigDecimal) val.get("time")).divide((BigDecimal) val.get("count"), 2, RoundingMode.HALF_UP)); } } if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) aeii.addToMapByTime(timeByArtifact, ownTime); } static void printHotSpotList(Writer writer, List infoList) throws IOException { // "[${time}:${timeMin}:${timeAvg}:${timeMax}][${count}] ${type} ${action} ${actionDetail} ${name}" for (Map info: infoList) { writer.append("[").append(StringUtilities.paddedString(((BigDecimal) info.get("time")).toPlainString(), 8, false)).append(":"); writer.append(StringUtilities.paddedString(((BigDecimal) info.get("timeMin")).toPlainString(), 7, false)).append(":"); writer.append(StringUtilities.paddedString(((BigDecimal) info.get("timeAvg")).toPlainString(), 7, false)).append(":"); writer.append(StringUtilities.paddedString(((BigDecimal) info.get("timeMax")).toPlainString(), 7, false)).append("]"); writer.append("[").append(StringUtilities.paddedString(((BigDecimal) info.get("count")).toPlainString(), 4, false)).append("] "); writer.append(StringUtilities.paddedString((String) info.get("type"), 10, true)).append(" "); writer.append(StringUtilities.paddedString((String) info.get("action"), 7, true)).append(" "); writer.append(StringUtilities.paddedString((String) info.get("actionDetail"), 5, true)).append(" "); writer.append((String) info.get("name")).append("\n"); } } static List consolidateArtifactInfo(List aeiiList) { List topLevelList = new LinkedList<>(); Map> flatMap = new LinkedHashMap<>(); for (ArtifactExecutionInfoImpl aeii: aeiiList) aeii.consolidateArtifactInfo(topLevelList, flatMap, null); return topLevelList; } @SuppressWarnings("unchecked") private void consolidateArtifactInfo(List topLevelList, Map> flatMap, Map parentArtifactMap) { String key = getKeyString(); Map artifactMap = flatMap.get(key); if (artifactMap == null) { artifactMap = new LinkedHashMap<>(); artifactMap.put("time", getRunningTimeMillis()); artifactMap.put("thisTime", getThisRunningTimeMillis()); artifactMap.put("childrenTime", getChildrenRunningTimeMillis()); artifactMap.put("count", BigDecimal.ONE); artifactMap.put("name", nameInternal); artifactMap.put("actionDetail", actionDetail); artifactMap.put("childInfoList", new LinkedList()); artifactMap.put("key", key); artifactMap.put("type", getTypeDescription()); artifactMap.put("action", getActionDescription()); flatMap.put(key, artifactMap); if (parentArtifactMap != null) { ((List) parentArtifactMap.get("childInfoList")).add(artifactMap); } else { topLevelList.add(artifactMap); } } else { artifactMap.put("count", ((BigDecimal) artifactMap.get("count")).add(BigDecimal.ONE)); artifactMap.put("time", ((BigDecimal) artifactMap.get("time")).add(getRunningTimeMillis())); artifactMap.put("thisTime", ((BigDecimal) artifactMap.get("thisTime")).add(getThisRunningTimeMillis())); artifactMap.put("childrenTime", ((BigDecimal) artifactMap.get("childrenTime")).add(getChildrenRunningTimeMillis())); if (parentArtifactMap != null) { // is the current artifact in the current parent"s child list? if not add it (a given artifact may be under multiple parents, normal) boolean foundMap = false; for (Map candidate: (List) parentArtifactMap.get("childInfoList")) if (key.equals(candidate.get("key"))) { foundMap = true; break; } if (!foundMap) ((List) parentArtifactMap.get("childInfoList")).add(artifactMap); } } if (childList != null) for (ArtifactExecutionInfoImpl aeii: childList) aeii.consolidateArtifactInfo(topLevelList, flatMap, artifactMap); } public static String printArtifactInfoList(List infoList) throws IOException { StringWriter sw = new StringWriter(); printArtifactInfoList(sw, infoList, 0); return sw.toString(); } @SuppressWarnings("unchecked") public static void printArtifactInfoList(Writer writer, List infoList, int level) throws IOException { // "[${time}:${thisTime}:${childrenTime}][${count}] ${type} ${action} ${actionDetail} ${name}" for (Map info: infoList) { for (int i = 0; i < level; i++) writer.append("|").append(" "); writer.append("[").append(StringUtilities.paddedString(((BigDecimal) info.get("time")).toPlainString(), 8, false)).append(":"); writer.append(StringUtilities.paddedString(((BigDecimal) info.get("thisTime")).toPlainString(), 6, false)).append(":"); writer.append(StringUtilities.paddedString(((BigDecimal) info.get("childrenTime")).toPlainString(), 6, false)).append("]"); writer.append("[").append(StringUtilities.paddedString(((BigDecimal) info.get("count")).toPlainString(), 4, false)).append("] "); writer.append(StringUtilities.paddedString((String) info.get("type"), 10, true)).append(" "); writer.append(StringUtilities.paddedString((String) info.get("action"), 7, true)).append(" "); writer.append(StringUtilities.paddedString((String) info.get("actionDetail"), 5, true)).append(" "); writer.append((String) info.get("name")).append("\n"); // if we get past level 25 just give up, probably a loop in the tree if (level < 25) { printArtifactInfoList(writer, (List) info.get("childInfoList"), level + 1); } else { for (int i = 0; i < level; i++) writer.append("|").append(" "); writer.append("Reached depth limit, not printing children (may be a cycle in the 'tree')\n"); } } } @Override public String toString() { return "[name:'" + nameInternal + "', type:'" + internalTypeEnum + "', action:'" + internalActionEnum + "', required: " + internalAuthzWasRequired + ", granted:" + internalAuthzWasGranted + ", user:'" + internalAuthorizedUserId + "', authz:'" + internalAuthorizedAuthzType + "', authAction:'" + internalAuthorizedActionEnum + "', inheritable:" + internalAuthorizationInheritable + ", runningTime:" + getRunningTime() + "', txId:" + moquiTxId + "]"; } @Override public String toBasicString() { StringBuilder builder = new StringBuilder().append(internalTypeEnum.toString()).append(':').append(nameInternal) .append(" (").append(internalActionEnum.toString()); if (actionDetail != null && !actionDetail.isEmpty()) builder.append(':').append(actionDetail); builder.append(") ").append(System.currentTimeMillis() - startTimeMillis).append("ms"); if (moquiTxId != null) builder.append(" TX ").append(moquiTxId); return builder.toString(); } public static class ArtifactAuthzCheck { public String userGroupId, artifactAuthzId, authzServiceName; public String artifactGroupId, artifactName, filterMap; public ArtifactType artifactType; public AuthzAction authzAction; public AuthzType authzType; public boolean nameIsPattern, inheritAuthz; public ArtifactAuthzCheck(EntityValueBase aacvEvb) { Map aacvMap = aacvEvb.getValueMap(); userGroupId = (String) aacvMap.get("userGroupId"); artifactAuthzId = (String) aacvMap.get("artifactAuthzId"); authzServiceName = (String) aacvMap.get("authzServiceName"); artifactGroupId = (String) aacvMap.get("artifactGroupId"); artifactName = (String) aacvMap.get("artifactName"); filterMap = (String) aacvMap.get("filterMap"); String artifactTypeEnumId = (String) aacvMap.get("artifactTypeEnumId"); artifactType = artifactTypeEnumId != null ? ArtifactType.valueOf(artifactTypeEnumId) : null; String authzActionEnumId = (String) aacvMap.get("authzActionEnumId"); authzAction = authzActionEnumId != null ? AuthzAction.valueOf(authzActionEnumId) : null; String authzTypeEnumId = (String) aacvMap.get("authzTypeEnumId"); authzType = authzTypeEnumId != null ? AuthzType.valueOf(authzTypeEnumId) : null; nameIsPattern = "Y".equals(aacvMap.get("nameIsPattern")); inheritAuthz = "Y".equals(aacvMap.get("inheritAuthz")); } @Override public String toString() { return "[userGroupId:" + userGroupId + ", artifactAuthzId:" + artifactAuthzId + ", artifactGroupId:" + artifactGroupId + ", artifactName:" + artifactName + ", artifactType:" + artifactType + ", authzAction:" + authzAction + ", authzType:" + authzType + ", nameIsPattern:" + nameIsPattern + ", inheritAuthz:" + inheritAuthz + "]"; } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/CacheFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.moqui.jcache.MCache import org.moqui.jcache.MCacheConfiguration import org.moqui.jcache.MCacheManager import org.moqui.impl.tools.MCacheToolFactory import org.moqui.jcache.MEntry import org.moqui.jcache.MStats import org.moqui.util.CollectionUtilities import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import javax.cache.Cache import javax.cache.CacheManager import javax.cache.configuration.Configuration import javax.cache.configuration.Factory import javax.cache.configuration.MutableConfiguration import javax.cache.expiry.AccessedExpiryPolicy import javax.cache.expiry.CreatedExpiryPolicy import javax.cache.expiry.Duration import javax.cache.expiry.EternalExpiryPolicy import javax.cache.expiry.ExpiryPolicy import java.sql.Timestamp import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap import org.moqui.context.CacheFacade import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.TimeUnit @CompileStatic public class CacheFacadeImpl implements CacheFacade { protected final static Logger logger = LoggerFactory.getLogger(CacheFacadeImpl.class) protected final ExecutionContextFactoryImpl ecfi protected CacheManager localCacheManagerInternal = (CacheManager) null protected CacheManager distCacheManagerInternal = (CacheManager) null final ConcurrentMap localCacheMap = new ConcurrentHashMap<>() CacheFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi MNode cacheListNode = ecfi.getConfXmlRoot().first("cache-list") String localCacheFactoryName = cacheListNode.attribute("local-factory") ?: MCacheToolFactory.TOOL_NAME localCacheManagerInternal = ecfi.getTool(localCacheFactoryName, CacheManager.class) } CacheManager getDistCacheManager() { if (distCacheManagerInternal == null) { MNode cacheListNode = ecfi.getConfXmlRoot().first("cache-list") String distCacheFactoryName = cacheListNode.attribute("distributed-factory") ?: MCacheToolFactory.TOOL_NAME distCacheManagerInternal = ecfi.getTool(distCacheFactoryName, CacheManager.class) } return distCacheManagerInternal } void destroy() { if (localCacheManagerInternal != null) { for (String cacheName in localCacheManagerInternal.getCacheNames()) localCacheManagerInternal.destroyCache(cacheName) } localCacheMap.clear() if (distCacheManagerInternal != null) { for (String cacheName in distCacheManagerInternal.getCacheNames()) distCacheManagerInternal.destroyCache(cacheName) } } @Override void clearAllCaches() { for (Cache cache in localCacheMap.values()) cache.clear() } @Override void clearCachesByPrefix(String prefix) { for (Map.Entry entry in localCacheMap.entrySet()) { String tempName = entry.key int separatorIndex = tempName.indexOf("__") if (separatorIndex > 0) tempName = tempName.substring(separatorIndex + 2) if (!tempName.startsWith(prefix)) continue entry.value.clear() } } @Override Cache getCache(String cacheName) { return getCacheInternal(cacheName, "local") } @Override Cache getCache(String cacheName, Class keyType, Class valueType) { return getCacheInternal(cacheName, "local") } @Override MCache getLocalCache(String cacheName) { return getCacheInternal(cacheName, "local").unwrap(MCache.class) } @Override Cache getDistributedCache(String cacheName) { return getCacheInternal(cacheName, "distributed") } Cache getCacheInternal(String cacheName, String defaultCacheType) { Cache theCache = localCacheMap.get(cacheName) if (theCache == null) { localCacheMap.putIfAbsent(cacheName, initCache(cacheName, defaultCacheType)) theCache = localCacheMap.get(cacheName) } return theCache } @Override void registerCache(Cache cache) { String cacheName = cache.getName() localCacheMap.putIfAbsent(cacheName, cache) } @Override boolean cacheExists(String cacheName) { return localCacheMap.containsKey(cacheName) } @Override Set getCacheNames() { return localCacheMap.keySet() } List> getAllCachesInfo(String orderByField, String filterRegexp) { boolean hasFilterRegexp = filterRegexp != null && filterRegexp.length() > 0 List> ci = new LinkedList() for (String cn in localCacheMap.keySet()) { if (hasFilterRegexp && !cn.matches("(?i).*" + filterRegexp + ".*")) continue Cache co = getCache(cn) /* TODO: somehow support external cache stats like Hazelcast, through some sort of Moqui interface or maybe the JMX bean? NOTE: this isn't all that important because we don't have a good use case for distributed caches if (co instanceof ICache) { ICache ico = co.unwrap(ICache.class) CacheStatistics cs = ico.getLocalCacheStatistics() CacheConfig conf = co.getConfiguration(CacheConfig.class) EvictionConfig evConf = conf.getEvictionConfig() ExpiryPolicy expPol = conf.getExpiryPolicyFactory()?.create() Long expireIdle = expPol.expiryForAccess?.durationAmount ?: 0 Long expireLive = expPol.expiryForCreation?.durationAmount ?: 0 ci.add([name:co.getName(), expireTimeIdle:expireIdle, expireTimeLive:expireLive, maxElements:evConf.getSize(), evictionStrategy:evConf.getEvictionPolicy().name(), size:ico.size(), getCount:cs.getCacheGets(), putCount:cs.getCachePuts(), hitCount:cs.getCacheHits(), missCountTotal:cs.getCacheMisses(), evictionCount:cs.getCacheEvictions(), removeCount:cs.getCacheRemovals(), expireCount:0] as Map) } else */ if (co instanceof MCache) { MCache mc = co.unwrap(MCache.class) MStats stats = mc.getMStats() Long expireIdle = mc.getAccessDuration()?.durationAmount ?: 0 Long expireLive = mc.getCreationDuration()?.durationAmount ?: 0 ci.add([name:co.getName(), expireTimeIdle:expireIdle, expireTimeLive:expireLive, maxElements:mc.getMaxEntries(), evictionStrategy:"LRU", size:mc.size(), getCount:stats.getCacheGets(), putCount:stats.getCachePuts(), hitCount:stats.getCacheHits(), missCountTotal:stats.getCacheMisses(), evictionCount:stats.getCacheEvictions(), removeCount:stats.getCacheRemovals(), expireCount:stats.getCacheExpires()] as Map) } else { logger.warn("Cannot get detailed info for cache ${cn} which is of type ${co.class.name}") } } if (orderByField) CollectionUtilities.orderMapList(ci, [orderByField]) return ci } protected MNode getCacheNode(String cacheName) { MNode cacheListNode = ecfi.getConfXmlRoot().first("cache-list") MNode cacheElement = cacheListNode.first({ MNode it -> it.name == "cache" && it.attribute("name") == cacheName }) // nothing found? try starts with, ie allow the cache configuration to be a prefix if (cacheElement == null) cacheElement = cacheListNode .first({ MNode it -> it.name == "cache" && cacheName.startsWith(it.attribute("name")) }) return cacheElement } protected synchronized Cache initCache(String cacheName, String defaultCacheType) { if (localCacheMap.containsKey(cacheName)) return localCacheMap.get(cacheName) if (!defaultCacheType) defaultCacheType = "local" Cache newCache MNode cacheNode = getCacheNode(cacheName) if (cacheNode != null) { String keyTypeName = cacheNode.attribute("key-type") ?: "String" String valueTypeName = cacheNode.attribute("value-type") ?: "Object" Class keyType = ObjectUtilities.getClass(keyTypeName) Class valueType = ObjectUtilities.getClass(valueTypeName) Factory expiryPolicyFactory if (cacheNode.attribute("expire-time-idle") && cacheNode.attribute("expire-time-idle") != "0") { expiryPolicyFactory = AccessedExpiryPolicy.factoryOf( new Duration(TimeUnit.SECONDS, Long.parseLong(cacheNode.attribute("expire-time-idle")))) } else if (cacheNode.attribute("expire-time-live") && cacheNode.attribute("expire-time-live") != "0") { expiryPolicyFactory = CreatedExpiryPolicy.factoryOf( new Duration(TimeUnit.SECONDS, Long.parseLong(cacheNode.attribute("expire-time-live")))) } else { expiryPolicyFactory = EternalExpiryPolicy.factoryOf() } String cacheType = cacheNode.attribute("type") ?: defaultCacheType CacheManager cacheManager if ("local".equals(cacheType)) { cacheManager = localCacheManagerInternal } else if ("distributed".equals(cacheType)) { cacheManager = getDistCacheManager() } else { throw new IllegalArgumentException("Cache type ${cacheType} not supported") } Configuration config if (cacheManager instanceof MCacheManager) { // use MCache MCacheConfiguration mConf = new MCacheConfiguration() mConf.setTypes(keyType, valueType) mConf.setStoreByValue(false).setStatisticsEnabled(true) mConf.setExpiryPolicyFactory(expiryPolicyFactory) String maxElementsStr = cacheNode.attribute("max-elements") if (maxElementsStr && maxElementsStr != "0") { int maxElements = Integer.parseInt(maxElementsStr) mConf.setMaxEntries(maxElements) } config = (Configuration) mConf /* TODO: somehow support external cache configuration like Hazelcast, through some sort of Moqui interface, maybe pass cacheNode to Cache factory? NOTE: this isn't all that important because we don't have a good use case for distributed caches, and they can be configured directly through hazelcast.xml or other Hazelcast conf } else if (cacheManager instanceof AbstractHazelcastCacheManager) { // use Hazelcast CacheConfig cacheConfig = new CacheConfig() cacheConfig.setTypes(keyType, valueType) cacheConfig.setStoreByValue(true).setStatisticsEnabled(true) cacheConfig.setExpiryPolicyFactory(expiryPolicyFactory) // from here down the settings are specific to Hazelcast (not supported in javax.cache) cacheConfig.setName(fullCacheName) cacheConfig.setInMemoryFormat(InMemoryFormat.OBJECT) String maxElementsStr = cacheNode.attribute("max-elements") if (maxElementsStr && maxElementsStr != "0") { int maxElements = Integer.parseInt(maxElementsStr) EvictionPolicy ep = cacheNode.attribute("eviction-strategy") == "least-recently-used" ? EvictionPolicy.LRU : EvictionPolicy.LFU EvictionConfig evictionConfig = new EvictionConfig(maxElements, EvictionConfig.MaxSizePolicy.ENTRY_COUNT, ep) cacheConfig.setEvictionConfig(evictionConfig) } config = (Configuration) cacheConfig */ } else { logger.info("Initializing cache ${cacheName} which has a CacheManager of type ${cacheManager.class.name} and extended configuration not supported, using simple MutableConfigutation") MutableConfiguration mutConfig = new MutableConfiguration() mutConfig.setTypes(keyType, valueType) mutConfig.setStoreByValue("distributed".equals(cacheType)).setStatisticsEnabled(true) mutConfig.setExpiryPolicyFactory(expiryPolicyFactory) config = (Configuration) mutConfig } newCache = cacheManager.createCache(cacheName, config) } else { CacheManager cacheManager boolean storeByValue if ("local".equals(defaultCacheType)) { cacheManager = localCacheManagerInternal storeByValue = false } else if ("distributed".equals(defaultCacheType)) { cacheManager = getDistCacheManager() storeByValue = true } else { throw new IllegalArgumentException("Default cache type ${defaultCacheType} not supported") } logger.info("Creating default ${defaultCacheType} cache ${cacheName}, storeByValue=${storeByValue}") MutableConfiguration mutConfig = new MutableConfiguration() mutConfig.setStoreByValue(storeByValue).setStatisticsEnabled(true) // any defaults we want here? better to use underlying defaults and conf file settings only newCache = cacheManager.createCache(cacheName, mutConfig) } // NOTE: put in localCacheMap done in caller (getCache) return newCache } List makeElementInfoList(String cacheName, String orderByField) { Cache cache = getCache(cacheName) if (cache instanceof MCache) { MCache mCache = cache.unwrap(MCache.class) List elementInfoList = new ArrayList<>(); for (Cache.Entry ce in mCache.getEntryList()) { MEntry entry = ce.unwrap(MEntry.class) Map im = new HashMap([key:entry.key as String, value:entry.value as String, hitCount:entry.getAccessCount(), creationTime:new Timestamp(entry.getCreatedTime())]) if (entry.getLastUpdatedTime()) im.lastUpdateTime = new Timestamp(entry.getLastUpdatedTime()) if (entry.getLastAccessTime()) im.lastAccessTime = new Timestamp(entry.getLastAccessTime()) elementInfoList.add(im) } if (orderByField) CollectionUtilities.orderMapList(elementInfoList, [orderByField]) return elementInfoList } else { return new ArrayList() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ContextJavaUtil.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context; import com.fasterxml.jackson.annotation.JsonInclude; import com.fasterxml.jackson.core.JsonGenerator; import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; import com.fasterxml.jackson.databind.SerializationFeature; import com.fasterxml.jackson.databind.SerializerProvider; import com.fasterxml.jackson.databind.module.SimpleModule; import com.fasterxml.jackson.databind.ser.std.StdSerializer; import groovy.lang.GString; import org.codehaus.groovy.runtime.StringGroovyMethods; import org.jetbrains.annotations.NotNull; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.entity.EntityFind; import org.moqui.entity.EntityList; import org.moqui.entity.EntityValue; import org.moqui.impl.entity.EntityValueBase; import org.moqui.impl.screen.ScreenRenderImpl; import org.moqui.resource.ResourceReference; import org.moqui.util.ContextStack; import org.moqui.util.LiteStringMap; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import jakarta.transaction.Synchronization; import jakarta.transaction.Transaction; import javax.transaction.xa.XAResource; import java.io.IOException; import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.*; import java.time.ZoneOffset; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import java.util.concurrent.atomic.AtomicLong; public class ContextJavaUtil { protected final static Logger logger = LoggerFactory.getLogger(ContextJavaUtil.class); private static final long checkSlowThreshold = 50; protected static final double userImpactMinMillis = 1000; /** the Groovy JsonBuilder doesn't handle various Moqui objects very well, ends up trying to access all * properties and results in infinite recursion, so need to unwrap and exclude some */ public static Map unwrapMap(Map sourceMap) { Map targetMap = new HashMap<>(); for (Map.Entry entry: sourceMap.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); if (value == null) continue; // logger.warn("======== actionsResult - ${entry.key} (${entry.value?.getClass()?.getName()}): ${entry.value}") Object unwrapped = unwrap(key, value); if (unwrapped != null) targetMap.put(key, unwrapped); } return targetMap; } @SuppressWarnings("unchecked") public static Object unwrap(String key, Object value) { if (value == null) return null; if (value instanceof CharSequence || value instanceof Number || value instanceof java.util.Date) { return value; } else if (value instanceof EntityFind || value instanceof ExecutionContextImpl || value instanceof ScreenRenderImpl || value instanceof ContextStack) { // intentionally skip, commonly left in context by entity-find XML action return null; } else if (value instanceof EntityValue) { EntityValue ev = (EntityValue) value; return ev.getPlainValueMap(0); } else if (value instanceof EntityList) { EntityList el = (EntityList) value; ArrayList newList = new ArrayList<>(); int elSize = el.size(); for (int i = 0; i < elSize; i++) { EntityValue ev = el.get(i); newList.add(ev.getPlainValueMap(0)); } return newList; } else if (value instanceof Collection) { Collection valCol = (Collection) value; ArrayList newList = new ArrayList<>(valCol.size()); for (Object entry: valCol) newList.add(unwrap(key, entry)); return newList; } else if (value instanceof Map) { Map valMap = (Map) value; Map newMap = new HashMap<>(valMap.size()); for (Map.Entry entry: valMap.entrySet()) newMap.put(entry.getKey(), unwrap(key, entry.getValue())); return newMap; } else { logger.info("In screen actions skipping value from actions block that is not supported; key=" + key + ", type=" + value.getClass().getName() + ", value=" + value); return null; } } public static class ArtifactStatsInfo { private ArtifactExecutionInfo.ArtifactType artifactTypeEnum; private String artifactSubType; private String artifactName; public ArtifactBinInfo curHitBin = null; private long hitCount = 0L; // slowHitCount = 0L; private double totalTimeMillis = 0, totalSquaredTime = 0; ArtifactStatsInfo(ArtifactExecutionInfo.ArtifactType artifactTypeEnum, String artifactSubType, String artifactName) { this.artifactTypeEnum = artifactTypeEnum; this.artifactSubType = artifactSubType; this.artifactName = artifactName; } double getAverage() { return hitCount > 0 ? totalTimeMillis / hitCount : 0; } double getStdDev() { if (hitCount < 2) return 0; return Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeMillis*totalTimeMillis) / hitCount)) / (hitCount - 1L)); } public boolean countHit(long startTime, double runningTime) { hitCount++; boolean isSlow = isHitSlow(runningTime); // if (isSlow) slowHitCount++; // do something funny with these so we get a better avg and std dev, leave out the first result (count 2nd // twice) if first hit is more than 2x the second because the first hit is almost always MUCH slower if (hitCount == 2L && totalTimeMillis > (runningTime * 3)) { totalTimeMillis = runningTime * 2; totalSquaredTime = runningTime * runningTime * 2; } else { totalTimeMillis += runningTime; totalSquaredTime += runningTime * runningTime; } if (curHitBin == null) curHitBin = new ArtifactBinInfo(this, startTime); curHitBin.countHit(runningTime, isSlow); return isSlow; } boolean isHitSlow(double runningTime) { if (hitCount < checkSlowThreshold) return false; // calc new average and standard deviation double average = hitCount > 0 ? totalTimeMillis / hitCount : 0; double stdDev = Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeMillis*totalTimeMillis) / hitCount)) / (hitCount - 1L)); // if runningTime is more than 2.6 std devs from the avg, count it and possibly log it // using 2.6 standard deviations because 2 would give us around 5% of hits (normal distro), shooting for more like 1% double slowTime = average + (stdDev * 2.6); if (slowTime != 0 && runningTime > slowTime) { if (runningTime > userImpactMinMillis) logger.warn("Slow hit to " + artifactTypeEnum + ":" + artifactSubType + ":" + artifactName + " running time " + runningTime + " is greater than average " + average + " plus 2.6 standard deviations " + stdDev); return true; } else { return false; } } } public static class ArtifactBinInfo { private final ArtifactStatsInfo statsInfo; public final long startTime; private long hitCount = 0L, slowHitCount = 0L; private double totalTimeMillis = 0, totalSquaredTime = 0, minTimeMillis = Long.MAX_VALUE, maxTimeMillis = 0; ArtifactBinInfo(ArtifactStatsInfo statsInfo, long startTime) { this.statsInfo = statsInfo; this.startTime = startTime; } void countHit(double runningTime, boolean isSlow) { hitCount++; if (isSlow) slowHitCount++; if (hitCount == 2L && totalTimeMillis > (runningTime * 3)) { totalTimeMillis = runningTime * 2; totalSquaredTime = runningTime * runningTime * 2; } else { totalTimeMillis += runningTime; totalSquaredTime += runningTime * runningTime; } if (runningTime < minTimeMillis) minTimeMillis = runningTime; if (runningTime > maxTimeMillis) maxTimeMillis = runningTime; } EntityValue makeAhbValue(ExecutionContextFactoryImpl ecfi, Timestamp binEndDateTime) { EntityValueBase ahb = (EntityValueBase) ecfi.entityFacade.makeValue("moqui.server.ArtifactHitBin"); ahb.put("artifactType", statsInfo.artifactTypeEnum.name()); ahb.put("artifactSubType", statsInfo.artifactSubType); ahb.put("artifactName", statsInfo.artifactName); ahb.put("binStartDateTime", new Timestamp(startTime)); ahb.put("binEndDateTime", binEndDateTime); ahb.put("hitCount", hitCount); // NOTE: use 6 digit precision for nanos in millisecond unit ahb.put("totalTimeMillis", new BigDecimal(totalTimeMillis).setScale(6, RoundingMode.HALF_UP)); ahb.put("totalSquaredTime", new BigDecimal(totalSquaredTime).setScale(6, RoundingMode.HALF_UP)); ahb.put("minTimeMillis", new BigDecimal(minTimeMillis).setScale(6, RoundingMode.HALF_UP)); ahb.put("maxTimeMillis", new BigDecimal(maxTimeMillis).setScale(6, RoundingMode.HALF_UP)); ahb.put("slowHitCount", slowHitCount); ahb.put("serverIpAddress", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostAddress() : "127.0.0.1"); ahb.put("serverHostName", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostName() : "localhost"); return ahb; } } public static class ArtifactHitInfo { String visitId, userId; boolean isSlowHit; ArtifactExecutionInfo.ArtifactType artifactTypeEnum; String artifactSubType, artifactName; long startTime; double runningTimeMillis; Map parameters; Long outputSize; String errorMessage = null; String requestUrl = null, referrerUrl = null; ArtifactHitInfo(ExecutionContextImpl eci, boolean isSlowHit, ArtifactExecutionInfo.ArtifactType artifactTypeEnum, String artifactSubType, String artifactName, long startTime, double runningTimeMillis, Map parameters, Long outputSize) { visitId = eci.userFacade.getVisitId(); userId = eci.userFacade.getUserId(); this.isSlowHit = isSlowHit; this.artifactTypeEnum = artifactTypeEnum; this.artifactSubType = artifactSubType; this.artifactName = artifactName; this.startTime = startTime; this.runningTimeMillis = runningTimeMillis; this.parameters = parameters; this.outputSize = outputSize; if (eci.getMessage().hasError()) { StringBuilder errorMessage = new StringBuilder(); for (String curErr: eci.getMessage().getErrors()) errorMessage.append(curErr).append(";"); if (errorMessage.length() > 255) errorMessage.delete(255, errorMessage.length()); this.errorMessage = errorMessage.toString(); } WebFacadeImpl wfi = eci.getWebImpl(); if (wfi != null) { String fullUrl = wfi.getRequestUrl(); requestUrl = (fullUrl.length() > 255) ? fullUrl.substring(0, 255) : fullUrl; referrerUrl = wfi.getRequest().getHeader("Referer"); } } EntityValue makeAhiValue(ExecutionContextFactoryImpl ecfi) { EntityValueBase ahp = (EntityValueBase) ecfi.entityFacade.makeValue("moqui.server.ArtifactHit"); ahp.put("visitId", visitId); ahp.put("userId", userId); ahp.put("isSlowHit", isSlowHit ? 'Y' : 'N'); ahp.put("artifactType", artifactTypeEnum.name()); ahp.put("artifactSubType", artifactSubType); ahp.put("artifactName", artifactName); ahp.put("startDateTime", new Timestamp(startTime)); ahp.put("runningTimeMillis", new BigDecimal(runningTimeMillis).setScale(6, RoundingMode.HALF_UP)); if (parameters != null && parameters.size() > 0) { StringBuilder ps = new StringBuilder(); for (Map.Entry pme: parameters.entrySet()) { Object value = pme.getValue(); if (value == null || ObjectUtilities.isEmpty(value) || value instanceof Map || value instanceof Collection) continue; String key = pme.getKey(); if (key != null && key.contains("password")) continue; if (ps.length() > 0) ps.append(", "); String valString = value.toString(); if (valString.length() > 80) valString = valString.substring(0, 80); ps.append(key).append("=").append(valString); } // is text-long, could be up to 4000, probably don't want that much for data size if (ps.length() > 1000) ps.delete(1000, ps.length()); ahp.put("parameterString", ps.toString()); } if (outputSize != null) ahp.put("outputSize", outputSize); if (errorMessage != null) { ahp.put("wasError", "Y"); ahp.put("errorMessage", errorMessage); } else { ahp.put("wasError", "N"); } if (requestUrl != null && requestUrl.length() > 0) ahp.put("requestUrl", requestUrl); if (referrerUrl != null && referrerUrl.length() > 0) ahp.put("referrerUrl", referrerUrl); ahp.put("serverIpAddress", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostAddress() : "127.0.0.1"); ahp.put("serverHostName", ecfi.localhostAddress != null ? ecfi.localhostAddress.getHostName() : "localhost"); return ahp; } } static class RollbackInfo { public String causeMessage; /** A rollback is often done because of another error, this represents that error. */ public Throwable causeThrowable; /** This is for a stack trace for where the rollback was actually called to help track it down more easily. */ public Exception rollbackLocation; public RollbackInfo(String causeMessage, Throwable causeThrowable, Exception rollbackLocation) { this.causeMessage = causeMessage; this.causeThrowable = causeThrowable; this.rollbackLocation = rollbackLocation; } } static final AtomicLong moquiTxIdLast = new AtomicLong(0L); static class TxStackInfo { private TransactionFacadeImpl transactionFacade; public final long moquiTxId = moquiTxIdLast.incrementAndGet(); public Exception transactionBegin = null; public Long transactionBeginStartTime = null; public int transactionTimeout = 60; public RollbackInfo rollbackOnlyInfo = null; public Transaction suspendedTx = null; public Exception suspendedTxLocation = null; Map activeXaResourceMap = new HashMap<>(); Map activeSynchronizationMap = new HashMap<>(); Map txConByGroup = new HashMap<>(); public TransactionCache txCache = null; ArrayList recordLockList = new ArrayList<>(); public Map getActiveXaResourceMap() { return activeXaResourceMap; } public Map getActiveSynchronizationMap() { return activeSynchronizationMap; } public Map getTxConByGroup() { return txConByGroup; } public TxStackInfo(TransactionFacadeImpl tfi) { transactionFacade = tfi; } public void clearCurrent() { rollbackOnlyInfo = null; transactionBegin = null; transactionBeginStartTime = null; transactionTimeout = 60; activeXaResourceMap.clear(); activeSynchronizationMap.clear(); txCache = null; // this should already be done, but make sure closeTxConnections(); // lock track: remove all EntityRecordLock in recordLockList from TransactionFacadeImpl.recordLockByEntityPk int recordLockListSize = recordLockList.size(); // if (recordLockListSize > 0) logger.warn("TOREMOVE TxStackInfo EntityRecordLock clearing " + recordLockListSize + " locks"); for (int i = 0; i < recordLockListSize; i++) { EntityRecordLock erl = recordLockList.get(i); erl.clear(transactionFacade.recordLockByEntityPk); } recordLockList.clear(); } public void closeTxConnections() { for (ConnectionWrapper con: txConByGroup.values()) { try { if (con != null && !con.isClosed()) con.closeInternal(); } catch (Throwable t) { logger.error("Error closing connection for group " + con.getGroupName(), t); } } txConByGroup.clear(); } } public static class EntityRecordLock { // TODO enum for operation? create, update, delete, find-for-update String entityName, pkString, entityPlusPk, threadName; String mutateEntityName, mutatePkString; ArrayList artifactStack; long lockTime = -1, txBeginTime = -1, moquiTxId = -1; public EntityRecordLock(String entityName, String pkString, ArrayList artifactStack) { this.entityName = entityName; this.pkString = pkString; // NOTE: used primary as a key, for efficiency don't use separator between entityName and pkString entityPlusPk = entityName.concat(pkString); threadName = Thread.currentThread().getName(); this.artifactStack = artifactStack; lockTime = System.currentTimeMillis(); } EntityRecordLock mutator(String mutateEntityName, String mutatePkString) { this.mutateEntityName = mutateEntityName; this.mutatePkString = mutatePkString; return this; } void register(ConcurrentHashMap> recordLockByEntityPk, TxStackInfo txStackInfo) { if (txStackInfo != null) { moquiTxId = txStackInfo.moquiTxId; txBeginTime = txStackInfo.transactionBeginStartTime != null ? txStackInfo.transactionBeginStartTime : -1; } ArrayList curErlList = recordLockByEntityPk.computeIfAbsent(entityPlusPk, k -> new ArrayList<>()); synchronized (curErlList) { // is this another lock in the same transaction? if (curErlList.size() > 0) { for (int i = 0; i < curErlList.size(); i++) { EntityRecordLock otherErl = curErlList.get(i); if (otherErl.moquiTxId == moquiTxId) { // found a match, just return and do nothing return; } } } // check for existing locks in this.recordLockByEntityPk, log warning if others found if (curErlList.size() > 0) { StringBuilder msgBuilder = new StringBuilder().append("Potential lock conflict entity ").append(entityName) .append(" pk ").append(pkString).append(" thread ").append(threadName) .append(" TX ").append(moquiTxId).append(" began ").append(new Timestamp(txBeginTime)); if (mutateEntityName != null) msgBuilder.append(" from mutate of entity ").append(mutateEntityName).append(" pk ").append(mutatePkString); msgBuilder.append(" at: "); if (artifactStack != null) for (int mi = 0; mi < artifactStack.size(); mi++) { msgBuilder.append("\n").append(StringGroovyMethods.padLeft((CharSequence) Integer.toString(mi), 2, "0")) .append(": ").append(artifactStack.get(mi).toBasicString()); } for (int i = 0; i < curErlList.size(); i++) { EntityRecordLock otherErl = curErlList.get(i); msgBuilder.append("\n== OTHER LOCK ").append(i).append(" thread ").append(otherErl.threadName) .append(" TX ").append(otherErl.moquiTxId).append(" began ").append(new Timestamp(otherErl.txBeginTime)).append(" at: "); if (otherErl.artifactStack != null) for (int mi = 0; mi < otherErl.artifactStack.size(); mi++) { msgBuilder.append("\n").append(StringGroovyMethods.padLeft((CharSequence) Integer.toString(mi), 2, "0")) .append(": ").append(otherErl.artifactStack.get(mi).toBasicString()); } } logger.warn(msgBuilder.toString()); } // add new lock to this.recordLockByEntityPk, and TxStackInfo.recordLockList if (txStackInfo != null) { curErlList.add(this); txStackInfo.recordLockList.add(this); } else { logger.warn("In EntityRecordLock register no TxStackInfo so not registering lock because won't be able to clear for entity " + entityName + " pk " + pkString + " thread " + threadName); } } } void clear(ConcurrentHashMap> recordLockByEntityPk) { ArrayList curErlList = recordLockByEntityPk.get(entityPlusPk); if (curErlList == null) { logger.warn("In EntityRecordLock clear no locks found for " + entityPlusPk); return; } synchronized (curErlList) { boolean haveRemoved = false; for (int i = 0; i < curErlList.size(); i++) { EntityRecordLock otherErl = curErlList.get(i); if (moquiTxId == otherErl.moquiTxId) { curErlList.remove(i); haveRemoved = true; } } if (!haveRemoved) logger.warn("In EntityRecordLock clear no locks found for " + entityPlusPk); } } } /** A simple delegating wrapper for java.sql.Connection. * * The close() method does nothing, only closed when closeInternal() called by TransactionFacade on commit, * rollback, or destroy (when transactions are also cleaned up as a last resort). * * Connections are attached to 2 things: entity group and transaction. */ public static class ConnectionWrapper implements Connection { protected Connection con; TransactionFacadeImpl tfi; String groupName; public ConnectionWrapper(Connection con, TransactionFacadeImpl tfi, String groupName) { this.con = con; this.tfi = tfi; this.groupName = groupName; } public String getGroupName() { return groupName; } public void closeInternal() throws SQLException { con.close(); } @Override public Statement createStatement() throws SQLException { return con.createStatement(); } @Override public PreparedStatement prepareStatement(String sql) throws SQLException { return con.prepareStatement(sql); } @Override public CallableStatement prepareCall(String sql) throws SQLException { return con.prepareCall(sql); } @Override public String nativeSQL(String sql) throws SQLException { return con.nativeSQL(sql); } @Override public void setAutoCommit(boolean autoCommit) throws SQLException { con.setAutoCommit(autoCommit); } @Override public boolean getAutoCommit() throws SQLException { return con.getAutoCommit(); } @Override public void commit() throws SQLException { con.commit(); } @Override public void rollback() throws SQLException { con.rollback(); } @Override public void close() throws SQLException { // do nothing! see closeInternal } @Override public boolean isClosed() throws SQLException { return con.isClosed(); } @Override public DatabaseMetaData getMetaData() throws SQLException { return con.getMetaData(); } @Override public void setReadOnly(boolean readOnly) throws SQLException { con.setReadOnly(readOnly); } @Override public boolean isReadOnly() throws SQLException { return con.isReadOnly(); } @Override public void setCatalog(String catalog) throws SQLException { con.setCatalog(catalog); } @Override public String getCatalog() throws SQLException { return con.getCatalog(); } @Override public void setTransactionIsolation(int level) throws SQLException { con.setTransactionIsolation(level); } @Override public int getTransactionIsolation() throws SQLException { return con.getTransactionIsolation(); } @Override public SQLWarning getWarnings() throws SQLException { return con.getWarnings(); } @Override public void clearWarnings() throws SQLException { con.clearWarnings(); } @Override public Statement createStatement(int resultSetType, int resultSetConcurrency) throws SQLException { return con.createStatement(resultSetType, resultSetConcurrency); } @Override public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { return con.prepareStatement(sql, resultSetType, resultSetConcurrency); } @Override public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency) throws SQLException { return con.prepareCall(sql, resultSetType, resultSetConcurrency); } @Override public Map> getTypeMap() throws SQLException { return con.getTypeMap(); } @Override public void setTypeMap(Map> map) throws SQLException { con.setTypeMap(map); } @Override public void setHoldability(int holdability) throws SQLException { con.setHoldability(holdability); } @Override public int getHoldability() throws SQLException { return con.getHoldability(); } @Override public Savepoint setSavepoint() throws SQLException { return con.setSavepoint(); } @Override public Savepoint setSavepoint(String name) throws SQLException { return con.setSavepoint(name); } @Override public void rollback(Savepoint savepoint) throws SQLException { con.rollback(savepoint); } @Override public void releaseSavepoint(Savepoint savepoint) throws SQLException { con.releaseSavepoint(savepoint); } @Override public Statement createStatement(int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { return con.createStatement(resultSetType, resultSetConcurrency, resultSetHoldability); } @Override public PreparedStatement prepareStatement(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { return con.prepareStatement(sql, resultSetType, resultSetConcurrency, resultSetHoldability); } @Override public CallableStatement prepareCall(String sql, int resultSetType, int resultSetConcurrency, int resultSetHoldability) throws SQLException { return con.prepareCall(sql, resultSetType, resultSetConcurrency, resultSetHoldability); } @Override public PreparedStatement prepareStatement(String sql, int autoGeneratedKeys) throws SQLException { return con.prepareStatement(sql, autoGeneratedKeys); } @Override public PreparedStatement prepareStatement(String sql, int[] columnIndexes) throws SQLException { return con.prepareStatement(sql, columnIndexes); } @Override public PreparedStatement prepareStatement(String sql, String[] columnNames) throws SQLException { return con.prepareStatement(sql, columnNames); } @Override public Clob createClob() throws SQLException { return con.createClob(); } @Override public Blob createBlob() throws SQLException { return con.createBlob(); } @Override public NClob createNClob() throws SQLException { return con.createNClob(); } @Override public SQLXML createSQLXML() throws SQLException { return con.createSQLXML(); } @Override public boolean isValid(int timeout) throws SQLException { return con.isValid(timeout); } @Override public void setClientInfo(String name, String value) throws SQLClientInfoException { con.setClientInfo(name, value); } @Override public void setClientInfo(Properties properties) throws SQLClientInfoException { con.setClientInfo(properties); } @Override public String getClientInfo(String name) throws SQLException { return con.getClientInfo(name); } @Override public Properties getClientInfo() throws SQLException { return con.getClientInfo(); } @Override public Array createArrayOf(String typeName, Object[] elements) throws SQLException { return con.createArrayOf(typeName, elements); } @Override public Struct createStruct(String typeName, Object[] attributes) throws SQLException { return con.createStruct(typeName, attributes); } @Override public void setSchema(String schema) throws SQLException { con.setSchema(schema); } @Override public String getSchema() throws SQLException { return con.getSchema(); } @Override public void abort(Executor executor) throws SQLException { con.abort(executor); } @Override public void setNetworkTimeout(Executor executor, int milliseconds) throws SQLException { con.setNetworkTimeout(executor, milliseconds); } @Override public int getNetworkTimeout() throws SQLException { return con.getNetworkTimeout(); } @Override public T unwrap(Class iface) throws SQLException { return con.unwrap(iface); } @Override public boolean isWrapperFor(Class iface) throws SQLException { return con.isWrapperFor(iface); } // Object overrides @Override public int hashCode() { return con.hashCode(); } @Override public boolean equals(Object obj) { return obj instanceof Connection && con.equals(obj); } @Override public String toString() { return "Group: " + groupName + ", Con: " + con.toString(); } /* these don't work, don't think we need them anyway: protected Object clone() throws CloneNotSupportedException { return new ConnectionWrapper((Connection) con.clone(), tfi, groupName) } protected void finalize() throws Throwable { con.finalize() } */ } public final static ObjectMapper jacksonMapper = new ObjectMapper() .setDefaultPropertyInclusion(JsonInclude.Value.construct( JsonInclude.Include.ALWAYS, JsonInclude.Include.ALWAYS)) .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS).enable(SerializationFeature.INDENT_OUTPUT) .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true); static { // Jackson custom serializers, etc SimpleModule module = new SimpleModule(); module.addSerializer(GString.class, new ContextJavaUtil.GStringJsonSerializer()); module.addSerializer(LiteStringMap.class, new ContextJavaUtil.LiteStringMapJsonSerializer()); module.addSerializer(ResourceReference.class, new ContextJavaUtil.ResourceReferenceJsonSerializer()); jacksonMapper.registerModule(module); } static class GStringJsonSerializer extends StdSerializer { GStringJsonSerializer() { super(GString.class); } @Override public void serialize(GString value, JsonGenerator gen, SerializerProvider serializers) throws IOException, JsonProcessingException { if (value != null) gen.writeString(value.toString()); } } static class TimestampNoNegativeJsonSerializer extends StdSerializer { TimestampNoNegativeJsonSerializer() { super(Timestamp.class); } @Override public void serialize(Timestamp value, JsonGenerator gen, SerializerProvider serializers) throws IOException, JsonProcessingException { if (value != null) { long time = value.getTime(); if (time < 0) { String isoUtc = value.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT); gen.writeString(isoUtc); // logger.warn("Negative Timestamp " + time + ": " + isoUtc); } else { gen.writeNumber(time); } } } } static class LiteStringMapJsonSerializer extends StdSerializer { LiteStringMapJsonSerializer() { super(LiteStringMap.class); } @Override public void serialize(LiteStringMap lsm, JsonGenerator gen, SerializerProvider serializers) throws IOException, JsonProcessingException { gen.writeStartObject(); if (lsm != null) { int size = lsm.size(); for (int i = 0; i < size; i++) { String key = lsm.getKey(i); Object value = lsm.getValue(i); // sparse maps could have null keys at certain indexes if (key == null) continue; gen.writeObjectField(key, value); } } gen.writeEndObject(); } } static class ResourceReferenceJsonSerializer extends StdSerializer { ResourceReferenceJsonSerializer() { super(ResourceReference.class); } @Override public void serialize(ResourceReference resourceRef, JsonGenerator gen, SerializerProvider serializers) throws IOException, JsonProcessingException { if (resourceRef == null) { gen.writeNull(); return; } gen.writeStartObject(); gen.writeObjectField("location", resourceRef.getLocation()); gen.writeObjectField("isDirectory", resourceRef.isDirectory()); gen.writeObjectField("lastModified", resourceRef.getLastModified()); ResourceReference.Version currentVersion = resourceRef.getCurrentVersion(); if (currentVersion != null) gen.writeObjectField("currentVersionName", currentVersion.getVersionName()); gen.writeEndObject(); } } // NOTE: using unbound LinkedBlockingQueue, so max pool size in ThreadPoolExecutor has no effect public static class WorkerThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("MoquiWorkers"); private final AtomicInteger threadNumber = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(workerGroup, r, "MoquiWorker-" + threadNumber.getAndIncrement()); } } public static class JobThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("MoquiJobs"); private final AtomicInteger threadNumber = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(workerGroup, r, "MoquiJob-" + threadNumber.getAndIncrement()); } } public static class WorkerThreadPoolExecutor extends ThreadPoolExecutor { private ExecutionContextFactoryImpl ecfi; public WorkerThreadPoolExecutor(ExecutionContextFactoryImpl ecfi, int coreSize, int maxSize, long aliveTime, TimeUnit timeUnit, BlockingQueue blockingQueue, ThreadFactory threadFactory) { super(coreSize, maxSize, aliveTime, timeUnit, blockingQueue, threadFactory); this.ecfi = ecfi; } @Override protected void afterExecute(Runnable runnable, Throwable throwable) { ExecutionContextImpl activeEc = ecfi.activeContext.get(); if (activeEc != null) { logger.warn("In WorkerThreadPoolExecutor.afterExecute() there is still an ExecutionContext for runnable " + runnable.getClass().getName() + " in thread (" + Thread.currentThread().threadId() + ":" + Thread.currentThread().getName() + "), destroying"); try { activeEc.destroy(); } catch (Throwable t) { logger.error("Error destroying ExecutionContext in WorkerThreadPoolExecutor.afterExecute()", t); } } else { if (ecfi.transactionFacade.isTransactionInPlace()) { logger.error("In WorkerThreadPoolExecutor a transaction is in place for thread " + Thread.currentThread().getName() + ", trying to commit"); try { ecfi.transactionFacade.destroyAllInThread(); } catch (Exception e) { logger.error("WorkerThreadPoolExecutor commit in place transaction failed in thread " + Thread.currentThread().getName(), e); } } } super.afterExecute(runnable, throwable); } } static class ScheduledThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("MoquiScheduled"); private final AtomicInteger threadNumber = new AtomicInteger(1); public Thread newThread(Runnable r) { return new Thread(workerGroup, r, "MoquiScheduled-" + threadNumber.getAndIncrement()); } } public static class CustomScheduledTask implements RunnableScheduledFuture { public final Runnable runnable; public final Callable callable; public final RunnableScheduledFuture future; public CustomScheduledTask(Runnable runnable, RunnableScheduledFuture future) { this.runnable = runnable; this.callable = null; this.future = future; } public CustomScheduledTask(Callable callable, RunnableScheduledFuture future) { this.runnable = null; this.callable = callable; this.future = future; } @Override public boolean isPeriodic() { return future.isPeriodic(); } @Override public long getDelay(@NotNull TimeUnit timeUnit) { return future.getDelay(timeUnit); } @Override public int compareTo(@NotNull Delayed delayed) { return future.compareTo(delayed); } @Override public void run() { try { // logger.info("Running scheduled task " + toString()); future.run(); } catch (Throwable t) { logger.error("CustomScheduledTask uncaught Throwable in run(), catching and suppressing so task does not get unscheduled", t); } } @Override public boolean cancel(boolean b) { return future.cancel(b); } @Override public boolean isCancelled() { return future.isCancelled(); } @Override public boolean isDone() { return future.isDone(); } @Override public V get() throws InterruptedException, ExecutionException { return future.get(); } @Override public V get(long l, @NotNull TimeUnit timeUnit) throws InterruptedException, ExecutionException, TimeoutException { return future.get(l, timeUnit); } @Override public String toString() { return "CustomScheduledTask " + (runnable != null ? runnable.getClass().getName() : (callable != null ? callable.getClass().getName() : "[no Runnable or Callable!]")); } } public static class CustomScheduledExecutor extends ScheduledThreadPoolExecutor { public CustomScheduledExecutor(int coreThreads) { super(coreThreads, new ScheduledThreadFactory()); } public CustomScheduledExecutor(int coreThreads, ThreadFactory threadFactory) { super(coreThreads, threadFactory); } protected RunnableScheduledFuture decorateTask(Runnable r, RunnableScheduledFuture task) { return new CustomScheduledTask(r, task); } protected RunnableScheduledFuture decorateTask(Callable c, RunnableScheduledFuture task) { return new CustomScheduledTask(c, task); } } static class ScheduledRunnableInfo { public final Runnable command; public final long period; // NOTE: tracking initial ScheduledFuture is useless as it gets replaced with each run: public final ScheduledFuture scheduledFuture; ScheduledRunnableInfo(Runnable command, long period) { this.command = command; this.period = period; } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ElasticFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import com.fasterxml.jackson.annotation.JsonInclude import com.fasterxml.jackson.core.JsonGenerator import com.fasterxml.jackson.databind.DeserializationFeature import com.fasterxml.jackson.databind.JsonNode import com.fasterxml.jackson.databind.ObjectMapper import com.fasterxml.jackson.databind.module.SimpleModule import groovy.json.JsonOutput import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.ElasticFacade import org.moqui.entity.EntityException import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.entity.EntityDataDocument import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityJavaUtil import org.moqui.impl.entity.FieldInfo import org.moqui.impl.util.ElasticSearchLogger import org.moqui.util.LiteStringMap import org.moqui.util.MNode import org.moqui.util.RestClient import org.moqui.util.RestClient.Method import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp import java.util.concurrent.Future @CompileStatic class ElasticFacadeImpl implements ElasticFacade { private final static Logger logger = LoggerFactory.getLogger(ElasticFacadeImpl.class) private final static Set docSkipKeys = new HashSet<>(Arrays.asList("_index", "_type", "_id", "_timestamp")) // Max HTTP Response Size for Search - this may need to be configurable, set very high for now (appears that Jetty only grows the buffer as needed for response content) public static int MAX_RESPONSE_SIZE_SEARCH = 100 * 1024 * 1024 // Request Timeout, another thing that could be configurable but can be specified via API, set to 50 to give plenty of time for TX/etc cleanup public static int DEFAULT_REQUEST_TIMEOUT = 50 public static int SMALL_OP_REQUEST_TIMEOUT = 5 public final static ObjectMapper jacksonMapper = new ObjectMapper() .setSerializationInclusion(JsonInclude.Include.ALWAYS) .enable(DeserializationFeature.USE_BIG_DECIMAL_FOR_FLOATS) .configure(JsonGenerator.Feature.WRITE_BIGDECIMAL_AS_PLAIN, true) static { // Jackson custom serializers, etc SimpleModule module = new SimpleModule() module.addSerializer(GString.class, new ContextJavaUtil.GStringJsonSerializer()) module.addSerializer(LiteStringMap.class, new ContextJavaUtil.LiteStringMapJsonSerializer()) // NOTE: using custom serializer for Timestamps because ElasticSearch 7+ does NOT allow negative longs for epoch_millis format... sigh // .enable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS) module.addSerializer(Timestamp.class, new ContextJavaUtil.TimestampNoNegativeJsonSerializer()) jacksonMapper.registerModule(module) } public final ExecutionContextFactoryImpl ecfi private final Map clientByClusterName = new LinkedHashMap<>() private ElasticSearchLogger esLogger = null ElasticFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi init() } void init() { MNode elasticFacadeNode = ecfi.getConfXmlRoot().first("elastic-facade") ArrayList clusterNodeList = elasticFacadeNode.children("cluster") for (MNode clusterNode in clusterNodeList) { clusterNode.setSystemExpandAttributes(true) String clusterName = clusterNode.attribute("name") String clusterUrl = clusterNode.attribute("url") logger.info("Initializing ElasticFacade Client for cluster ${clusterName} at ${clusterUrl}") if (clientByClusterName.containsKey(clusterName)) { logger.warn("ElasticFacade Client for cluster ${clusterName} already initialized, skipping") continue } if (!clusterUrl) { logger.warn("ElasticFacade Client for cluster ${clusterName} has no url, skipping") continue } try { ElasticClientImpl elci = new ElasticClientImpl(clusterNode, ecfi) clientByClusterName.put(clusterName, elci) } catch (Throwable t) { Throwable cause = t.getCause() if (cause != null && cause.message.contains("refused")) { logger.error("Error initializing ElasticClient for cluster ${clusterName}: ${cause.toString()}") } else { logger.error("Error initializing ElasticClient for cluster ${clusterName}", t) } } } // init ElasticSearchLogger if (esLogger == null || !esLogger.isInitialized()) { ElasticClientImpl loggerEci = clientByClusterName.get("logger") ?: clientByClusterName.get("default") if (loggerEci != null) { logger.info("Initializing ElasticSearchLogger with cluster ${loggerEci.getClusterName()}") esLogger = new ElasticSearchLogger(loggerEci, ecfi) } else { logger.warn("No Elastic Client found with name 'logger' or 'default', not initializing ElasticSearchLogger") } } else { logger.warn("ElasticSearchLogger in place and initialized, not initializing ElasticSearchLogger") } // Index DataFeed with indexOnStartEmpty=Y try { ElasticClientImpl defaultEci = clientByClusterName.get("default") if (defaultEci != null) { EntityList dataFeedList = ecfi.entityFacade.find("moqui.entity.feed.DataFeed") .condition("indexOnStartEmpty", "Y").disableAuthz().list() for (EntityValue dataFeed in dataFeedList) { EntityList dfddList = ecfi.entityFacade.find("moqui.entity.feed.DataFeedDocumentDetail") .condition("dataFeedId", dataFeed.dataFeedId).disableAuthz().list() Set indexNames = new HashSet() for (int i = 0; i < dfddList.size(); i++) { EntityValue dfdd = (EntityValue) dfddList.get(i) indexNames.add(dfdd.getString("indexName")) } boolean foundNotExists = false for (String indexName in indexNames) if (!defaultEci.indexExists(indexName)) foundNotExists = true if (foundNotExists) { // NOTE: called with localOnly(true) to avoid issues during startup if a distributed executor service is configured String jobRunId = ecfi.service.job("IndexDataFeedDocuments").parameter("dataFeedId", dataFeed.dataFeedId).localOnly(true).run() logger.info("Found index does not exist for DataFeed ${dataFeed.dataFeedId}, started job ${jobRunId} to index") } } } } catch (Throwable t) { logger.error("Error checking or indexing for all DataFeed with indexOnStartEmpty=Y", t) } } void destroy() { if (esLogger != null) esLogger.destroy() for (ElasticClientImpl eci in clientByClusterName.values()) eci.destroy() } @Override ElasticClient getDefault() { return clientByClusterName.get("default") } @Override ElasticClient getClient(String clusterName) { return clientByClusterName.get(clusterName) } @Override List getClientList() { return new ArrayList(clientByClusterName.values()) } static class ElasticClientImpl implements ElasticClient { private final ExecutionContextFactoryImpl ecfi private final MNode clusterNode private final String clusterName, clusterUser, clusterPassword, indexPrefix private final String clusterUrl, clusterProtocol, clusterHost private final int clusterPort private RestClient.PooledRequestFactory requestFactory private Map serverInfo = (Map) null private String esVersion = (String) null private boolean esVersionUnder7 = false private boolean isOpenSearch = true ElasticClientImpl(MNode clusterNode, ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi this.clusterNode = clusterNode this.clusterName = clusterNode.attribute("name") this.clusterUser = clusterNode.attribute("user") this.clusterPassword = clusterNode.attribute("password") this.indexPrefix = clusterNode.attribute("index-prefix") String urlTemp = clusterNode.attribute("url") if (urlTemp.endsWith("/")) urlTemp = urlTemp.substring(0, urlTemp.length() - 1) this.clusterUrl = urlTemp URI uri = new URI(urlTemp) clusterProtocol = uri.getScheme() ?: "http" clusterHost = uri.getHost() int portTemp = uri.getPort() clusterPort = portTemp > 0 ? portTemp : 9200 String poolMaxStr = clusterNode.attribute("pool-max") String queueSizeStr = clusterNode.attribute("queue-size") requestFactory = new RestClient.PooledRequestFactory("ES_" + clusterName) if (poolMaxStr) requestFactory.poolSize(Integer.parseInt(poolMaxStr)) if (queueSizeStr) requestFactory.queueSize(Integer.parseInt(queueSizeStr)) requestFactory.init() // try connecting and get server info int retries = ((clusterHost == 'localhost' || clusterHost == '127.0.0.1') && !"true".equals(System.getProperty("moqui.elasticsearch.started"))) ? 1 : 20 for (int i = 1; i <= retries; i++) { try { serverInfo = getServerInfo() } catch (Throwable t) { if (i == retries) { requestFactory.destroy() throw t // logger.error("Final error connecting to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort}, try ${i} of ${retries}: ${t.toString()}", t) } else { logger.warn("Error connecting to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort}, try ${i} of ${retries}: ${t.toString()}") Thread.sleep(1000) } } if (serverInfo != null) { // [name:dejc-m1p.local, cluster_name:opensearch, cluster_uuid:aoMc3T7ES9yCC6yzi-_Ghg, version:[distribution:opensearch, number:1.3.1, build_type:tar, build_hash:c4c0672877bf0f787ca857c7c37b775967f93d81, build_date:2022-03-29T18:34:46.566802Z, build_snapshot:false, lucene_version:8.10.1, minimum_wire_compatibility_version:6.8.0, minimum_index_compatibility_version:6.0.0-beta1], tagline:The OpenSearch Project: https://opensearch.org/] Map versionMap = ((Map) serverInfo.version) String distro = versionMap?.distribution ?: "elasticsearch" isOpenSearch = "opensearch".equals(distro) esVersion = versionMap?.number esVersionUnder7 = !isOpenSearch && esVersion?.charAt(0) < ((char) '7') logger.info("Connected to ElasticSearch cluster ${clusterName} at ${clusterProtocol}://${clusterHost}:${clusterPort} distribution ${distro} version ${esVersion}, ES earlier than 7.0? ${esVersionUnder7}\n${serverInfo}") break } } } @Override String getClusterName() { return clusterName } @Override String getClusterLocation() { return clusterProtocol + "://" + clusterHost + ":" + clusterPort } boolean isEsVersionUnder7() { return esVersionUnder7 } void destroy() { requestFactory.destroy() } @Override Map getServerInfo() { RestClient.RestResponse response = makeRestClient(Method.GET, null, null, null).call() checkResponse(response, "Server info", null) return (Map) jsonToObject(response.text()) } @Override boolean indexExists(final String index) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("Index name required") RestClient.RestResponse response = makeRestClient(Method.HEAD, index, null, null).call() return response.statusCode == 200 } @Override boolean aliasExists(final String origAlias) { String alias = prefixIndexName(origAlias) if (alias == null) throw new IllegalArgumentException("Alias required") RestClient.RestResponse response = makeRestClient(Method.HEAD, null, "_alias/" + alias, null).call() return response.statusCode == 200 } @Override void createIndex(String index, Map docMapping, String origAlias) { createIndex(index, null, docMapping, origAlias, null) } void createIndex(String index, String docType, Map docMapping, String origAlias) { createIndex(index, docType, docMapping, origAlias, null) } void createIndex(String index, String docType, Map docMapping, String origAlias, Map settings) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("Index name required") RestClient restClient = makeRestClient(Method.PUT, index, null, null) if (docMapping || origAlias) { Map requestMap = new HashMap() if (docMapping) { if (esVersionUnder7) requestMap.put("mappings", [(docType?:'_doc'):docMapping]) else requestMap.put("mappings", docMapping) } if (settings != null && settings.size() > 0) { requestMap.put('settings', settings) } if (origAlias != null && !origAlias.isEmpty()) { String alias = prefixIndexName(origAlias) requestMap.put("aliases", [(alias):[:]]) } restClient.text(objectToJson(requestMap)) } // NOTE: this is for ES 7.0+ only, before that mapping needed to be named RestClient.RestResponse response = restClient.call() checkResponse(response, "Create index", index) } @Override void putMapping(String index, Map docMapping) { putMapping(index, null, docMapping) } void putMapping(String index, String docType, Map docMapping) { if (!docMapping) throw new IllegalArgumentException("Mapping may not be empty for put mapping") // NOTE: this is for ES 7.0+ only, before that mapping needed to be named in the path String path = esVersionUnder7 ? "_mapping/" + (docType?:'_doc') : "_mapping" RestClient restClient = makeRestClient(Method.PUT, index, path, null) restClient.text(objectToJson(docMapping)) RestClient.RestResponse response = restClient.call() checkResponse(response, "Put mapping", index) } @Override void deleteIndex(String index) { RestClient restClient = makeRestClient(Method.DELETE, index, null, null) RestClient.RestResponse response = restClient.call() checkResponse(response, "Delete index", index) } @Override void index(String index, String _id, Map document) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("In index document the index name may not be empty") if (_id == null || _id.isEmpty()) throw new IllegalArgumentException("In index document the _id may not be empty") RestClient.RestResponse response = makeRestClient(Method.PUT, index, "_doc/" + _id, null, SMALL_OP_REQUEST_TIMEOUT) .text(objectToJson(document)).call() checkResponse(response, "Index document ${_id}", index) } @Override void update(String index, String _id, Map documentFragment) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("In update document the index name may not be empty") if (_id == null || _id.isEmpty()) throw new IllegalArgumentException("In update document the _id may not be empty") RestClient.RestResponse response = makeRestClient(Method.POST, index, "_update/" + _id, null, SMALL_OP_REQUEST_TIMEOUT) .text(objectToJson([doc:documentFragment])).call() checkResponse(response, "Update document ${_id}", index) } @Override void delete(String index, String _id) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("In delete document the index name may not be empty") if (_id == null || _id.isEmpty()) throw new IllegalArgumentException("In delete document the _id may not be empty") RestClient.RestResponse response = makeRestClient(Method.DELETE, index, "_doc/" + _id, null, SMALL_OP_REQUEST_TIMEOUT).call() if (response.statusCode == 404) { logger.warn("In delete document not found in index ${index} with ID ${_id}") } else { checkResponse(response, "Delete document ${_id}", index) } } @Override Integer deleteByQuery(String index, Map queryMap) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("In delete by query the index name may not be empty") RestClient.RestResponse response = makeRestClient(Method.POST, index, "_delete_by_query", null) .text(objectToJson([query:queryMap])).call() checkResponse(response, "Delete by query", index) Map responseMap = (Map) jsonToObject(response.text()) return responseMap.deleted as Integer } @Override void bulk(String index, List actionSourceList) { if (actionSourceList == null || actionSourceList.size() == 0) return RestClient.RestResponse response = bulkResponse(index, actionSourceList, false) checkResponse(response, "Bulk operations", index) } RestClient.RestResponse bulkResponse(String index, List actionSourceList, boolean refresh) { // NOTE: don't use logger in this method, with ElasticSearchLogger in place results in infinite log feedback if (actionSourceList == null || actionSourceList.size() == 0) return null StringWriter bodyWriter = new StringWriter(actionSourceList.size() * 100) for (Map entry in actionSourceList) { // look for _index fields in each Map, if found prefix if (entry.size() == 1) { Map actionMap = (Map) entry.values().first() Object _indexVal = actionMap.get("_index") if (_indexVal != null && _indexVal instanceof String) actionMap.put("_index", prefixIndexName((String) _indexVal)) } // System.out.println("bulk entry ${entry}") // now done mucking around with the data, write it jacksonMapper.writeValue(bodyWriter, entry) bodyWriter.append((char) '\n') } RestClient restClient = makeRestClient(Method.POST, index, "_bulk", [refresh:(refresh ? "true" : "wait_for")]) .contentType("application/x-ndjson") restClient.timeout(600) restClient.text(bodyWriter.toString()) // System.out.println("Bulk:\n${bodyWriter.toString()}") RestClient.RestResponse response = restClient.call() // System.out.println("Bulk Response: ${response.statusCode} ${response.reasonPhrase}\n${response.text()}") return response } @Override void bulkIndex(String index, String idField, List documentList) { bulkIndex(index, null, idField, documentList, false) } void bulkIndex(String index, String docType, String idField, List documentList, boolean refresh) { List actionSourceList = new ArrayList<>(documentList.size() * 2) boolean hasId = idField != null && !idField.isEmpty() int loopIdx = 0 for (Map document in documentList) { Map indexMap = new LinkedHashMap() indexMap.put("_index", index) if (hasId) { Object idValue = document.get(idField) if (idValue != null) { indexMap.put("_id", idValue) } else { logger.warn("Bulk Index to ${index} found null value for ${idField} in doc ${loopIdx}") } } if (esVersionUnder7) indexMap.put("_type", docType ?: "_doc") Map actionMap = [index:indexMap] actionSourceList.add(actionMap) actionSourceList.add(document) loopIdx++ } RestClient.RestResponse response = bulkResponse(index, actionSourceList, refresh) checkResponse(response, "Bulk operations", index) checkBulkResponseErrors(response, "Bulk index", index) } @Override Map get(final String index, String _id) { if (index == null || index.isEmpty()) throw new IllegalArgumentException("In get document the index name may not be empty") if (_id == null || _id.isEmpty()) throw new IllegalArgumentException("In get document the _id may not be empty") String path = "_doc/" + _id if (esVersionUnder7) { // need actual doc type, this is a hack that will only work with old moqui-elasticsearch DataDocument based index name, otherwise need another parameter so API changes // NOTE: this is for partial backwards compatibility for specific scenarios, remove after moqui-elasticsearch deprecate path = esIndexToDdId(index) + "/" + _id } RestClient.RestResponse response = makeRestClient(Method.GET, index, path, null, SMALL_OP_REQUEST_TIMEOUT).call() if (response.statusCode == 404) { return null } else { checkResponse(response, "Get document ${_id}", index) return (Map) jsonToObject(response.text()) } } @Override Map getSource(String index, String _id) { return (Map) get(index, _id)?._source } @Override List get(String index, List _idList) { if (_idList == null || _idList.size() == 0) return [] if (index == null || index.isEmpty()) throw new IllegalArgumentException("In get documents the index name may not be empty") RestClient.RestResponse response = makeRestClient(Method.GET, index, "_mget", null) .text(objectToJson([ids:_idList])).call() checkResponse(response, "Get document multi", index) Map bodyMap = (Map) jsonToObject(response.text()) return (List) bodyMap.docs } @Override Map search(String index, Map searchMap) { // logger.warn("Search ${index}\n${objectToJson(searchMap)}") RestClient.RestResponse response = makeRestClient(Method.GET, index, "_search", null).maxResponseSize(MAX_RESPONSE_SIZE_SEARCH) .text(objectToJson(searchMap)).call() // System.out.println("Search Response: ${response.statusCode} ${response.reasonPhrase}\n${response.text()}") checkResponse(response, "Search", index) Map resultMap = (Map) jsonToObject(response.text()) // go through each hit (in resultMap.hits.hits) and replace _index value from ES List hitsList = (List) ((Map) resultMap.hits).hits for (Map hit in hitsList) { Object _indexVal = hit.get("_index") if (_indexVal != null && _indexVal instanceof String) hit.put("_index", unprefixIndexName((String) _indexVal)) // logger.warn("search hit ${hit}") } // now done mucking around with the data, return it return resultMap } @Override List searchHits(String origIndex, Map searchMap) { Map resultMap = search(origIndex, searchMap) return (List) ((Map) resultMap.hits).hits } @Override Map validateQuery(String index, Map queryMap, boolean explain) { String queryJson = objectToJson([query:queryMap]) RestClient.RestResponse response = makeRestClient(Method.GET, index, "_validate/query", explain ? [explain:'true'] : null, SMALL_OP_REQUEST_TIMEOUT) .text(queryJson).call() checkResponse(response, "Validate Query", index) String responseText = response.text() Map responseMap = (Map) jsonToObject(responseText) // System.out.println("Validate Query Response: ${response.statusCode} ${response.reasonPhrase} Value? ${responseMap.get("valid") as boolean}\n${response.text()}") // return null if valid if (responseMap.get("valid")) return null logger.warn("Invalid ElasticSearch query\n${JsonOutput.prettyPrint(queryJson)}\nResponse: ${JsonOutput.prettyPrint(responseText)}") return responseMap } @Override long count(String index, Map countMap) { Map resultMap = countResponse(index, countMap) Number count = (Number) resultMap.count return count != null ? count.longValue() : 0 } @Override Map countResponse(String index, Map countMap) { if (countMap == null || countMap.isEmpty()) countMap = [query:[match_all:[:]]] // System.out.println("Count Request index ${index} ${countMap}") RestClient.RestResponse response = makeRestClient(Method.GET, index, "_count", null) .text(objectToJson(countMap)).call() // System.out.println("Count Response: ${response.statusCode} ${response.reasonPhrase}\n${response.text()}") checkResponse(response, "Count", index) Map resultMap = (Map) jsonToObject(response.text()) return resultMap } @Override String getPitId(String index, String keepAlive) { if (keepAlive == null) keepAlive = "60s" RestClient.RestResponse response if (isOpenSearch) { // see: https://opensearch.org/docs/latest/opensearch/point-in-time-api#create-a-pit // requires 3.4.0 or later response = makeRestClient(Method.POST, index, "_search/point_in_time", [keep_alive:keepAlive]).call() } else { // see: https://www.elastic.co/guide/en/elasticsearch/reference/7.10/paginate-search-results.html#scroll-search-results // whatever the docs say: // - it doesn't work with the keep_alive parameter at all "contains unrecognized parameter: [keep_alive]" // - does not work with no body "request body is required" // - and it doesn't work without the doc type _doc before _pit in the path "mapping type name [_pit] can't start with '_' unless it is called [_doc]" // in other words, the docs are completely wrong for ES 7.10.2 // response = makeRestClient(Method.POST, index, "_pit", [keep_alive:keepAlive]).call() response = makeRestClient(Method.POST, index, "_doc/_pit", null).text(objectToJson([keep_alive:keepAlive])).call() } // System.out.println("Get PIT Response: ${response.statusCode} ${response.reasonPhrase}\n${response.text()}") checkResponse(response, "PIT", index) Map resultMap = (Map) jsonToObject(response.text()) return isOpenSearch ? resultMap?.pit_id : resultMap?.id } @Override void deletePit(String pitId) { RestClient.RestResponse response if (isOpenSearch) { // see: https://opensearch.org/docs/latest/opensearch/point-in-time-api#delete-pits // requires 3.4.0 or later response = makeRestClient(Method.DELETE, null, "_search/point_in_time", null) .text(objectToJson([pit_id:[pitId]])).call() } else { // see: https://www.elastic.co/guide/en/elasticsearch/reference/7.10/paginate-search-results.html#scroll-search-results response = makeRestClient(Method.DELETE, null, "_pit", null).text(objectToJson([id:pitId])).call() } // System.out.println("Delete PIT Response: ${response.statusCode} ${response.reasonPhrase}\n${response.text()}") checkResponse(response, "PIT", null) } @Override RestClient.RestResponse call(Method method, String index, String path, Map parameters, Object bodyJsonObject) { RestClient restClient = makeRestClient(method, index, path, parameters).text(objectToJson(bodyJsonObject)) return restClient.call() } @Override Future callFuture(Method method, String index, String path, Map parameters, Object bodyJsonObject) { RestClient restClient = makeRestClient(method, index, path, parameters).text(objectToJson(bodyJsonObject)) return restClient.callFuture() } @Override RestClient makeRestClient(Method method, String index, String path, Map parameters) { return makeRestClient(method, index, path, parameters, null) } RestClient makeRestClient(Method method, String index, String path, Map parameters, Integer timeout) { // NOTE: don't use logger in this method, with ElasticSearchLogger in place results in infinite log feedback String serverIndex = prefixIndexName(index) // System.out.println("=== ES call index ${serverIndex} path ${path} parameters ${parameters}") RestClient restClient = new RestClient().withRequestFactory(requestFactory).method(method) .contentType("application/json").timeout(timeout != null ? timeout : DEFAULT_REQUEST_TIMEOUT) restClient.uri().protocol(clusterProtocol).host(clusterHost).port(clusterPort) .path(serverIndex).path(path).parameters(parameters).build() // see https://www.elastic.co/guide/en/elasticsearch/reference/7.4/http-clients.html if (clusterUser != null && !clusterUser.isEmpty()) restClient.basicAuth(clusterUser, clusterPassword) return restClient } @Override void checkCreateDataDocumentIndexes(String indexName) { // if the index alias exists call it good if (indexExists(indexName)) return EntityList ddList = ecfi.entityFacade.find("moqui.entity.document.DataDocument").condition("indexName", indexName).list() for (EntityValue dd in ddList) storeIndexAndMapping(indexName, dd) } @Override void checkCreateDataDocumentIndex(String dataDocumentId) { String idxName = ddIdToEsIndex(dataDocumentId) if (indexExists(idxName)) return EntityValue dd = ecfi.entityFacade.find("moqui.entity.document.DataDocument").condition("dataDocumentId", dataDocumentId).one() storeIndexAndMapping((String) dd.indexName, dd) } @Override void putDataDocumentMappings(String indexName) { EntityList ddList = ecfi.entityFacade.find("moqui.entity.document.DataDocument").condition("indexName", indexName).list() for (EntityValue dd in ddList) storeIndexAndMapping(indexName, dd) } synchronized protected void storeIndexAndMapping(String indexName, EntityValue dd) { String dataDocumentId = (String) dd.getNoCheckSimple("dataDocumentId") String manualMappingServiceName = (String) dd.getNoCheckSimple("manualMappingServiceName") String esIndexName = ddIdToEsIndex(dataDocumentId) // logger.warn("========== Checking index ${esIndexName} with alias ${indexName} , hasIndex=${hasIndex}") boolean hasIndex = indexExists(esIndexName) Map docMapping = makeElasticSearchMapping(dataDocumentId, ecfi) Map settings = null if (manualMappingServiceName) { def serviceResult = ecfi.service.sync().name(manualMappingServiceName).parameter('mapping', docMapping).call() docMapping = (Map) serviceResult.mapping settings = (Map) serviceResult.settings } if (hasIndex) { logger.info("Updating ElasticSearch index ${esIndexName} for ${dataDocumentId} document mapping") putMapping(esIndexName, dataDocumentId, docMapping) } else { logger.info("Creating ElasticSearch index ${esIndexName} for ${dataDocumentId} with alias ${indexName} and adding document mapping") createIndex(esIndexName, dataDocumentId, docMapping, indexName, settings) // logger.warn("========== Added mapping for ${dataDocumentId} to index ${esIndexName}:\n${docMapping}") } } @Override void verifyDataDocumentIndexes(List documentList) { Set indexNames = new HashSet() Set dataDocumentIds = new HashSet() for (Map document in documentList) { indexNames.add((String) document.get("_index")) dataDocumentIds.add((String) document.get("_type")) } for (String indexName in indexNames) checkCreateDataDocumentIndexes(indexName) for (String dataDocumentId in dataDocumentIds) checkCreateDataDocumentIndex(dataDocumentId) } @Override void bulkIndexDataDocument(List documentList) { int docsPerBulk = 1000 int docListSize = documentList.size() String _index = null String _type = null String _id = null String esIndexName = null ArrayList actionSourceList = new ArrayList(docsPerBulk * 2) int curBulkDocs = 0 int batchCount = 0 for (Map document in documentList) { // logger.warn("====== Indexing document: ${document}") _index = document._index _type = document._type _id = document._id // String _timestamp = document._timestamp // As of ES 2.0 _index, _type, _id, and _timestamp shouldn't be in document to be indexed // clone document before removing fields so they are present for other code using the same data document = new LiteStringMap(document, docSkipKeys) // no longer needed with docSkipKeys: document.remove('_index'); document.remove('_type'); document.remove('_id'); document.remove('_timestamp') // as of ES 6.0, and required for 7 series, one index per doc type so one per dataDocumentId, cleaned up to be valid ES index name (all lower case, etc) esIndexName = ddIdToEsIndex(_type) // before indexing convert types needed for ES // hopefully not needed with Jackson settings, but if so: ElasticSearchUtil.convertTypesForEs(document) // add the document to the bulk index if (esVersionUnder7) { actionSourceList.add([index:[_index:esIndexName, _type:_type, _id:_id]]) } else { actionSourceList.add([index:[_index:esIndexName, _id:_id]]) } actionSourceList.add(document) curBulkDocs++ if (curBulkDocs >= docsPerBulk) { // logger.info("Bulk index batch ${batchCount}, cur docs ${curBulkDocs} of ${docListSize}, last index ${esIndexName} (for index ${_index} type ${_type})") // logger.warn("last document: ${document}") RestClient.RestResponse response = bulkResponse(null, actionSourceList, false) if (response.statusCode < 200 || response.statusCode >= 300) { checkResponse(response, "Bulk index", null) curBulkDocs = 0 actionSourceList = null break } /* don't support getting versions any more, generally waste of resources: BulkItemResponse[] itemResponses = bulkResponse.getItems() int itemResponsesSize = itemResponses.length for (int i = 0; i < itemResponsesSize; i++) documentVersionList.add(itemResponses[i].getVersion()) */ // reset for the next set curBulkDocs = 0 actionSourceList = new ArrayList(docsPerBulk * 2) batchCount++ } } if (curBulkDocs > 0) { // logger.info("Bulk index last, cur docs ${curBulkDocs} of ${docListSize}, last index ${esIndexName} (for index ${_index} type ${_type})") RestClient.RestResponse response = bulkResponse(null, actionSourceList, false) checkResponse(response, "Bulk index", null) /* don't support getting versions any more, generally waste of resources: BulkItemResponse[] itemResponses = bulkResponse.getItems() int itemResponsesSize = itemResponses.length for (int i = 0; i < itemResponsesSize; i++) documentVersionList.add(itemResponses[i].getVersion()) */ } } @Override String objectToJson(Object jsonObject) { return ElasticFacadeImpl.objectToJson(jsonObject) } @Override Object jsonToObject(String jsonString) { return ElasticFacadeImpl.jsonToObject(jsonString) } String prefixIndexName(String index) { if (index == null) return null index = index.trim() if (index.isEmpty()) return null // handle comma separated index names return index.split(",").collect({ it = it.trim() return indexPrefix != null && !it.startsWith(indexPrefix) ? indexPrefix.concat(it) : it }).join(",") // return indexPrefix != null && !index.startsWith(indexPrefix) ? indexPrefix.concat(index) : index } String unprefixIndexName(String index) { if (index == null) return null index = index.trim() if (index.isEmpty()) return null // handle comma separated index names return index.split(",").collect({ it = it.trim() return indexPrefix != null && it.startsWith(indexPrefix) ? it.substring(indexPrefix.length()) : it }).join(",") // return indexPrefix != null && index.startsWith(indexPrefix) ? index.substring(indexPrefix.length()) : index } } // ============== Utility Methods ============== static void checkResponse(RestClient.RestResponse response, String operation, String index) { if (response.statusCode >= 200 && response.statusCode < 300) return String msg = "${operation}${index ? ' on index ' + index : ''} failed with code ${response.statusCode}: ${response.reasonPhrase}" String responseText = response.text() boolean logRequestBody = true try { Map responseMap = (Map) jsonToObject(response.text()) Map errorMap = (Map) responseMap.error if (errorMap) { msg = msg + ' - ' + errorMap.reason + ' (line ' + errorMap.line + ' col ' + errorMap.col + ')' // maybe not, just always do it: logRequestBody = errorMap.type == 'parsing_exception' } } catch (Throwable t) { logger.error("Error parsing ElasticSearch response: ${t.toString()}") } String requestUri = response.getClient().getUriString() String requestBody = response.getClient().getBodyText() if (requestBody != null && requestBody.length() > 2000) requestBody = requestBody.substring(0, 2000) logger.error("ElasticSearch ${msg}${responseText ? '\nResponse: ' + responseText : ''}${requestUri ? '\nURI: ' + requestUri : ''}${requestBody ? '\nRequest: ' + requestBody : ''}") throw new BaseException(msg) } static void checkBulkResponseErrors(RestClient.RestResponse response, String operation, String index) { if (response == null) return try { Map responseMap = (Map) jsonToObject(response.text()) if (responseMap.errors == true) { List items = (List) responseMap.items List errorMessages = [] for (Map item in items) { Map itemMap = (Map) item.values().first() if (itemMap.error) { String errorMsg = "Doc ${itemMap._id ?: 'unknown'}: ${itemMap.error}" errorMessages.add(errorMsg) if (errorMessages.size() >= 10) break } } String msg = "${operation}${index ? ' on index ' + index : ''} had ${items?.size() ?: 0} items with errors" if (errorMessages) msg += ":\n " + errorMessages.join("\n ") logger.error("ElasticSearch ${msg}") throw new BaseException(msg) } } catch (BaseException be) { throw be } catch (Throwable t) { logger.error("Error checking bulk response for errors: ${t.toString()}") } } static String objectToJson(Object jsonObject) { if (jsonObject instanceof String) return (String) jsonObject return jacksonMapper.writeValueAsString(jsonObject) } static Object jsonToObject(String jsonString) { try { JsonNode jsonNode = jacksonMapper.readTree(jsonString) if (jsonNode.isObject()) { return jacksonMapper.treeToValue(jsonNode, Map.class) } else if (jsonNode.isArray()) { return jacksonMapper.treeToValue(jsonNode, List.class) } else { throw new BaseException("JSON text root is not an Object or Array") } } catch (Throwable t) { throw new BaseException("Error parsing JSON: " + t.toString(), t) } } /* with Jackson configuration for serialization should not need this: static void convertTypesForEs(Map theMap) { // initially just Timestamp to Long using Timestamp.getTime() to handle ES time zone issues with Timestamp objects for (Map.Entry entry in theMap.entrySet()) { Object valObj = entry.getValue() if (valObj instanceof Timestamp) { entry.setValue(((Timestamp) valObj).getTime()) } else if (valObj instanceof java.sql.Date) { entry.setValue(valObj.toString()) } else if (valObj instanceof BigDecimal) { entry.setValue(((BigDecimal) valObj).doubleValue()) } else if (valObj instanceof GString) { entry.setValue(valObj.toString()) } else if (valObj instanceof Map) { convertTypesForEs((Map) valObj) } else if (valObj instanceof Collection) { for (Object colObj in ((Collection) valObj)) { if (colObj instanceof Map) { convertTypesForEs((Map) colObj) } else { // if first in list isn't a Map don't expect others to be break } } } } } */ static String ddIdToEsIndex(String dataDocumentId) { if (dataDocumentId.contains("_")) return dataDocumentId.toLowerCase() return EntityJavaUtil.camelCaseToUnderscored(dataDocumentId).toLowerCase() } static String esIndexToDdId(String index) { return EntityJavaUtil.underscoredToCamelCase(index, true) } static final Map esTypeMap = [id:'keyword', 'id-long':'keyword', date:'date', time:'text', 'date-time':'date', 'number-integer':'long', 'number-decimal':'double', 'number-float':'double', 'currency-amount':'double', 'currency-precise':'double', 'text-indicator':'keyword', 'text-short':'text', 'text-medium':'text', 'text-intermediate':'text', 'text-long':'text', 'text-very-long':'text', 'binary-very-long':'binary'] static Map makeElasticSearchMapping(String dataDocumentId, ExecutionContextFactoryImpl ecfi) { EntityValue dataDocument = ecfi.entityFacade.find("moqui.entity.document.DataDocument") .condition("dataDocumentId", dataDocumentId).useCache(true).one() if (dataDocument == null) throw new EntityException("No DataDocument found with ID [${dataDocumentId}]") EntityList dataDocumentFieldList = dataDocument.findRelated("moqui.entity.document.DataDocumentField", null, null, true, false) EntityList dataDocumentRelAliasList = dataDocument.findRelated("moqui.entity.document.DataDocumentRelAlias", null, null, true, false) Map relationshipAliasMap = [:] for (EntityValue dataDocumentRelAlias in dataDocumentRelAliasList) relationshipAliasMap.put((String) dataDocumentRelAlias.relationshipName, (String) dataDocumentRelAlias.documentAlias) String primaryEntityName = dataDocument.primaryEntityName // String primaryEntityAlias = relationshipAliasMap.get(primaryEntityName) ?: primaryEntityName EntityDefinition primaryEd = ecfi.entityFacade.getEntityDefinition(primaryEntityName) Map rootProperties = [_entity:[type:'keyword']] as Map Map mappingMap = [properties:rootProperties] as Map List remainingPkFields = new ArrayList(primaryEd.getPkFieldNames()) for (EntityValue dataDocumentField in dataDocumentFieldList) { String fieldPath = (String) dataDocumentField.fieldPath ArrayList fieldPathElementList = EntityDataDocument.fieldPathToList(fieldPath) if (fieldPathElementList.size() == 1) { // is a field on the primary entity, put it there String fieldName = ((String) dataDocumentField.fieldNameAlias) ?: fieldPath String mappingType = (String) dataDocumentField.fieldType String sortable = (String) dataDocumentField.sortable if (fieldPath.startsWith("(")) { rootProperties.put(fieldName, makePropertyMap(null, mappingType ?: 'double', sortable)) } else { FieldInfo fieldInfo = primaryEd.getFieldInfo(fieldPath) if (fieldInfo == null) throw new EntityException("Could not find field [${fieldPath}] for entity [${primaryEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]") rootProperties.put(fieldName, makePropertyMap(fieldInfo.type, mappingType, sortable)) if (remainingPkFields.contains(fieldPath)) remainingPkFields.remove(fieldPath) } continue } Map currentProperties = rootProperties EntityDefinition currentEd = primaryEd int fieldPathElementListSize = fieldPathElementList.size() for (int i = 0; i < fieldPathElementListSize; i++) { String fieldPathElement = (String) fieldPathElementList.get(i) if (i < (fieldPathElementListSize - 1)) { EntityJavaUtil.RelationshipInfo relInfo = currentEd.getRelationshipInfo(fieldPathElement) if (relInfo == null) throw new EntityException("Could not find relationship [${fieldPathElement}] for entity [${currentEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]") currentEd = relInfo.relatedEd if (currentEd == null) throw new EntityException("Could not find entity [${relInfo.relatedEntityName}] in DataDocument [${dataDocumentId}]") // only put type many in sub-objects, same as DataDocument generation if (!relInfo.isTypeOne) { String objectName = relationshipAliasMap.get(fieldPathElement) ?: fieldPathElement Map subObject = (Map) currentProperties.get(objectName) Map subProperties if (subObject == null) { subProperties = new HashMap<>() // using type:'nested' with include_in_root:true seems to support nested queries and currently works with query string full path field names too // NOTE: keep an eye on this and if it breaks for our primary use case which is query strings with full path field names then remove type:'nested' and include_in_root subObject = [properties:subProperties, type:'nested', include_in_root:true] as Map currentProperties.put(objectName, subObject) } else { subProperties = (Map) subObject.get("properties") } currentProperties = subProperties } } else { String fieldName = (String) dataDocumentField.fieldNameAlias ?: fieldPathElement String mappingType = (String) dataDocumentField.fieldType String sortable = (String) dataDocumentField.sortable if (fieldPathElement.startsWith("(")) { currentProperties.put(fieldName, makePropertyMap(null, mappingType ?: 'double', sortable)) } else { FieldInfo fieldInfo = currentEd.getFieldInfo(fieldPathElement) if (fieldInfo == null) throw new EntityException("Could not find field [${fieldPathElement}] for entity [${currentEd.getFullEntityName()}] in DataDocument [${dataDocumentId}]") currentProperties.put(fieldName, makePropertyMap(fieldInfo.type, mappingType, sortable)) } } } } // now get all the PK fields not aliased explicitly for (String remainingPkName in remainingPkFields) { FieldInfo fieldInfo = primaryEd.getFieldInfo(remainingPkName) String mappingType = esTypeMap.get(fieldInfo.type) ?: 'keyword' Map propertyMap = makePropertyMap(null, mappingType, null) // don't use not_analyzed in more recent ES: if (fieldInfo.type.startsWith("id")) propertyMap.index = 'not_analyzed' rootProperties.put(remainingPkName, propertyMap) } if (logger.isTraceEnabled()) logger.trace("Generated ElasticSearch mapping for ${dataDocumentId}: \n${JsonOutput.prettyPrint(JsonOutput.toJson(mappingMap))}") return mappingMap } static Map makePropertyMap(String fieldType, String mappingType, String sortable) { if (!mappingType) mappingType = esTypeMap.get(fieldType) ?: 'text' Map propertyMap = new LinkedHashMap<>() propertyMap.put("type", mappingType) if ("Y".equals(sortable) && "text".equals(mappingType)) propertyMap.put("fields", [keyword: [type: "keyword"]]) if ("date-time".equals(fieldType)) propertyMap.format = "date_time||epoch_millis||date_time_no_millis||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss.S||yyyy-MM-dd" else if ("date".equals(fieldType)) propertyMap.format = "date||strict_date_optional_time||epoch_millis" // if (fieldType.startsWith("id")) propertyMap.index = 'not_analyzed' return propertyMap } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ExecutionContextFactoryImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.apache.logging.log4j.LogManager import org.apache.logging.log4j.core.LoggerContext import org.apache.shiro.SecurityUtils import org.apache.shiro.authc.credential.CredentialsMatcher import org.apache.shiro.authc.credential.HashedCredentialsMatcher import org.apache.shiro.crypto.hash.SimpleHash import org.apache.shiro.env.BasicIniEnvironment import org.apache.shiro.mgt.SecurityManager import org.codehaus.groovy.control.CompilationUnit import org.codehaus.groovy.control.CompilerConfiguration import org.codehaus.groovy.tools.GroovyClass import org.moqui.BaseException import org.moqui.Moqui import org.moqui.context.* import org.moqui.context.ArtifactExecutionInfo.ArtifactType import org.moqui.entity.EntityDataLoader import org.moqui.entity.EntityFacade import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.util.CollectionUtilities import org.moqui.util.MClassLoader import org.moqui.impl.actions.XmlAction import org.moqui.resource.UrlResourceReference import org.moqui.impl.context.ContextJavaUtil.ArtifactBinInfo import org.moqui.impl.context.ContextJavaUtil.ArtifactStatsInfo import org.moqui.impl.context.ContextJavaUtil.ArtifactHitInfo import org.moqui.impl.context.ContextJavaUtil.CustomScheduledExecutor import org.moqui.impl.context.ContextJavaUtil.ScheduledRunnableInfo import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.screen.ScreenFacadeImpl import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.impl.webapp.NotificationWebSocketListener import org.moqui.screen.ScreenFacade import org.moqui.service.ServiceFacade import org.moqui.util.MNode import org.moqui.resource.ResourceReference import org.moqui.util.ObjectUtilities import org.moqui.util.SimpleTopic import org.moqui.util.StringUtilities import org.moqui.util.SystemBinding import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.ServletContext import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import javax.annotation.Nonnull import jakarta.websocket.server.ServerContainer import java.lang.management.ManagementFactory import java.math.RoundingMode import java.sql.Timestamp import java.util.concurrent.BlockingQueue import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.LinkedBlockingQueue import java.util.concurrent.ScheduledFuture import java.util.concurrent.ScheduledThreadPoolExecutor import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean import java.util.jar.JarFile import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @CompileStatic class ExecutionContextFactoryImpl implements ExecutionContextFactory { protected final static Logger logger = LoggerFactory.getLogger(ExecutionContextFactoryImpl.class) protected final static boolean isTraceEnabled = logger.isTraceEnabled() private AtomicBoolean destroyed = new AtomicBoolean(false) public final long initStartTime public final String initStartHex protected String runtimePath @SuppressWarnings("GrFinalVariableAccess") protected final String runtimeConfPath @SuppressWarnings("GrFinalVariableAccess") protected final MNode confXmlRoot protected MNode serverStatsNode protected String moquiVersion = "" protected Map versionMap = null protected InetAddress localhostAddress = null protected MClassLoader moquiClassLoader protected GroovyClassLoader groovyClassLoader protected CompilerConfiguration groovyCompilerConf // NOTE: this is experimental, don't set to true! still issues with unique class names, etc // also issue with how to support recompile of actions on change, could just use for expressions but that only helps so much // maybe some way to load from disk only if timestamp newer for XmlActions and GroovyScriptRunner // this could be driven by setting in Moqui Conf XML file // also need to clean out runtime/script-classes in gradle cleanAll protected boolean groovyCompileCacheToDisk = false protected LinkedHashMap componentInfoMap = new LinkedHashMap<>() public final ThreadLocal activeContext = new ThreadLocal<>() public final Map activeContextMap = new HashMap<>() protected final LinkedHashMap toolFactoryMap = new LinkedHashMap<>() protected final Map webappInfoMap = new HashMap<>() protected final List registeredNotificationMessageListeners = [] protected final Map artifactStatsInfoByType = new HashMap<>() public final Map artifactTypeAuthzEnabled = new EnumMap(ArtifactType.class) public final Map artifactTypeTarpitEnabled = new EnumMap(ArtifactType.class) protected String skipStatsCond protected long hitBinLengthMillis = 900000 // 15 minute default private final EnumMap artifactPersistHitByTypeEnum = new EnumMap(ArtifactType.class) private final EnumMap artifactPersistBinByTypeEnum = new EnumMap(ArtifactType.class) final ConcurrentLinkedQueue deferredHitInfoQueue = new ConcurrentLinkedQueue() /** The SecurityManager for Apache Shiro */ protected SecurityManager internalSecurityManager /** The ServletContext, if Moqui was initialized in a webapp (generally through MoquiContextListener) */ protected ServletContext internalServletContext = null /** The WebSocket ServerContainer, if found in 'jakarta.websocket.server.ServerContainer' ServletContext attribute */ protected ServerContainer internalServerContainer = null /** Notification Message Topic (for distributed notifications) */ private SimpleTopic notificationMessageTopic = null private NotificationWebSocketListener notificationWebSocketListener = new NotificationWebSocketListener() protected ArrayList logEventSubscribers = new ArrayList<>() // ======== Permanent Delegated Facades ======== @SuppressWarnings("GrFinalVariableAccess") public final CacheFacadeImpl cacheFacade @SuppressWarnings("GrFinalVariableAccess") public final LoggerFacadeImpl loggerFacade @SuppressWarnings("GrFinalVariableAccess") public final ResourceFacadeImpl resourceFacade @SuppressWarnings("GrFinalVariableAccess") public final TransactionFacadeImpl transactionFacade @SuppressWarnings("GrFinalVariableAccess") public final EntityFacadeImpl entityFacade @SuppressWarnings("GrFinalVariableAccess") public final ElasticFacadeImpl elasticFacade @SuppressWarnings("GrFinalVariableAccess") public final ServiceFacadeImpl serviceFacade @SuppressWarnings("GrFinalVariableAccess") public final ScreenFacadeImpl screenFacade /** The main worker pool for services, running async closures and runnables, etc */ @SuppressWarnings("GrFinalVariableAccess") public final ThreadPoolExecutor workerPool /** An executor for the scheduled job runner */ @SuppressWarnings("GrFinalVariableAccess") public final CustomScheduledExecutor scheduledExecutor public final ArrayList scheduledRunnableList = new ArrayList<>() /** * This constructor gets runtime directory and conf file location from a properties file on the classpath so that * it can initialize on its own. This is the constructor to be used by the ServiceLoader in the Moqui.java file, * or by init methods in a servlet or context filter or OSGi component or Spring component or whatever. */ ExecutionContextFactoryImpl() { initStartTime = System.currentTimeMillis() // 1609900441000 (decimal 13 chars) = 176D58B3DA8 (hex 11 chars) take 7 means leave off 4 hex chars which is 65536ms which is ~1 minute (ie server start time round floor to ~1 min) initStartHex = Long.toHexString(initStartTime).take(7) // get the MoquiInit.properties file Properties moquiInitProperties = new Properties() URL initProps = this.class.getClassLoader().getResource("MoquiInit.properties") if (initProps != null) { InputStream is = initProps.openStream(); moquiInitProperties.load(is); is.close() } // if there is a system property use that, otherwise from the properties file runtimePath = System.getProperty("moqui.runtime") if (!runtimePath) { runtimePath = moquiInitProperties.getProperty("moqui.runtime") // if there was no system property set one, make sure at least something is always set for conf files/etc if (runtimePath) System.setProperty("moqui.runtime", runtimePath) } if (!runtimePath) throw new IllegalArgumentException("No moqui.runtime property found in MoquiInit.properties or in a system property (with: -Dmoqui.runtime=... on the command line)") if (runtimePath.endsWith("/")) runtimePath = runtimePath.substring(0, runtimePath.length()-1) // check the runtime directory via File File runtimeFile = new File(runtimePath) if (runtimeFile.exists()) { runtimePath = runtimeFile.getCanonicalPath() } else { throw new IllegalArgumentException("The moqui.runtime path [${runtimePath}] was not found.") } // get the moqui configuration file path String confPartialPath = System.getProperty("moqui.conf") if (!confPartialPath) confPartialPath = moquiInitProperties.getProperty("moqui.conf") if (!confPartialPath) throw new IllegalArgumentException("No moqui.conf property found in MoquiInit.properties or in a system property (with: -Dmoqui.conf=... on the command line)") String confFullPath if (confPartialPath.startsWith("/")) { confFullPath = confPartialPath } else { confFullPath = runtimePath + "/" + confPartialPath } // setup the confFile File confFile = new File(confFullPath) if (confFile.exists()) { runtimeConfPath = confFullPath } else { runtimeConfPath = null throw new IllegalArgumentException("The moqui.conf path [${confFullPath}] was not found.") } // sleep here to attach profiler before init: sleep(30000) // initialize all configuration, get various conf files merged and load components MNode runtimeConfXmlRoot = MNode.parse(confFile) MNode baseConfigNode = initBaseConfig(runtimeConfXmlRoot) // init components before initConfig() so component configuration files can be incorporated initComponents(baseConfigNode) // init the configuration (merge from component and runtime conf files) confXmlRoot = initConfig(baseConfigNode, runtimeConfXmlRoot) reconfigureLog4j() workerPool = makeWorkerPool() scheduledExecutor = makeScheduledExecutor() preFacadeInit() // this init order is important as some facades will use others cacheFacade = new CacheFacadeImpl(this) logger.info("Cache Facade initialized") loggerFacade = new LoggerFacadeImpl(this) // logger.info("Logger Facade initialized") resourceFacade = new ResourceFacadeImpl(this) logger.info("Resource Facade initialized") transactionFacade = new TransactionFacadeImpl(this) logger.info("Transaction Facade initialized") entityFacade = new EntityFacadeImpl(this) logger.info("Entity Facade initialized") serviceFacade = new ServiceFacadeImpl(this) logger.info("Service Facade initialized") screenFacade = new ScreenFacadeImpl(this) logger.info("Screen Facade initialized") postFacadeInit() // NOTE: ElasticFacade init after postFacadeInit() so finds embedded from moqui-elasticsearch if present, can move up once moqui-elasticsearch deprecated elasticFacade = new ElasticFacadeImpl(this) logger.info("Elastic Facade initialized") logger.info("Execution Context Factory initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds") } /** This constructor takes the runtime directory path and conf file path directly. */ ExecutionContextFactoryImpl(String runtimePathParm, String confPathParm) { initStartTime = System.currentTimeMillis() // 1609900441000 (decimal 13 chars) = 176D58B3DA8 (hex 11 chars) take 7 means leave off 4 hex chars which is 65536ms which is ~1 minute (ie server start time round floor to ~1 min) initStartHex = Long.toHexString(initStartTime).take(7) // setup the runtimeFile File runtimeFile = new File(runtimePathParm) if (!runtimeFile.exists()) throw new IllegalArgumentException("The moqui.runtime path [${runtimePathParm}] was not found.") // setup the confFile if (runtimePathParm.endsWith('/')) runtimePathParm = runtimePathParm.substring(0, runtimePathParm.length()-1) if (confPathParm.startsWith('/')) confPathParm = confPathParm.substring(1) String confFullPath = runtimePathParm + '/' + confPathParm File confFile = new File(confFullPath) if (!confFile.exists()) throw new IllegalArgumentException("The moqui.conf path [${confFullPath}] was not found.") runtimePath = runtimePathParm runtimeConfPath = confFullPath // initialize all configuration, get various conf files merged and load components MNode runtimeConfXmlRoot = MNode.parse(confFile) MNode baseConfigNode = initBaseConfig(runtimeConfXmlRoot) // init components before initConfig() so component configuration files can be incorporated initComponents(baseConfigNode) // init the configuration (merge from component and runtime conf files) confXmlRoot = initConfig(baseConfigNode, runtimeConfXmlRoot) reconfigureLog4j() workerPool = makeWorkerPool() scheduledExecutor = makeScheduledExecutor() preFacadeInit() // this init order is important as some facades will use others cacheFacade = new CacheFacadeImpl(this) logger.info("Cache Facade initialized") loggerFacade = new LoggerFacadeImpl(this) // logger.info("LoggerFacadeImpl initialized") resourceFacade = new ResourceFacadeImpl(this) logger.info("Resource Facade initialized") transactionFacade = new TransactionFacadeImpl(this) logger.info("Transaction Facade initialized") entityFacade = new EntityFacadeImpl(this) logger.info("Entity Facade initialized") serviceFacade = new ServiceFacadeImpl(this) logger.info("Service Facade initialized") screenFacade = new ScreenFacadeImpl(this) logger.info("Screen Facade initialized") postFacadeInit() // NOTE: ElasticFacade init after postFacadeInit() so finds embedded from moqui-elasticsearch if present, can move up once moqui-elasticsearch deprecated elasticFacade = new ElasticFacadeImpl(this) logger.info("Elastic Facade initialized") logger.info("Execution Context Factory initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds") } protected void reconfigureLog4j() { URL log4j2Url = this.class.getClassLoader().getResource("log4j2.xml") if (log4j2Url == null) { logger.warn("No log4j2.xml file found on the classpath, no reconfiguring Log4J") return } final LoggerContext ctx = (LoggerContext) LogManager.getContext(true) ctx.setConfigLocation(log4j2Url.toURI()) } protected MNode initBaseConfig(MNode runtimeConfXmlRoot) { String version = this.class.getPackage().getImplementationVersion() if (version != null) moquiVersion = version /* Enumeration resources = getClass().getClassLoader().getResources("META-INF/MANIFEST.MF") while (resources.hasMoreElements()) { try { Manifest manifest = new Manifest(resources.nextElement().openStream()) Attributes attributes = manifest.getMainAttributes() String implTitle = attributes.getValue("Implementation-Title") String implVendor = attributes.getValue("Implementation-Vendor") if ("Moqui Framework".equals(implTitle) && "Moqui Ecosystem".equals(implVendor)) { moquiVersion = attributes.getValue("Implementation-Version") break } } catch (IOException e) { logger.info("Error reading manifest files", e) } } */ System.setProperty("moqui.version", moquiVersion) // don't set the moqui.runtime and moqui.conf system properties as before, causes conflict with multiple moqui instances in one JVM // NOTE: moqui.runtime is set in MoquiStart and in MoquiContextListener (if there is an embedded runtime directory) // System.setProperty("moqui.runtime", runtimePath) // System.setProperty("moqui.conf", runtimeConfPath) logger.info("Initializing Moqui Framework version ${moquiVersion ?: 'Unknown'}\n - runtime directory: ${this.runtimePath}\n - runtime config: ${this.runtimeConfPath}") logger.info("Running on Java ${System.getProperty("java.version")} VM ${System.getProperty("java.vm.version")} Runtime ${System.getProperty("java.runtime.version")}") URL defaultConfUrl = this.class.getClassLoader().getResource("MoquiDefaultConf.xml") if (defaultConfUrl == null) throw new IllegalArgumentException("Could not find MoquiDefaultConf.xml file on the classpath") MNode newConfigXmlRoot = MNode.parse(defaultConfUrl.toString(), defaultConfUrl.newInputStream()) // just merge the component configuration, needed before component init is done mergeConfigComponentNodes(newConfigXmlRoot, runtimeConfXmlRoot) return newConfigXmlRoot } protected void initComponents(MNode baseConfigNode) { File versionJsonFile = new File(runtimePath + "/version.json") if (versionJsonFile.exists()) { try { versionMap = (Map) new JsonSlurper().parse(versionJsonFile) } catch (Exception e) { logger.warn("Error parsion runtime/version.json", e) } } // init components referred to in component-list.component and component-dir elements in the conf file for (MNode childNode in baseConfigNode.first("component-list").children) { if ("component".equals(childNode.name)) { addComponent(new ComponentInfo(null, childNode, this)) } else if ("component-dir".equals(childNode.name)) { addComponentDir(childNode.attribute("location")) } } checkSortDependentComponents() } protected MNode initConfig(MNode baseConfigNode, MNode runtimeConfXmlRoot) { // merge any config files in components for (ComponentInfo ci in componentInfoMap.values()) { ResourceReference compXmlRr = ci.componentRr.getChild("MoquiConf.xml") if (compXmlRr.getExists()) { logger.info("Merging MoquiConf.xml file from component ${ci.name}") MNode compXmlNode = MNode.parse(compXmlRr) mergeConfigNodes(baseConfigNode, compXmlNode) } } // merge the runtime conf file into the default one to override any settings (they both have the same root node, go from there) logger.info("Merging runtime configuration at ${runtimeConfPath}") mergeConfigNodes(baseConfigNode, runtimeConfXmlRoot) // set default System properties now that all is merged for (MNode defPropNode in baseConfigNode.children("default-property")) { String propName = defPropNode.attribute("name") String isSecretAttr = defPropNode.attribute("is-secret") boolean isSecret = !"false".equals(isSecretAttr) && ("true".equals(isSecretAttr) || propName.contains("pass") || propName.contains("pw") || propName.contains("key")) if (System.getProperty(propName)) { if (isSecret) { logger.info("Found secret property ${propName}, not setting from env var or default") } else { logger.info("Found property ${propName} with value [${System.getProperty(propName)}], not setting from env var or default") } } else if (System.getenv(propName)) { // make env vars available as Java System properties System.setProperty(propName, System.getenv(propName)) if (isSecret) { logger.info("Setting secret property ${propName} from env var") } else { logger.info("Setting property ${propName} from env var with value [${System.getProperty(propName)}]") } } else { String valueAttr = defPropNode.attribute("value") if (valueAttr != null && !valueAttr.isEmpty()) { System.setProperty(propName, SystemBinding.expand(valueAttr)) if (isSecret) { logger.info("Setting secret property ${propName} from default") } else { logger.info("Setting property ${propName} from default with value [${System.getProperty(propName)}]") } } } } // if there are default_locale or default_time_zone Java props or system env vars set defaults String localeStr = SystemBinding.getPropOrEnv("default_locale") if (localeStr) { try { int usIdx = localeStr.indexOf("_") Locale.setDefault(usIdx < 0 ? new Locale(localeStr) : new Locale(localeStr.substring(0, usIdx), localeStr.substring(usIdx+1).toUpperCase())) } catch (Throwable t) { logger.error("Error setting default locale to ${localeStr}: ${t.toString()}") } } String tzStr = SystemBinding.getPropOrEnv("default_time_zone") if (tzStr) { try { logger.info("Found default_time_zone ${tzStr}: ${TimeZone.getTimeZone(tzStr)}") TimeZone.setDefault(TimeZone.getTimeZone(tzStr)) } catch (Throwable t) { logger.error("Error setting default time zone to ${tzStr}: ${t.toString()}") } } logger.info("Default locale ${Locale.getDefault()}, time zone ${TimeZone.getDefault()}") return baseConfigNode } private ThreadPoolExecutor makeWorkerPool() { MNode toolsNode = confXmlRoot.first('tools') int workerQueueSize = (toolsNode.attribute("worker-queue") ?: "65536") as int BlockingQueue workQueue = new LinkedBlockingQueue<>(workerQueueSize) int coreSize = (toolsNode.attribute("worker-pool-core") ?: "16") as int int maxSize = (toolsNode.attribute("worker-pool-max") ?: "32") as int int availableProcessorsSize = Runtime.getRuntime().availableProcessors() * 3 if (availableProcessorsSize > maxSize) { logger.info("Setting worker pool size to ${availableProcessorsSize} based on available processors * 3") maxSize = availableProcessorsSize } long aliveTime = (toolsNode.attribute("worker-pool-alive") ?: "60") as long logger.info("Initializing worker ThreadPoolExecutor: queue limit ${workerQueueSize}, pool-core ${coreSize}, pool-max ${maxSize}, pool-alive ${aliveTime}s") return new ContextJavaUtil.WorkerThreadPoolExecutor(this, coreSize, maxSize, aliveTime, TimeUnit.SECONDS, workQueue, new ContextJavaUtil.WorkerThreadFactory()) } boolean waitWorkerPoolEmpty(int retryLimit) { ThreadPoolExecutor jobWorkerPool = serviceFacade.jobWorkerPool int count = 0 while (count < retryLimit && (workerPool.getQueue().size() > 0 || workerPool.getActiveCount() > 0 || jobWorkerPool.getQueue().size() > 0 || jobWorkerPool.getActiveCount() > 0)) { if (count % 10 == 0) logger.warn("Wait for workerPool and jobWorkerPool empty: worker queue size ${workerPool.getQueue().size()} active ${workerPool.getActiveCount()} max threads ${workerPool.getMaximumPoolSize()}; service job queue size ${jobWorkerPool.getQueue().size()} active ${jobWorkerPool.getActiveCount()}") Thread.sleep(100) count++ } int afterSize = workerPool.getQueue().size() + workerPool.getActiveCount() int jobAfterSize = jobWorkerPool.getQueue().size() + jobWorkerPool.getActiveCount() if (afterSize > 0 || jobAfterSize > 0) logger.warn("After ${retryLimit} 100ms waits worker pool size is ${afterSize} and service job pool size is ${jobAfterSize}") return afterSize == 0 && jobAfterSize == 0 } private CustomScheduledExecutor makeScheduledExecutor() { // TODO: make the scheduled thread pool core and max sizes configurable? so far only used for a small number of scheduled Runnables CustomScheduledExecutor executor = new CustomScheduledExecutor(2) executor.setMaximumPoolSize(8) return executor } void scheduleAtFixedRate(Runnable command, long initialDelaySeconds, long periodSeconds) { // NOTE: actually returns an inaccessible class: ScheduledThreadPoolExecutor$ScheduledFutureTask ScheduledFuture scheduledFuture = this.scheduledExecutor.scheduleAtFixedRate(command, initialDelaySeconds, periodSeconds, TimeUnit.SECONDS) this.scheduledRunnableList.add(new ScheduledRunnableInfo(command, periodSeconds)) } void scheduledReInit() { for (ScheduledRunnableInfo runnableInfo in this.scheduledRunnableList) { String commandClass = runnableInfo.command.class.name logger.warn("Removing scheduled runnable ${commandClass}") BlockingQueue queue = this.scheduledExecutor.getQueue() for (Runnable qr in queue) { if (qr instanceof ContextJavaUtil.CustomScheduledTask) { ContextJavaUtil.CustomScheduledTask task = (ContextJavaUtil.CustomScheduledTask) qr if (task.runnable != null && task.runnable.class.name == commandClass) { logger.warn("Removing scheduled runnable ${commandClass} - found matching task, removing") boolean removed = scheduledExecutor.remove(task) logger.warn("Removed scheduled runnable ${commandClass}, was present? ${removed}") } } } logger.warn("Adding scheduled runnable ${commandClass} period ${runnableInfo.period}s") this.scheduledExecutor.scheduleAtFixedRate(runnableInfo.command, 0, runnableInfo.period, TimeUnit.SECONDS) } } private void preFacadeInit() { // save the current configuration in a file for debugging/reference File confSaveFile = new File(runtimePath + "/log/MoquiActualConf.xml") try { if (confSaveFile.exists()) confSaveFile.delete() if (!confSaveFile.parentFile.exists()) confSaveFile.parentFile.mkdirs() FileWriter fw = new FileWriter(confSaveFile) fw.write(confXmlRoot.toString()) fw.close() } catch (Exception e) { logger.warn("Could not save ${confSaveFile.absolutePath} file: ${e.toString()}") } // get localhost address for ongoing use try { localhostAddress = InetAddress.getLocalHost() } catch (UnknownHostException e) { logger.warn("Could not get localhost address", new BaseException("Could not get localhost address", e)) } // init ClassLoader early so that classpath:// resources and framework interface impls will work initClassLoader() // do these after initComponents as that may override configuration serverStatsNode = confXmlRoot.first('server-stats') skipStatsCond = serverStatsNode.attribute("stats-skip-condition") String binLengthAttr = serverStatsNode.attribute("bin-length-seconds") if (binLengthAttr != null && !binLengthAttr.isEmpty()) hitBinLengthMillis = (binLengthAttr as long)*1000 // populate ArtifactType configurations for (ArtifactType at in ArtifactType.values()) { MNode artifactStats = getArtifactStatsNode(at.name(), null) if (artifactStats == null) { artifactPersistHitByTypeEnum.put(at, Boolean.FALSE) artifactPersistBinByTypeEnum.put(at, Boolean.FALSE) } else { artifactPersistHitByTypeEnum.put(at, "true".equals(artifactStats.attribute("persist-hit"))) artifactPersistBinByTypeEnum.put(at, "true".equals(artifactStats.attribute("persist-bin"))) } MNode aeNode = getArtifactExecutionNode(at.name()) if (aeNode == null) { artifactTypeAuthzEnabled.put(at, true) artifactTypeTarpitEnabled.put(at, true) } else { artifactTypeAuthzEnabled.put(at, !"false".equals(aeNode.attribute("authz-enabled"))) artifactTypeTarpitEnabled.put(at, !"false".equals(aeNode.attribute("tarpit-enabled"))) } } // register notificationWebSocketListener registerNotificationMessageListener(notificationWebSocketListener) // Load ToolFactory implementations from tools.tool-factory elements, run preFacadeInit() methods ArrayList> toolFactoryAttrsList = new ArrayList<>() for (MNode toolFactoryNode in confXmlRoot.first("tools").children("tool-factory")) { if (toolFactoryNode.attribute("disabled") == "true") { logger.info("Not loading disabled ToolFactory with class: ${toolFactoryNode.attribute("class")}") continue } toolFactoryAttrsList.add(toolFactoryNode.getAttributes()) } CollectionUtilities.orderMapList(toolFactoryAttrsList as List, ["init-priority", "class"]) for (Map toolFactoryAttrs in toolFactoryAttrsList) { String tfClass = toolFactoryAttrs.get("class") logger.info("Loading ToolFactory with class: ${tfClass}") try { ToolFactory tf = (ToolFactory) Thread.currentThread().getContextClassLoader().loadClass(tfClass).newInstance() tf.preFacadeInit(this) toolFactoryMap.put(tf.getName(), tf) } catch (Throwable t) { logger.error("Error loading ToolFactory with class ${tfClass}", t) } } } private void postFacadeInit() { entityFacade.postFacadeInit() serviceFacade.postFacadeInit() // Warm cache on start if configured to do so if (confXmlRoot.first("cache-list").attribute("warm-on-start") != "false") warmCache() // Run init() in ToolFactory implementations from tools.tool-factory elements Iterator> tfIterator = toolFactoryMap.entrySet().iterator() while (tfIterator.hasNext()) { Map.Entry tfEntry = tfIterator.next() ToolFactory tf = tfEntry.getValue() logger.info("Initializing ToolFactory: ${tf.getName()}") try { tf.init(this) } catch (Throwable t) { logger.error("Error initializing ToolFactory ${tf.getName()}", t) tfIterator.remove() } } // Notification Message Topic String notificationTopicFactory = confXmlRoot.first("tools").attribute("notification-topic-factory") if (notificationTopicFactory) { try { notificationMessageTopic = (SimpleTopic) getTool(notificationTopicFactory, SimpleTopic.class) } catch (Throwable t) { logger.error("Error initializing notification-topic-factory ${notificationTopicFactory}", t) } } // schedule DeferredHitInfoFlush (every 5 seconds, after 10 second init delay) DeferredHitInfoFlush dhif = new DeferredHitInfoFlush(this) this.scheduleAtFixedRate(dhif, 10, 5) // all config loaded, save memory by clearing the parsed MNode cache, especially for production mode MNode.clearParsedNodeCache() // bunch of junk in memory, trigger gc (to happen soon, when JVM decides, not immediate) System.gc() } void warmCache() { this.entityFacade.warmCache() this.serviceFacade.warmCache() this.screenFacade.warmCache() } /** Setup the cached ClassLoader, this should init in the main thread so we can set it properly */ private void initClassLoader() { long startTime = System.currentTimeMillis() MClassLoader.addCommonClass("org.moqui.entity.EntityValue", EntityValue.class) MClassLoader.addCommonClass("EntityValue", EntityValue.class) MClassLoader.addCommonClass("org.moqui.entity.EntityList", EntityList.class) MClassLoader.addCommonClass("EntityList", EntityList.class) logger.info("Initializing MClassLoader context ${Thread.currentThread().getContextClassLoader()?.class?.name} cur class ${this.class.classLoader?.class?.name} system ${System.classLoader?.class?.name}") ClassLoader pcl = (Thread.currentThread().getContextClassLoader() ?: this.class.classLoader) ?: System.classLoader moquiClassLoader = new MClassLoader(pcl) logger.info("Initialized MClassLoader with parent ${pcl.class.name}") // NOTE: initialized here but NOT used as currentThread ClassLoader groovyClassLoader = new GroovyClassLoader(moquiClassLoader) File scriptClassesDir = new File(runtimePath + "/script-classes") scriptClassesDir.mkdirs() if (groovyCompileCacheToDisk) moquiClassLoader.addClassesDirectory(scriptClassesDir) groovyCompilerConf = new CompilerConfiguration() groovyCompilerConf.setTargetDirectory(scriptClassesDir) // add runtime/classes jar files to the class loader File runtimeClassesFile = new File(runtimePath + "/classes") if (runtimeClassesFile.exists()) { moquiClassLoader.addClassesDirectory(runtimeClassesFile) } // add runtime/lib jar files to the class loader File runtimeLibFile = new File(runtimePath + "/lib") if (runtimeLibFile.exists()) for (File jarFile: runtimeLibFile.listFiles()) { if (jarFile.getName().endsWith(".jar")) { moquiClassLoader.addJarFile(new JarFile(jarFile), jarFile.toURI().toURL()) logger.info("Added JAR from runtime/lib: ${jarFile.getName()}") } } // add /classes and /lib jar files to the class loader now that component locations loaded for (ComponentInfo ci in componentInfoMap.values()) { ResourceReference classesRr = ci.componentRr.getChild("classes") if (classesRr.exists && classesRr.supportsDirectory() && classesRr.isDirectory()) { moquiClassLoader.addClassesDirectory(new File(classesRr.getUrl().getPath())) } ResourceReference libRr = ci.componentRr.getChild("lib") if (libRr.exists && libRr.supportsDirectory() && libRr.isDirectory()) { Set jarsLoaded = new LinkedHashSet<>() for (ResourceReference jarRr: libRr.getDirectoryEntries()) { if (jarRr.fileName.endsWith(".jar")) { try { moquiClassLoader.addJarFile(new JarFile(new File(jarRr.getUrl().getPath())), jarRr.getUrl()) jarsLoaded.add(jarRr.getFileName()) } catch (Exception e) { logger.error("Could not load JAR from component ${ci.name}: ${jarRr.getLocation()}: ${e.toString()}") } } } logger.info("Added JARs from component ${ci.name}: ${jarsLoaded}") } } // clear not found info just in case anything was falsely added moquiClassLoader.clearNotFoundInfo() // set as context classloader Thread.currentThread().setContextClassLoader(moquiClassLoader) logger.info("Initialized ClassLoaders in ${System.currentTimeMillis() - startTime}ms") } @Override boolean checkEmptyDb() { /* NOTE: Called from Moqui.dynamicInit() after ECFI init (which is also called from MoquiContextListener.contextInitialized()) */ MNode toolsNode = confXmlRoot.first("tools") toolsNode.setSystemExpandAttributes(true) boolean needsRestartEcfi = false boolean emptyDbLoadRan = false // if empty-db-load has a value and is not 'none' then load those String emptyDbLoad = toolsNode.attribute("empty-db-load") if (emptyDbLoad && emptyDbLoad != 'none') { long enumCount = getEntity().find("moqui.basic.Enumeration").disableAuthz().count() if (enumCount == 0) { logger.info("Found ${enumCount} Enumeration records, loading empty-db-load data types (${emptyDbLoad})") ExecutionContext ec = getExecutionContext() try { ec.getArtifactExecution().disableAuthz() ec.getArtifactExecution().push("loadDataEmptyDb", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false) ec.getArtifactExecution().setAnonymousAuthorizedAll() ec.getUser().loginAnonymousIfNoUser() EntityDataLoader edl = ec.getEntity().makeDataLoader() if (emptyDbLoad != 'all') edl.dataTypes(new HashSet(emptyDbLoad.split(",") as List)) try { long startTime = System.currentTimeMillis() long records = edl.load() logger.info("Loaded [${records}] records (with types from empty-db-load: ${emptyDbLoad}) in ${(System.currentTimeMillis() - startTime)/1000} seconds.") } catch (Throwable t) { logger.error("Error loading empty DB data (with types: ${emptyDbLoad})", t) } } finally { ec.destroy() } needsRestartEcfi = true emptyDbLoadRan = true } else { logger.info("Found ${enumCount} Enumeration records, NOT loading empty-db-load data types (${emptyDbLoad})") } } // if on-start-load-types has a value and is not 'none' then load those String onStartLoadTypes = toolsNode.attribute("on-start-load-types") String onStartLoadComponents = toolsNode.attribute("on-start-load-components") if (!emptyDbLoadRan && onStartLoadTypes && onStartLoadTypes != 'none') { logger.info("Loading on-start-load-types data types [${onStartLoadTypes}] and components [${onStartLoadComponents ?: 'all'}]") ExecutionContext ec = getExecutionContext() try { ec.getArtifactExecution().disableAuthz() ec.getArtifactExecution().push("loadDataOnStart", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false) ec.getArtifactExecution().setAnonymousAuthorizedAll() ec.getUser().loginAnonymousIfNoUser() EntityDataLoader edl = ec.getEntity().makeDataLoader() if (onStartLoadTypes != 'all') edl.dataTypes(new HashSet(onStartLoadTypes.split(",") as List)) if (onStartLoadComponents && onStartLoadComponents != 'all') edl.componentNameList(onStartLoadComponents.split(",") as List) try { long startTime = System.currentTimeMillis() long records = edl.load() logger.info("Loaded [${records}] records (with types from on-start-load-types: [${onStartLoadTypes}] components: [${onStartLoadComponents ?: 'all'}]) in ${(System.currentTimeMillis() - startTime)/1000} seconds.") } catch (Throwable t) { logger.error("Error loading on-start DB data (with types: [${onStartLoadTypes}] components: [${onStartLoadComponents ?: 'all'}])", t) } } finally { ec.destroy() } needsRestartEcfi = true } // if this instance_purpose is test load type 'test' data if ("test".equals(System.getProperty("instance_purpose"))) { logger.warn("Loading 'test' type data (because instance_purpose=test)") ExecutionContext ec = getExecutionContext() try { ec.getArtifactExecution().disableAuthz() ec.getArtifactExecution().push("loadDataTest", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false) ec.getArtifactExecution().setAnonymousAuthorizedAll() ec.getUser().loginAnonymousIfNoUser() EntityDataLoader edl = ec.getEntity().makeDataLoader() edl.dataTypes(new HashSet(['test'])) try { long startTime = System.currentTimeMillis() long records = edl.load() logger.info("Loaded [${records}] records (with type test) in ${(System.currentTimeMillis() - startTime)/1000} seconds.") } catch (Throwable t) { logger.error("Error loading empty DB data (with type test)", t) } } finally { ec.destroy() } } return needsRestartEcfi } @Override void destroy() { if (destroyed.getAndSet(true)) { logger.warn("Not destroying ExecutionContextFactory, already destroyed (or destroying)") return } // persist any remaining bins in artifactHitBinByType Timestamp currentTimestamp = new Timestamp(System.currentTimeMillis()) List asiList = new ArrayList<>(artifactStatsInfoByType.values()) artifactStatsInfoByType.clear() ArtifactExecutionFacadeImpl aefi = getEci().artifactExecutionFacade boolean enableAuthz = !aefi.disableAuthz() try { for (ArtifactStatsInfo asi in asiList) { if (asi.curHitBin == null) continue EntityValue ahb = asi.curHitBin.makeAhbValue(this, currentTimestamp) ahb.setSequencedIdPrimary().create() } } finally { if (enableAuthz) aefi.enableAuthz() } logger.info("ArtifactHitBins stored") // shutdown scheduled executor and worker pools try { logger.info("Shutting scheduled executor") scheduledExecutor.shutdown() logger.info("Shutting down worker pool") workerPool.shutdown() scheduledExecutor.awaitTermination(30, TimeUnit.SECONDS) if (scheduledExecutor.isTerminated()) logger.info("Scheduled executor shut down and terminated") else logger.warn("Scheduled executor NOT YET terminated, waited 30 seconds") workerPool.awaitTermination(30, TimeUnit.SECONDS) if (workerPool.isTerminated()) logger.info("Worker pool shut down and terminated") else logger.warn("Worker pool NOT YET terminated, waited 30 seconds") } catch (Throwable t) { logger.error("Error in workerPool/scheduledExecutor shutdown", t) } // stop NotificationMessageListeners for (NotificationMessageListener nml in registeredNotificationMessageListeners) nml.destroy() // Run destroy() in ToolFactory implementations from tools.tool-factory elements, in reverse order ArrayList toolFactoryList = new ArrayList<>(toolFactoryMap.values()) Collections.reverse(toolFactoryList) for (ToolFactory tf in toolFactoryList) { logger.info("Destroying ToolFactory: ${tf.getName()}") // NOTE: also calling System.out.println because log4j gets often gets closed before this completes // System.out.println("Destroying ToolFactory: ${tf.getName()}") try { tf.destroy() } catch (Throwable t) { logger.error("Error destroying ToolFactory ${tf.getName()}", t) } } /* use to watch destroy issues: if (activeContextMap.size() > 2) { Set threadIds = activeContextMap.keySet() ThreadMXBean threadMXBean = ManagementFactory.getThreadMXBean() for (Long threadId in threadIds) { ThreadInfo threadInfo = threadMXBean.getThreadInfo(threadId) if (threadInfo == null) continue logger.warn("Active execution context in thread ${threadInfo.threadId}:${threadInfo.getThreadName()} state ${threadInfo.getThreadState()} blocked ${threadInfo.getBlockedCount()} lock ${threadInfo.getLockInfo()}") } for (ThreadInfo threadInfo in threadMXBean.dumpAllThreads(true, true)) { System.out.println() System.out.println(threadInfo.toString()) // for (StackTraceElement ste in threadInfo.stackTrace) System.out.println(" ste " + ste.toString()) } } */ // this destroy order is important as some use others so must be destroyed first if (this.serviceFacade != null) this.serviceFacade.destroy() if (this.elasticFacade != null) this.elasticFacade.destroy() if (this.entityFacade != null) this.entityFacade.destroy() if (this.transactionFacade != null) this.transactionFacade.destroy() if (this.cacheFacade != null) this.cacheFacade.destroy() logger.info("Facades destroyed") System.out.println("Facades destroyed") for (ToolFactory tf in toolFactoryList) { try { tf.postFacadeDestroy() } catch (Throwable t) { logger.error("Error in post-facade destroy of ToolFactory ${tf.getName()}", t) } } activeContext.remove() // use System.out directly for this as logger may already be stopped System.out.println("Moqui ExecutionContextFactory Destroyed") } @Override boolean isDestroyed() { return destroyed } /** Trigger ECF destroy and re-init in another thread, after short wait */ void triggerDynamicReInit() { Thread.start("EcfiReInit", { sleep(2000) // wait 2 seconds Moqui.dynamicReInit(ExecutionContextFactoryImpl.class, internalServletContext) }) } @Override @Nonnull String getRuntimePath() { return runtimePath } @Override @Nonnull String getMoquiVersion() { return moquiVersion } Map getVersionMap() { return versionMap } MNode getConfXmlRoot() { return confXmlRoot } MNode getServerStatsNode() { return serverStatsNode } MNode getArtifactExecutionNode(String artifactTypeEnumId) { return confXmlRoot.first("artifact-execution-facade") .first({ MNode it -> it.name == "artifact-execution" && it.attribute("type") == artifactTypeEnumId }) } InetAddress getLocalhostAddress() { return localhostAddress } @Override void registerNotificationMessageListener(@Nonnull NotificationMessageListener nml) { nml.init(this) registeredNotificationMessageListeners.add(nml) } @Override void registerLogEventSubscriber(@Nonnull LogEventSubscriber subscriber) { logEventSubscribers.add(subscriber) } @Override List getLogEventSubscribers() { return Collections.unmodifiableList(logEventSubscribers) } /** Called by NotificationMessageImpl.send(), send to topic (possibly distributed) */ void sendNotificationMessageToTopic(NotificationMessageImpl nmi) { if (notificationMessageTopic != null) { // send it to the topic, this will call notifyNotificationMessageListeners(nmi) notificationMessageTopic.publish(nmi) // logger.warn("Sent nmi to distributed topic, topic=${nmi.topic}") } else { // run it locally notifyNotificationMessageListeners(nmi) } } /** This is called when message received from topic (possibly distributed) */ void notifyNotificationMessageListeners(NotificationMessageImpl nmi) { // process notifications in the worker thread pool ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(this, { int nmlSize = registeredNotificationMessageListeners.size() for (int i = 0; i < nmlSize; i++) { NotificationMessageListener nml = (NotificationMessageListener) registeredNotificationMessageListeners.get(i) nml.onMessage(nmi) } }) workerPool.execute(runnable) } NotificationWebSocketListener getNotificationWebSocketListener() { return notificationWebSocketListener } SecurityManager getSecurityManager() { if (internalSecurityManager != null) { return internalSecurityManager } // init Apache Shiro; NOTE: init must be done here so that ecfi will be fully initialized and in the static context BasicIniEnvironment env = new BasicIniEnvironment("classpath:shiro.ini"); internalSecurityManager = env.getSecurityManager() // NOTE: setting this statically just in case something uses it, but for Moqui we'll be getting the SecurityManager from the ecfi SecurityUtils.setSecurityManager(internalSecurityManager) return internalSecurityManager } CredentialsMatcher getCredentialsMatcher(String hashType, boolean isBase64) { HashedCredentialsMatcher hcm = new HashedCredentialsMatcher() if (hashType) { hcm.setHashAlgorithmName(hashType) } else { hcm.setHashAlgorithmName(getPasswordHashType()) } // in Shiro this defaults to true, which is the default unless UserAccount.passwordBase64 = 'Y' hcm.setStoredCredentialsHexEncoded(!isBase64) return hcm } // NOTE: may not be used static String getRandomSalt() { return StringUtilities.getRandomString(8) } String getPasswordHashType() { MNode passwordNode = confXmlRoot.first("user-facade").first("password") return passwordNode.attribute("encrypt-hash-type") ?: "SHA-256" } // NOTE: used in UserServices.xml String getSimpleHash(String source, String salt) { return getSimpleHash(source, salt, getPasswordHashType(), false) } String getSimpleHash(String source, String salt, String hashType, boolean isBase64) { SimpleHash simple = new SimpleHash(hashType ?: getPasswordHashType(), source, salt ?: '') return isBase64 ? simple.toBase64() : simple.toHex() } String getLoginKeyHashType() { MNode loginKeyNode = confXmlRoot.first("user-facade").first("login-key") return loginKeyNode.attribute("encrypt-hash-type") ?: "SHA-256" } float getLoginKeyExpireHours() { MNode loginKeyNode = confXmlRoot.first("user-facade").first("login-key") return (loginKeyNode.attribute("expire-hours") ?: "144") as float } // ==================================================== // ========== Main Interface Implementations ========== // ==================================================== @Override @Nonnull ExecutionContext getExecutionContext() { return getEci() } ExecutionContextImpl getEci() { // the ExecutionContextImpl cast here looks funny, but avoids Groovy using a slow castToType call ExecutionContextImpl ec = (ExecutionContextImpl) activeContext.get() if (ec != null) return ec Thread currentThread = Thread.currentThread() if (logger.traceEnabled) logger.trace("Creating new ExecutionContext in thread [${currentThread.id}:${currentThread.name}]") if (!currentThread.getContextClassLoader().is(moquiClassLoader)) currentThread.setContextClassLoader(moquiClassLoader) ec = new ExecutionContextImpl(this, currentThread) this.activeContext.set(ec) this.activeContextMap.put(currentThread.id, ec) return ec } void destroyActiveExecutionContext() { ExecutionContext ec = this.activeContext.get() if (ec != null) { ec.destroy() this.activeContext.remove() this.activeContextMap.remove(Thread.currentThread().id) } } /** Using an EC in multiple threads is dangerous as much of the ECI is not designed to be thread safe. */ void useExecutionContextInThread(ExecutionContextImpl eci) { ExecutionContextImpl curEc = activeContext.get() if (curEc != null) curEc.destroy() activeContext.set(eci) } @Override ToolFactory getToolFactory(@Nonnull String toolName) { ToolFactory toolFactory = (ToolFactory) toolFactoryMap.get(toolName) return toolFactory } @Override V getTool(@Nonnull String toolName, Class instanceClass, Object... parameters) { ToolFactory toolFactory = (ToolFactory) toolFactoryMap.get(toolName) if (toolFactory == null) throw new IllegalArgumentException("No ToolFactory found with name ${toolName}") return toolFactory.getInstance(parameters) } @Override @Nonnull LinkedHashMap getComponentBaseLocations() { LinkedHashMap compLocMap = new LinkedHashMap() for (ComponentInfo componentInfo in componentInfoMap.values()) compLocMap.put(componentInfo.name, componentInfo.location) return compLocMap } @Override @Nonnull L10nFacade getL10n() { getEci().l10nFacade } @Override @Nonnull ResourceFacade getResource() { resourceFacade } @Override @Nonnull LoggerFacade getLogger() { loggerFacade } @Override @Nonnull CacheFacade getCache() { cacheFacade } @Override @Nonnull TransactionFacade getTransaction() { transactionFacade } @Override @Nonnull EntityFacade getEntity() { entityFacade } @Override @Nonnull ElasticFacade getElastic() { elasticFacade } @Override @Nonnull ServiceFacade getService() { serviceFacade } @Override @Nonnull ScreenFacade getScreen() { screenFacade } @Override @Nonnull ClassLoader getClassLoader() { moquiClassLoader } @Override @Nonnull GroovyClassLoader getGroovyClassLoader() { groovyClassLoader } synchronized Class compileGroovy(String script, String className) { boolean hasClassName = className != null && !className.isEmpty() if (groovyCompileCacheToDisk && hasClassName) { // if the className already exists just return it try { Class existingClass = groovyClassLoader.loadClass(className) if (existingClass != null) return existingClass } catch (ClassNotFoundException e) { /* ignore */ } CompilationUnit compileUnit = new CompilationUnit(groovyCompilerConf, null, groovyClassLoader) compileUnit.addSource(className, script) compileUnit.compile() // just through Phases.CLASS_GENERATION? List compiledClasses = compileUnit.getClasses() if (compiledClasses.size() > 1) logger.warn("WARNING: compiled groovy class ${className} got ${compiledClasses.size()} classes") Class returnClass = null for (Object compiledClass in compiledClasses) { GroovyClass groovyClass = (GroovyClass) compiledClass String compiledName = groovyClass.getName() byte[] compiledBytes = groovyClass.getBytes() // NOTE: this is the same step we'd use when getting bytes from disk Class curClass = null try { curClass = groovyClassLoader.loadClass(compiledName) } catch (ClassNotFoundException e) { /* ignore */ } if (curClass == null) curClass = groovyClassLoader.defineClass(compiledName, compiledBytes) if (compiledName.equals(className)) { returnClass = curClass } else { logger.warn("Got compiled groovy class with name ${compiledName} not same as original class name ${className}") } } if (returnClass == null) logger.error("No errors in groovy compilation but got null Class for ${className}") return returnClass } else { // the simple approach, groovy compiles internally and don't save to disk/etc return hasClassName ? groovyClassLoader.parseClass(script, className) : groovyClassLoader.parseClass(script) } } @Override @Nonnull ServletContext getServletContext() { internalServletContext } @Override @Nonnull ServerContainer getServerContainer() { internalServerContainer } @Override void initServletContext(ServletContext sc) { internalServletContext = sc internalServerContainer = (ServerContainer) sc.getAttribute("jakarta.websocket.server.ServerContainer") } Map getStatusMap() { return getStatusMap(false) } Map getStatusMap(boolean includeSensitive) { def memoryMXBean = ManagementFactory.getMemoryMXBean() def heapMemoryUsage = memoryMXBean.getHeapMemoryUsage() def nonHeapMemoryUsage = memoryMXBean.getNonHeapMemoryUsage() def runtimeFile = new File(runtimePath) def osMXBean = ManagementFactory.getOperatingSystemMXBean() def runtimeMXBean = ManagementFactory.getRuntimeMXBean() def uptimeHours = runtimeMXBean.getUptime() / (1000*60*60) def startTimestamp = new Timestamp(runtimeMXBean.getStartTime()) def gcMXBeans = ManagementFactory.getGarbageCollectorMXBeans() def gcCount = 0 def gcTime = 0 for (gcMXBean in gcMXBeans) { gcCount += gcMXBean.getCollectionCount() gcTime += gcMXBean.getCollectionTime() } def jitMXBean = ManagementFactory.getCompilationMXBean() def classMXBean = ManagementFactory.getClassLoadingMXBean() def threadMXBean = ManagementFactory.getThreadMXBean() BigDecimal loadAvg = new BigDecimal(osMXBean.getSystemLoadAverage()).setScale(2, RoundingMode.HALF_UP) int processors = osMXBean.getAvailableProcessors() BigDecimal loadPercent = ((loadAvg / processors) * 100.0).setScale(2, RoundingMode.HALF_UP) long heapUsed = heapMemoryUsage.getUsed() long heapMax = heapMemoryUsage.getMax() BigDecimal heapPercent = ((heapUsed / heapMax) * 100.0).setScale(2, RoundingMode.HALF_UP) long diskFreeSpace = runtimeFile.getFreeSpace() long diskTotalSpace = runtimeFile.getTotalSpace() BigDecimal diskPercent = (((diskTotalSpace - diskFreeSpace) / diskTotalSpace) * 100.0).setScale(2, RoundingMode.HALF_UP) HttpServletRequest request = getEci().getWeb()?.getRequest() Map statusMap = [ // because security: MoquiFramework:moquiVersion, Utilization: [LoadPercent:loadPercent, HeapPercent:heapPercent, DiskPercent:diskPercent], Web: [ LocalAddr:request?.getLocalAddr(), LocalPort:request?.getLocalPort(), LocalName:request?.getLocalName(), ServerName:request?.getServerName(), ServerPort:request?.getServerPort() ], Heap: [ Used:(heapUsed/(1024*1024)).setScale(3, RoundingMode.HALF_UP), Committed:(heapMemoryUsage.getCommitted()/(1024*1024)).setScale(3, RoundingMode.HALF_UP), Max:(heapMax/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ], NonHeap: [ Used:(nonHeapMemoryUsage.getUsed()/(1024*1024)).setScale(3, RoundingMode.HALF_UP), Committed:(nonHeapMemoryUsage.getCommitted()/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ], Disk: [ Free:(diskFreeSpace/(1024*1024)).setScale(3, RoundingMode.HALF_UP), Usable:(runtimeFile.getUsableSpace()/(1024*1024)).setScale(3, RoundingMode.HALF_UP), Total:(diskTotalSpace/(1024*1024)).setScale(3, RoundingMode.HALF_UP) ], // trimmed because security: System: [ Load:loadAvg, Processors:processors, CPU:osMXBean.getArch(), OsName:osMXBean.getName(), OsVersion:osMXBean.getVersion() ], System: [ Load:loadAvg, Processors:processors ], // trimmed because security: JavaRuntime: [ SpecVersion:runtimeMXBean.getSpecVersion(), VmVendor:runtimeMXBean.getVmVendor(), VmVersion:runtimeMXBean.getVmVersion(), Start:startTimestamp, UptimeHours:uptimeHours ], JavaRuntime: [ Start:startTimestamp, UptimeHours:uptimeHours ], JavaStats: [ GcCount:gcCount, GcTimeSeconds:gcTime/1000, JIT:jitMXBean.getName(), CompileTimeSeconds:jitMXBean.getTotalCompilationTime()/1000, ClassesLoaded:classMXBean.getLoadedClassCount(), ClassesTotalLoaded:classMXBean.getTotalLoadedClassCount(), ClassesUnloaded:classMXBean.getUnloadedClassCount(), ThreadCount:threadMXBean.getThreadCount(), PeakThreadCount:threadMXBean.getPeakThreadCount() ] as Map // because security: DataSources: entityFacade.getDataSourcesInfo() ] as Map if (includeSensitive) { statusMap.MoquiFramework = moquiVersion statusMap.System = [Load:loadAvg, Processors:processors, CPU:osMXBean.getArch(), OsName:osMXBean.getName(), OsVersion:osMXBean.getVersion()] statusMap.JavaRuntime = [SpecVersion:runtimeMXBean.getSpecVersion(), VmVendor:runtimeMXBean.getVmVendor(), VmVersion:runtimeMXBean.getVmVersion(), Start:startTimestamp, UptimeHours:uptimeHours] statusMap.DataSources = entityFacade.getDataSourcesInfo() } return statusMap } // ========================================== // ========== Component Management ========== // ========================================== // called in System dashboard List> getComponentInfoList() { List> infoList = new ArrayList<>(componentInfoMap.size()) for (ComponentInfo ci in componentInfoMap.values()) infoList.add([name:ci.name, location:ci.location, version:ci.version, versionMap:ci.versionMap, dependsOnNames:ci.dependsOnNames] as Map) return infoList } protected void checkSortDependentComponents() { // we have an issue here where not all dependencies are declared, most are implied by component load order // because of this not doing a full topological sort, just a single pass with dependencies inserted as needed ArrayList sortedNames = new ArrayList<>() for (ComponentInfo componentInfo in componentInfoMap.values()) { // for each dependsOn make sure component is valid, add to the list if not already there // given a close starting sort order this should get us to a pretty good list for (String dependsOnName in componentInfo.getRecursiveDependencies()) if (!sortedNames.contains(dependsOnName)) sortedNames.add(dependsOnName) if (!sortedNames.contains(componentInfo.name)) sortedNames.add(componentInfo.name) } logger.info("Components after depends-on sort: ${sortedNames}") // see if all dependencies are met List messages = [] for (int i = 0; i < sortedNames.size(); i++) { String name = sortedNames.get(i) ComponentInfo componentInfo = componentInfoMap.get(name) for (String dependsOnName in componentInfo.dependsOnNames) { int dependsOnIndex = sortedNames.indexOf(dependsOnName) if (dependsOnIndex > i) messages.add("Broken dependency order after initial pass: [${dependsOnName}] is after [${name}]".toString()) } } if (messages) { StringBuilder sb = new StringBuilder() for (String message in messages) { logger.error(message) sb.append(message).append(" ") } throw new IllegalArgumentException(sb.toString()) } // now create a new Map and replace the original LinkedHashMap newMap = new LinkedHashMap() for (String sortedName in sortedNames) newMap.put(sortedName, componentInfoMap.get(sortedName)) componentInfoMap = newMap } protected void addComponent(ComponentInfo componentInfo) { if (componentInfoMap.containsKey(componentInfo.name)) logger.warn("Overriding component [${componentInfo.name}] at [${componentInfoMap.get(componentInfo.name).location}] with location [${componentInfo.location}] because another component of the same name was initialized") // components registered later override those registered earlier by replacing the Map entry componentInfoMap.put(componentInfo.name, componentInfo) logger.info("Added component ${componentInfo.name.padRight(18)} at ${componentInfo.location}") } protected void addComponentDir(String location) { ResourceReference componentRr = getResourceReference(location) // if directory doesn't exist skip it, runtime doesn't always have an component directory if (componentRr.getExists() && componentRr.isDirectory()) { // see if there is a components.xml file, if so load according to it instead of all sub-directories ResourceReference cxmlRr = getResourceReference(location + "/components.xml") if (cxmlRr.getExists()) { MNode componentList = MNode.parse(cxmlRr) for (MNode childNode in componentList.children) { if (childNode.name == 'component') { ComponentInfo componentInfo = new ComponentInfo(location, childNode, this) addComponent(componentInfo) } else if (childNode.name == 'component-dir') { String locAttr = childNode.attribute("location") addComponentDir(location + "/" + locAttr) } } } else { // get all files in the directory TreeMap componentDirEntries = new TreeMap() for (ResourceReference componentSubRr in componentRr.getDirectoryEntries()) { // if it's a directory and doesn't start with a "." then add it as a component dir String subRrName = componentSubRr.getFileName() if ((!componentSubRr.isDirectory() && !subRrName.endsWith(".zip")) || subRrName.startsWith(".")) continue componentDirEntries.put(componentSubRr.getFileName(), componentSubRr) } for (Map.Entry componentDirEntry in componentDirEntries.entrySet()) { String compName = componentDirEntry.value.getFileName() // skip zip files that already have a matching directory if (compName.endsWith(".zip")) { String compNameNoZip = stripVersionFromName(compName.substring(0, compName.length() - 4)) if (componentDirEntries.containsKey(compNameNoZip)) continue } ComponentInfo componentInfo = new ComponentInfo(componentDirEntry.value.location, this) this.addComponent(componentInfo) } } } } protected static String stripVersionFromName(String name) { int lastDash = name.lastIndexOf("-") if (lastDash > 0 && lastDash < name.length() - 2 && Character.isDigit(name.charAt(lastDash + 1))) { return name.substring(0, lastDash) } else { return name } } protected static ResourceReference getResourceReference(String location) { // NOTE: somehow support other resource location types? // the ResourceFacade inits after components are loaded (so it is aware of initial components), so we can't get ResourceReferences from it return new UrlResourceReference().init(location) } static class ComponentInfo { protected final static Logger logger = LoggerFactory.getLogger(ComponentInfo.class) ExecutionContextFactoryImpl ecfi String name, location, version Map versionMap = null ResourceReference componentRr Set dependsOnNames = new LinkedHashSet() ComponentInfo(String baseLocation, MNode componentNode, ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi String curLoc = null if (baseLocation) curLoc = baseLocation + "/" + componentNode.attribute("location") init(curLoc, componentNode) } ComponentInfo(String location, ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi init(location, null) } protected void init(String specLoc, MNode origNode) { location = specLoc ?: origNode?.attribute("location") if (!location) throw new IllegalArgumentException("Cannot init component with no location (not specified or found in component.@location)") // support component zip files, expand now and replace name and location if (location.endsWith(".zip")) { ResourceReference zipRr = getResourceReference(location) if (!zipRr.supportsExists()) throw new IllegalArgumentException("Could component location ${location} does not support exists, cannot use as a component location") // make sure corresponding directory does not exist String locNoZip = stripVersionFromName(location.substring(0, location.length() - 4)) ResourceReference noZipRr = getResourceReference(locNoZip) if (zipRr.getExists() && !noZipRr.getExists()) { // NOTE: could use getPath() instead of toExternalForm().substring(5) for file specific URLs, will work on Windows? String zipPath = zipRr.getUrl().toExternalForm().substring(5) File zipFile = new File(zipPath) String targetDirLocation = zipFile.getParent() logger.info("Expanding component archive ${zipRr.getFileName()} to ${targetDirLocation}") ZipInputStream zipIn = new ZipInputStream(zipRr.openStream()) try { ZipEntry entry = zipIn.getNextEntry() // iterates over entries in the zip file while (entry != null) { ResourceReference entryRr = getResourceReference(targetDirLocation + '/' + entry.getName()) String filePath = entryRr.getUrl().toExternalForm().substring(5) if (entry.isDirectory()) { File dir = new File(filePath) dir.mkdir() } else { OutputStream os = new FileOutputStream(filePath) ObjectUtilities.copyStream(zipIn, os) } zipIn.closeEntry() entry = zipIn.getNextEntry() } } finally { zipIn.close() } } // assumes zip contains a single directory named the same as the component name (without version) location = locNoZip } // clean up the location if (location.endsWith('/')) location = location.substring(0, location.length()-1) int lastSlashIndex = location.lastIndexOf('/') if (lastSlashIndex < 0) { // if this happens the component directory is directly under the runtime directory, so prefix loc with that location = ecfi.runtimePath + '/' + location lastSlashIndex = location.lastIndexOf('/') } // set the default component name, version name = location.substring(lastSlashIndex+1) version = "unknown" // make sure directory exists componentRr = getResourceReference(location) if (!componentRr.supportsExists()) throw new IllegalArgumentException("Could component location ${location} does not support exists, cannot use as a component location") if (!componentRr.getExists()) throw new IllegalArgumentException("Could not find component directory at: ${location}") if (!componentRr.isDirectory()) throw new IllegalArgumentException("Component location is not a directory: ${location}") // see if there is a component.xml file, if so use that as the componentNode instead of origNode ResourceReference compXmlRr = componentRr.getChild("component.xml") MNode componentNode = compXmlRr.exists ? MNode.parse(compXmlRr) : origNode if (componentNode != null) { String nameAttr = componentNode.attribute("name") if (nameAttr) name = nameAttr String versionAttr = componentNode.attribute("version") if (versionAttr) version = SystemBinding.expand(versionAttr) if (componentNode.hasChild("depends-on")) for (MNode dependsOnNode in componentNode.children("depends-on")) dependsOnNames.add(dependsOnNode.attribute("name")) } ResourceReference versionJsonRr = componentRr.getChild("version.json") if (versionJsonRr.exists) { try { versionMap = (Map) new JsonSlurper().parseText(versionJsonRr.getText()) } catch (Exception e) { logger.warn("Error parsing ${versionJsonRr.location}", e) } } } List getRecursiveDependencies() { List dependsOnList = [] for (String dependsOnName in dependsOnNames) { ComponentInfo depCompInfo = ecfi.componentInfoMap.get(dependsOnName) if (depCompInfo == null) throw new IllegalArgumentException("Component ${name} depends on component ${dependsOnName} which is not initialized; try running 'gradle getDepends'") List childDepList = depCompInfo.getRecursiveDependencies() for (String childDep in childDepList) if (!dependsOnList.contains(childDep)) dependsOnList.add(childDep) if (!dependsOnList.contains(dependsOnName)) dependsOnList.add(dependsOnName) } return dependsOnList } } /* @Deprecated void initComponent(String location) { ComponentInfo componentInfo = new ComponentInfo(location, this) // check dependencies if (componentInfo.dependsOnNames) for (String dependsOnName in componentInfo.dependsOnNames) { if (!componentInfoMap.containsKey(dependsOnName)) throw new IllegalArgumentException("Component [${componentInfo.name}] depends on component [${dependsOnName}] which is not initialized") } addComponent(componentInfo) } void destroyComponent(String componentName) throws BaseException { componentInfoMap.remove(componentName) } */ // ========================================== // ========== Server Stat Tracking ========== // ========================================== protected MNode getArtifactStatsNode(String artifactType, String artifactSubType) { // find artifact-stats node by type AND sub-type, if not found find by just the type MNode artifactStats = null if (artifactSubType != null) artifactStats = confXmlRoot.first("server-stats").first({ MNode it -> it.name == "artifact-stats" && it.attribute("type") == artifactType && it.attribute("sub-type") == artifactSubType }) if (artifactStats == null) artifactStats = confXmlRoot.first("server-stats") .first({ MNode it -> it.name == "artifact-stats" && it.attribute('type') == artifactType }) return artifactStats } protected final Set entitiesToSkipHitCount = new HashSet([ 'moqui.server.ArtifactHit', 'create#moqui.server.ArtifactHit', 'moqui.server.ArtifactHitBin', 'create#moqui.server.ArtifactHitBin', 'moqui.entity.SequenceValueItem', 'moqui.security.UserAccount', 'moqui.entity.document.DataDocument', 'moqui.entity.document.DataDocumentField', 'moqui.entity.document.DataDocumentCondition', 'moqui.entity.feed.DataFeedAndDocument', 'moqui.entity.view.DbViewEntity', 'moqui.entity.view.DbViewEntityMember', 'moqui.entity.view.DbViewEntityKeyMap', 'moqui.entity.view.DbViewEntityAlias']) void countArtifactHit(ArtifactType artifactTypeEnum, String artifactSubType, String artifactName, Map parameters, long startTime, double runningTimeMillis, Long outputSize) { boolean isEntity = ArtifactExecutionInfo.AT_ENTITY.is(artifactTypeEnum) || (artifactSubType != null && artifactSubType.startsWith('entity')) // don't count the ones this calls if (isEntity && entitiesToSkipHitCount.contains(artifactName)) return // for screen, transition, screen-content check skip stats expression if (!isEntity && (ArtifactExecutionInfo.AT_XML_SCREEN.is(artifactTypeEnum) || ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT.is(artifactTypeEnum) || ArtifactExecutionInfo.AT_XML_SCREEN_TRANS.is(artifactTypeEnum)) && eci.getSkipStats()) return boolean isSlowHit = false if (Boolean.TRUE.is((Boolean) artifactPersistBinByTypeEnum.get(artifactTypeEnum))) { // NOTE: not adding artifactTypeEnum.name() to key, artifact names should be unique String binKey = artifactName // TODO: may be more cases where we don't need to append artifactTypeEnum, ie based on artifactName if (artifactSubType != null && !ArtifactExecutionInfo.AT_SERVICE.is(artifactTypeEnum)) binKey = binKey.concat(artifactSubType) ArtifactStatsInfo statsInfo = (ArtifactStatsInfo) artifactStatsInfoByType.get(binKey) if (statsInfo == null) { // consider seeding this from the DB using ArtifactHitReport to get all past data, or maybe not to better handle different servers/etc over time, etc statsInfo = new ArtifactStatsInfo(artifactTypeEnum, artifactSubType, artifactName) artifactStatsInfoByType.put(binKey, statsInfo) } // has the current bin expired since the last hit record? if (statsInfo.curHitBin != null) { long binStartTime = statsInfo.curHitBin.startTime if (startTime > (binStartTime + hitBinLengthMillis)) { if (isTraceEnabled) logger.trace("Advancing ArtifactHitBin [${artifactTypeEnum.name()}.${artifactSubType}:${artifactName}] current hit start [${new Timestamp(startTime)}], bin start [${new Timestamp(binStartTime)}] bin length ${hitBinLengthMillis/1000} seconds") advanceArtifactHitBin(getEci(), statsInfo, startTime, hitBinLengthMillis) } } // handle stats since start isSlowHit = statsInfo.countHit(startTime, runningTimeMillis) } // NOTE: never save individual hits for entity artifact hits, way too heavy and also avoids self-reference // (could also be done by checking for ArtifactHit/etc of course) // Always save slow hits above userImpactMinMillis regardless of settings if (!isEntity && ((isSlowHit && runningTimeMillis > ContextJavaUtil.userImpactMinMillis) || Boolean.TRUE.is((Boolean) artifactPersistHitByTypeEnum.get(artifactTypeEnum)))) { ExecutionContextImpl eci = getEci() ArtifactHitInfo ahi = new ArtifactHitInfo(eci, isSlowHit, artifactTypeEnum, artifactSubType, artifactName, startTime, runningTimeMillis, parameters, outputSize) deferredHitInfoQueue.add(ahi) } } static class DeferredHitInfoFlush implements Runnable { protected final static Logger logger = LoggerFactory.getLogger(DeferredHitInfoFlush.class) // max creates per chunk, one transaction per chunk (unless error) final static int maxCreates = 1000 final ExecutionContextFactoryImpl ecfi DeferredHitInfoFlush(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi } @Override synchronized void run() { ExecutionContextImpl eci = ecfi.getEci() eci.artifactExecutionFacade.disableAuthz() try { try { ConcurrentLinkedQueue queue = ecfi.deferredHitInfoQueue // split into maxCreates chunks, repeat based on initial size (may be added to while running) int remainingCreates = queue.size() // if (remainingCreates > maxCreates) logger.warn("Deferred ArtifactHit create queue size ${remainingCreates} is greater than max creates per chunk ${maxCreates}") // logger.info("Flushing ArtifactHit queue, size " + queue.size()) while (remainingCreates > 0) { flushQueue(queue) remainingCreates -= maxCreates // logger.info("Flush ArtifactHit queue pass complete, queue size ${queue.size()} remainingCreates ${remainingCreates}") } } catch (Throwable t) { logger.error("Error saving ArtifactHits", t) } } finally { // no need, we're destroying the eci: if (!authzDisabled) eci.artifactExecution.enableAuthz() eci.destroy() } } void flushQueue(ConcurrentLinkedQueue queue) { ExecutionContextFactoryImpl localEcfi = ecfi ArrayList createList = new ArrayList<>(maxCreates) int createCount = 0 while (createCount < maxCreates) { ArtifactHitInfo ahi = queue.poll() if (ahi == null) break createCount++ createList.add(ahi) } int retryCount = 5 while (retryCount > 0) { try { int createListSize = createList.size() if (createListSize == 0) break long startTime = System.currentTimeMillis() ecfi.transactionFacade.runUseOrBegin(60, "Error saving ArtifactHits", { List evList = new ArrayList<>(createListSize) for (int i = 0; i < createListSize; i++) { ArtifactHitInfo ahi = (ArtifactHitInfo) createList.get(i) EntityValue ahValue = ahi.makeAhiValue(localEcfi) ahValue.setSequencedIdPrimary() evList.add(ahValue) // old approach, create call per record, too slow when ArtifactHitBin in the logging group for ElasticFacade // try { ahValue.create() } catch (Throwable t) { createList.remove(i); throw t } } // new approach, use new EntityFacade.createBulk() method localEcfi.entityFacade.createBulk(evList) }) if (isTraceEnabled) logger.trace("Created ${createListSize} ArtifactHit records in ${System.currentTimeMillis() - startTime}ms") break } catch (Throwable t) { logger.error("Error saving ArtifactHits, retrying (${retryCount})", t) retryCount-- } } } } protected synchronized void advanceArtifactHitBin(ExecutionContextImpl eci, ArtifactStatsInfo statsInfo, long startTime, long hitBinLengthMillis) { ArtifactBinInfo abi = statsInfo.curHitBin if (abi == null) { statsInfo.curHitBin = new ArtifactBinInfo(statsInfo, startTime) return } // check the time again and return just in case something got in while waiting with the same type long binStartTime = abi.startTime if (startTime < (binStartTime + hitBinLengthMillis)) return // otherwise, persist the old and create a new one EntityValue ahb = abi.makeAhbValue(this, new Timestamp(binStartTime + hitBinLengthMillis)) eci.runInWorkerThread({ ArtifactExecutionFacadeImpl aefi = getEci().artifactExecutionFacade boolean enableAuthz = !aefi.disableAuthz() try { ahb.setSequencedIdPrimary().create() } finally { if (enableAuthz) aefi.enableAuthz() } }) statsInfo.curHitBin = new ArtifactBinInfo(statsInfo, startTime) } // ======================================================== // ========== Configuration File Merging Methods ========== // ======================================================== protected static void mergeConfigNodes(MNode baseNode, MNode overrideNode) { baseNode.mergeChildrenByKey(overrideNode, "default-property", "name", null) baseNode.mergeChildWithChildKey(overrideNode, "tools", "tool-factory", "class", null) baseNode.mergeChildWithChildKey(overrideNode, "cache-list", "cache", "name", null) if (overrideNode.hasChild("server-stats")) { // the artifact-stats nodes have 2 keys: type, sub-type; can't use the normal method MNode ssNode = baseNode.first("server-stats") MNode overrideSsNode = overrideNode.first("server-stats") // override attributes for this node ssNode.attributes.putAll(overrideSsNode.attributes) for (MNode childOverrideNode in overrideSsNode.children("artifact-stats")) { String type = childOverrideNode.attribute("type") String subType = childOverrideNode.attribute("sub-type") MNode childBaseNode = ssNode.first({ MNode it -> it.name == "artifact-stats" && it.attribute("type") == type && (it.attribute("sub-type") == subType || (!it.attribute("sub-type") && !subType)) }) if (childBaseNode) { // merge the node attributes childBaseNode.attributes.putAll(childOverrideNode.attributes) } else { // no matching child base node, so add a new one ssNode.append(childOverrideNode) } } } baseNode.mergeChildWithChildKey(overrideNode, "webapp-list", "webapp", "name", { MNode childBaseNode, MNode childOverrideNode -> mergeWebappChildNodes(childBaseNode, childOverrideNode) }) baseNode.mergeChildWithChildKey(overrideNode, "artifact-execution-facade", "artifact-execution", "type", null) if (overrideNode.hasChild("user-facade")) { MNode ufBaseNode = baseNode.first("user-facade") MNode ufOverrideNode = overrideNode.first("user-facade") ufBaseNode.mergeSingleChild(ufOverrideNode, "password") ufBaseNode.mergeSingleChild(ufOverrideNode, "login-key") ufBaseNode.mergeSingleChild(ufOverrideNode, "login") } if (overrideNode.hasChild("transaction-facade")) { MNode tfBaseNode = baseNode.first("transaction-facade") MNode tfOverrideNode = overrideNode.first("transaction-facade") tfBaseNode.attributes.putAll(tfOverrideNode.attributes) tfBaseNode.mergeSingleChild(tfOverrideNode, "server-jndi") tfBaseNode.mergeSingleChild(tfOverrideNode, "transaction-jndi") tfBaseNode.mergeSingleChild(tfOverrideNode, "transaction-internal") } if (overrideNode.hasChild("resource-facade")) { baseNode.mergeChildWithChildKey(overrideNode, "resource-facade", "resource-reference", "scheme", null) baseNode.mergeChildWithChildKey(overrideNode, "resource-facade", "template-renderer", "extension", null) baseNode.mergeChildWithChildKey(overrideNode, "resource-facade", "script-runner", "extension", null) } if (overrideNode.hasChild("screen-facade")) { baseNode.mergeChildWithChildKey(overrideNode, "screen-facade", "screen-text-output", "type", null) baseNode.mergeChildWithChildKey(overrideNode, "screen-facade", "screen-output", "type", null) baseNode.mergeChildWithChildKey(overrideNode, "screen-facade", "screen", "location", { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, "subscreens-item", "name", null) }) } if (overrideNode.hasChild("service-facade")) { MNode sfBaseNode = baseNode.first("service-facade") MNode sfOverrideNode = overrideNode.first("service-facade") sfBaseNode.mergeNodeWithChildKey(sfOverrideNode, "service-location", "name", null) sfBaseNode.mergeChildrenByKey(sfOverrideNode, "service-type", "name", null) sfBaseNode.mergeChildrenByKey(sfOverrideNode, "service-file", "location", null) sfBaseNode.mergeChildrenByKey(sfOverrideNode, "startup-service", "name", null) // handle thread-pool MNode tpOverrideNode = sfOverrideNode.first("thread-pool") if (tpOverrideNode) { MNode tpBaseNode = sfBaseNode.first("thread-pool") if (tpBaseNode) { tpBaseNode.mergeNodeWithChildKey(tpOverrideNode, "run-from-pool", "name", null) } else { sfBaseNode.append(tpOverrideNode) } } // handle jms-service, just copy all over for (MNode jsOverrideNode in sfOverrideNode.children("jms-service")) { sfBaseNode.append(jsOverrideNode) } } if (overrideNode.hasChild("elastic-facade")) { MNode efBaseNode = baseNode.first("elastic-facade") MNode efOverrideNode = overrideNode.first("elastic-facade") efBaseNode.mergeChildrenByKey(efOverrideNode, "cluster", "name", null) } if (overrideNode.hasChild("entity-facade")) { MNode efBaseNode = baseNode.first("entity-facade") MNode efOverrideNode = overrideNode.first("entity-facade") efBaseNode.mergeNodeWithChildKey(efOverrideNode, "datasource", "group-name", { MNode childBaseNode, MNode childOverrideNode -> // handle the jndi-jdbc and inline-jdbc nodes: if either exist in override have it totally remove both from base, then copy over if (childOverrideNode.hasChild("jndi-jdbc") || childOverrideNode.hasChild("inline-jdbc")) { childBaseNode.remove("jndi-jdbc") childBaseNode.remove("inline-jdbc") if (childOverrideNode.hasChild("inline-jdbc")) { childBaseNode.append(childOverrideNode.first("inline-jdbc")) } else if (childOverrideNode.hasChild("jndi-jdbc")) { childBaseNode.append(childOverrideNode.first("jndi-jdbc")) } } }) efBaseNode.mergeSingleChild(efOverrideNode, "server-jndi") // for load-entity and load-data just copy over override nodes for (MNode copyNode in efOverrideNode.children("load-entity")) efBaseNode.append(copyNode) for (MNode copyNode in efOverrideNode.children("load-data")) efBaseNode.append(copyNode) } if (overrideNode.hasChild("database-list")) { baseNode.mergeChildWithChildKey(overrideNode, "database-list", "dictionary-type", "type", null) // handle database-list -> database, database -> database-type@type baseNode.mergeChildWithChildKey(overrideNode, "database-list", "database", "name", { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeNodeWithChildKey(childOverrideNode, "database-type", "type", null) }) } baseNode.mergeChildWithChildKey(overrideNode, "repository-list", "repository", "name", { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, "init-param", "name", null) }) // NOTE: don't merge component-list node, done separately (for runtime config only, and before component config merges) } protected static void mergeConfigComponentNodes(MNode baseNode, MNode overrideNode) { if (overrideNode.hasChild("component-list")) { if (!baseNode.hasChild("component-list")) baseNode.append("component-list", null) MNode baseComponentNode = baseNode.first("component-list") for (MNode copyNode in overrideNode.first("component-list").children) baseComponentNode.append(copyNode) } } protected static void mergeWebappChildNodes(MNode baseNode, MNode overrideNode) { baseNode.mergeChildrenByKey(overrideNode, "root-screen", "host", null) baseNode.mergeChildrenByKey(overrideNode, "error-screen", "error", null) // handle webapp -> first-hit-in-visit[1], after-request[1], before-request[1], after-login[1], before-logout[1] mergeWebappActions(baseNode, overrideNode, "first-hit-in-visit") mergeWebappActions(baseNode, overrideNode, "after-request") mergeWebappActions(baseNode, overrideNode, "before-request") mergeWebappActions(baseNode, overrideNode, "after-login") mergeWebappActions(baseNode, overrideNode, "before-logout") mergeWebappActions(baseNode, overrideNode, "after-startup") mergeWebappActions(baseNode, overrideNode, "before-shutdown") baseNode.mergeChildrenByKey(overrideNode, "filter", "name", { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, "init-param", "name", null) for (MNode upNode in overrideNode.children("url-pattern")) childBaseNode.append(upNode.deepCopy(null)) for (MNode upNode in overrideNode.children("dispatcher")) childBaseNode.append(upNode.deepCopy(null)) }) baseNode.mergeChildrenByKey(overrideNode, "listener", "class", null) baseNode.mergeChildrenByKey(overrideNode, "servlet", "name", { MNode childBaseNode, MNode childOverrideNode -> childBaseNode.mergeChildrenByKey(childOverrideNode, "init-param", "name", null) for (MNode upNode in overrideNode.children("url-pattern")) childBaseNode.append(upNode.deepCopy(null)) }) baseNode.mergeSingleChild(overrideNode, "session-config") baseNode.mergeChildrenByKey(overrideNode, "endpoint", "path", null) baseNode.mergeChildrenByKeys(overrideNode, "response-header", null, "type", "name") } protected static void mergeWebappActions(MNode baseWebappNode, MNode overrideWebappNode, String childNodeName) { List overrideActionNodes = overrideWebappNode.first(childNodeName)?.first("actions")?.children if (overrideActionNodes) { MNode childNode = baseWebappNode.first(childNodeName) if (childNode == null) childNode = baseWebappNode.append(childNodeName, null) MNode actionsNode = childNode.first("actions") if (actionsNode == null) actionsNode = childNode.append("actions", null) for (MNode overrideActionNode in overrideActionNodes) actionsNode.append(overrideActionNode) } } MNode getWebappNode(String webappName) { return confXmlRoot.first("webapp-list") .first({ MNode it -> it.name == "webapp" && it.attribute("name") == webappName }) } WebappInfo getWebappInfo(String webappName) { WebappInfo wi = webappInfoMap.get(webappName) if (wi != null) return wi return makeWebappInfo(webappName) } protected synchronized WebappInfo makeWebappInfo(String webappName) { if (webappName == null || webappName.isEmpty()) return null WebappInfo wi = new WebappInfo(webappName, this) webappInfoMap.put(webappName, wi) return wi } static class WebappInfo { protected final static Logger logger = LoggerFactory.getLogger(WebappInfo.class) String webappName MNode webappNode XmlAction firstHitInVisitActions = null XmlAction beforeRequestActions = null XmlAction afterRequestActions = null XmlAction afterLoginActions = null XmlAction beforeLogoutActions = null XmlAction afterStartupActions = null XmlAction beforeShutdownActions = null ArrayList responseHeaderList Set allowOriginSet = new HashSet<>() Integer sessionTimeoutSeconds = null String httpPort, httpHost, httpsPort, httpsHost boolean httpsEnabled boolean requireSessionToken String clientIpHeader WebappInfo(String webappName, ExecutionContextFactoryImpl ecfi) { this.webappName = webappName webappNode = ecfi.confXmlRoot.first("webapp-list").first({ MNode it -> it.name == "webapp" && it.attribute("name") == webappName }) if (webappNode == null) throw new BaseException("Could not find webapp element for name ${webappName}") webappNode.setSystemExpandAttributes(true) httpPort = webappNode.attribute("http-port") ?: null httpHost = webappNode.attribute("http-host") ?: null httpsPort = webappNode.attribute("https-port") ?: null httpsHost = webappNode.attribute("https-host") ?: httpHost ?: null httpsEnabled = "true".equals(webappNode.attribute("https-enabled")) requireSessionToken = !"false".equals(webappNode.attribute("require-session-token")) clientIpHeader = webappNode.attribute("client-ip-header") String allowOrigins = webappNode.attribute("allow-origins") if (allowOrigins) for (String origin in allowOrigins.split(",")) allowOriginSet.add(origin.trim().toLowerCase()) logger.info("Initializing webapp ${webappName} http://${httpHost}:${httpPort} https://${httpsHost}:${httpsPort} https enabled? ${httpsEnabled}") // prep actions if (webappNode.hasChild("first-hit-in-visit")) firstHitInVisitActions = new XmlAction(ecfi, webappNode.first("first-hit-in-visit").first("actions"), "webapp_${webappName}.first_hit_in_visit.actions") if (webappNode.hasChild("before-request")) beforeRequestActions = new XmlAction(ecfi, webappNode.first("before-request").first("actions"), "webapp_${webappName}.before_request.actions") if (webappNode.hasChild("after-request")) afterRequestActions = new XmlAction(ecfi, webappNode.first("after-request").first("actions"), "webapp_${webappName}.after_request.actions") if (webappNode.hasChild("after-login")) afterLoginActions = new XmlAction(ecfi, webappNode.first("after-login").first("actions"), "webapp_${webappName}.after_login.actions") if (webappNode.hasChild("before-logout")) beforeLogoutActions = new XmlAction(ecfi, webappNode.first("before-logout").first("actions"), "webapp_${webappName}.before_logout.actions") if (webappNode.hasChild("after-startup")) afterStartupActions = new XmlAction(ecfi, webappNode.first("after-startup").first("actions"), "webapp_${webappName}.after_startup.actions") if (webappNode.hasChild("before-shutdown")) beforeShutdownActions = new XmlAction(ecfi, webappNode.first("before-shutdown").first("actions"), "webapp_${webappName}.before_shutdown.actions") responseHeaderList = webappNode.children("response-header") MNode sessionConfigNode = webappNode.first("session-config") if (sessionConfigNode != null && sessionConfigNode.attribute("timeout")) { sessionTimeoutSeconds = (sessionConfigNode.attribute("timeout") as int) * 60 } } MNode getErrorScreenNode(String error) { return webappNode.first({ MNode it -> it.name == "error-screen" && it.attribute("error") == error }) } void addHeaders(String type, HttpServletResponse response) { if (type == null || response == null) return int responseHeaderListSize = responseHeaderList.size() for (int i = 0; i < responseHeaderListSize; i++) { MNode responseHeader = (MNode) responseHeaderList.get(i) if (!type.equals(responseHeader.attribute("type"))) continue String headerValue = responseHeader.attribute("value") if (headerValue == null || headerValue.isEmpty()) continue if ("true".equals(responseHeader.attribute("add"))) { response.addHeader(responseHeader.attribute("name"), headerValue) } else { response.setHeader(responseHeader.attribute("name"), headerValue) } // logger.warn("Added header ${responseHeader.attribute("name")} value ${headerValue} type ${type}") } } } @Override String toString() { return "ExecutionContextFactory " + moquiVersion } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ExecutionContextImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context; import groovy.lang.Closure; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.util.ArrayList; import java.util.HashMap; import java.util.List; import java.util.Map; import java.util.concurrent.Future; import javax.annotation.Nonnull; import javax.annotation.Nullable; import javax.cache.Cache; import org.moqui.context.*; import org.moqui.entity.EntityFacade; import org.moqui.entity.EntityFind; import org.moqui.entity.EntityList; import org.moqui.entity.EntityValue; import org.moqui.impl.entity.EntityFacadeImpl; import org.moqui.impl.screen.ScreenFacadeImpl; import org.moqui.impl.service.ServiceFacadeImpl; import org.moqui.screen.ScreenFacade; import org.moqui.service.ServiceFacade; import org.moqui.util.ContextBinding; import org.moqui.util.ContextStack; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.slf4j.MDC; public class ExecutionContextImpl implements ExecutionContext { private static final Logger loggerDirect = LoggerFactory.getLogger(ExecutionContextFactoryImpl.class); public final ExecutionContextFactoryImpl ecfi; public final ContextStack contextStack = new ContextStack(); public final ContextBinding contextBindingInternal = new ContextBinding(contextStack); private EntityFacadeImpl activeEntityFacade; private WebFacade webFacade = (WebFacade) null; private WebFacadeImpl webFacadeImpl = (WebFacadeImpl) null; public final UserFacadeImpl userFacade; public final MessageFacadeImpl messageFacade; public final ArtifactExecutionFacadeImpl artifactExecutionFacade; public final L10nFacadeImpl l10nFacade; // local references to ECFI fields public final CacheFacadeImpl cacheFacade; public final LoggerFacadeImpl loggerFacade; public final ResourceFacadeImpl resourceFacade; public final ScreenFacadeImpl screenFacade; public final ServiceFacadeImpl serviceFacade; public final TransactionFacadeImpl transactionFacade; private Boolean skipStats = null; private Cache l10nMessageCache; private Cache tarpitHitCache; public String forThreadName; public long forThreadId; // public final Exception createLoc; public ExecutionContextImpl(ExecutionContextFactoryImpl ecfi, Thread forThread) { this.ecfi = ecfi; // NOTE: no WebFacade init here, wait for call in to do that // put reference to this in the context root contextStack.put("ec", this); forThreadName = forThread.getName(); forThreadId = forThread.threadId(); // createLoc = new BaseException("ec create"); activeEntityFacade = ecfi.entityFacade; userFacade = new UserFacadeImpl(this); messageFacade = new MessageFacadeImpl(); artifactExecutionFacade = new ArtifactExecutionFacadeImpl(this); l10nFacade = new L10nFacadeImpl(this); cacheFacade = ecfi.cacheFacade; loggerFacade = ecfi.loggerFacade; resourceFacade = ecfi.resourceFacade; screenFacade = ecfi.screenFacade; serviceFacade = ecfi.serviceFacade; transactionFacade = ecfi.transactionFacade; if (cacheFacade == null) throw new IllegalStateException("cacheFacade was null"); if (loggerFacade == null) throw new IllegalStateException("loggerFacade was null"); if (resourceFacade == null) throw new IllegalStateException("resourceFacade was null"); if (screenFacade == null) throw new IllegalStateException("screenFacade was null"); if (serviceFacade == null) throw new IllegalStateException("serviceFacade was null"); if (transactionFacade == null) throw new IllegalStateException("transactionFacade was null"); initCaches(); if (loggerDirect.isTraceEnabled()) loggerDirect.trace("ExecutionContextImpl initialized"); } @SuppressWarnings("unchecked") private void initCaches() { tarpitHitCache = cacheFacade.getCache("artifact.tarpit.hits"); l10nMessageCache = cacheFacade.getCache("l10n.message"); } Cache getL10nMessageCache() { return l10nMessageCache; } public Cache getTarpitHitCache() { return tarpitHitCache; } @Override public @Nonnull ExecutionContextFactory getFactory() { return ecfi; } @Override public @Nonnull ContextStack getContext() { return contextStack; } @Override public @Nonnull Map getContextRoot() { return contextStack.getRootMap(); } @Override public @Nonnull ContextBinding getContextBinding() { return contextBindingInternal; } @Override public V getTool(@Nonnull String toolName, Class instanceClass, Object... parameters) { return ecfi.getTool(toolName, instanceClass, parameters); } @Override public @Nullable WebFacade getWeb() { return webFacade; } public @Nullable WebFacadeImpl getWebImpl() { return webFacadeImpl; } @Override public @Nonnull UserFacade getUser() { return userFacade; } @Override public @Nonnull MessageFacade getMessage() { return messageFacade; } @Override public @Nonnull ArtifactExecutionFacade getArtifactExecution() { return artifactExecutionFacade; } @Override public @Nonnull L10nFacade getL10n() { return l10nFacade; } @Override public @Nonnull ResourceFacade getResource() { return resourceFacade; } @Override public @Nonnull LoggerFacade getLogger() { return loggerFacade; } @Override public @Nonnull CacheFacade getCache() { return cacheFacade; } @Override public @Nonnull TransactionFacade getTransaction() { return transactionFacade; } @Override public @Nonnull EntityFacade getEntity() { return activeEntityFacade; } public @Nonnull EntityFacadeImpl getEntityFacade() { return activeEntityFacade; } @Override public @Nonnull ElasticFacade getElastic() { return ecfi.elasticFacade; } @Override public @Nonnull ServiceFacade getService() { return serviceFacade; } @Override public @Nonnull ScreenFacade getScreen() { return screenFacade; } @Override public @Nonnull NotificationMessage makeNotificationMessage() { return new NotificationMessageImpl(ecfi); } @Override public @Nonnull List getNotificationMessages(@Nullable String topic) { String userId = userFacade.getUserId(); if (userId == null || userId.isEmpty()) return new ArrayList<>(); List nmList = new ArrayList<>(); boolean alreadyDisabled = artifactExecutionFacade.disableAuthz(); try { EntityFind nmbuFind = activeEntityFacade.find("moqui.security.user.NotificationMessageByUser").condition("userId", userId); if (topic != null && !topic.isEmpty()) nmbuFind.condition("topic", topic); EntityList nmbuList = nmbuFind.list(); for (EntityValue nmbu : nmbuList) { NotificationMessageImpl nmi = new NotificationMessageImpl(ecfi); nmi.populateFromValue(nmbu); nmList.add(nmi); } } finally { if (!alreadyDisabled) artifactExecutionFacade.enableAuthz(); } return nmList; } @Override public void initWebFacade(@Nonnull String webappMoquiName, @Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response) { WebFacadeImpl wfi = new WebFacadeImpl(webappMoquiName, request, response, this); webFacade = wfi; webFacadeImpl = wfi; // now that we have the webFacade in place we can do init UserFacade userFacade.initFromHttpRequest(request, response); // for convenience (and more consistent code in screen actions, services, etc) add all requestParameters to the context contextStack.putAll(webFacadeImpl.getRequestParameters()); // this is the beginning of a request, so trigger before-request actions wfi.runBeforeRequestActions(); String userId = userFacade.getUserId(); if (userId != null && !userId.isEmpty()) MDC.put("moqui_userId", userId); String visitorId = userFacade.getVisitorId(); if (visitorId != null && !visitorId.isEmpty()) MDC.put("moqui_visitorId", visitorId); if (loggerDirect.isTraceEnabled()) loggerDirect.trace("ExecutionContextImpl WebFacade initialized"); } /** Meant to be used to set a test stub that implements the WebFacade interface */ public void setWebFacade(WebFacade wf) { webFacade = wf; if (wf instanceof WebFacadeImpl) webFacadeImpl = (WebFacadeImpl) wf; contextStack.putAll(webFacade.getRequestParameters()); } public boolean getSkipStats() { if (skipStats != null) return skipStats; String skipStatsCond = ecfi.skipStatsCond; Map skipParms = new HashMap<>(); if (webFacade != null) skipParms.put("pathInfo", webFacade.getPathInfo()); skipStats = (skipStatsCond != null && !skipStatsCond.isEmpty()) && ecfi.resourceFacade.condition(skipStatsCond, null, skipParms); return skipStats; } @Override public Future runAsync(@Nonnull Closure closure) { ThreadPoolRunnable runnable = new ThreadPoolRunnable(this, closure); return ecfi.workerPool.submit(runnable); } /** Uses the ECFI constructor for ThreadPoolRunnable so does NOT use the current ECI in the separate thread */ public Future runInWorkerThread(@Nonnull Closure closure) { ThreadPoolRunnable runnable = new ThreadPoolRunnable(ecfi, closure); return ecfi.workerPool.submit(runnable); } @Override public void destroy() { // if webFacade exists this is the end of a request, so trigger after-request actions if (webFacadeImpl != null) webFacadeImpl.runAfterRequestActions(); // make sure there are no transactions open, if any commit them all now ecfi.transactionFacade.destroyAllInThread(); // clean up resources, like JCR session ecfi.resourceFacade.destroyAllInThread(); // clear out the ECFI's reference to this as well ecfi.activeContext.remove(); ecfi.activeContextMap.remove(Thread.currentThread().threadId()); MDC.remove("moqui_userId"); MDC.remove("moqui_visitorId"); if (loggerDirect.isTraceEnabled()) loggerDirect.trace("ExecutionContextImpl destroyed"); } @Override public String toString() { return "ExecutionContext"; } public static class ThreadPoolRunnable implements Runnable { private ExecutionContextImpl threadEci; private ExecutionContextFactoryImpl ecfi; private Closure closure; /** With this constructor (passing ECI) the ECI is used in the separate thread */ public ThreadPoolRunnable(ExecutionContextImpl eci, Closure closure) { threadEci = eci; ecfi = eci.ecfi; this.closure = closure; } /** With this constructor (passing ECFI) a new ECI is created for the separate thread */ public ThreadPoolRunnable(ExecutionContextFactoryImpl ecfi, Closure closure) { this.ecfi = ecfi; threadEci = null; this.closure = closure; } @Override public void run() { if (threadEci != null) { // ecfi.useExecutionContextInThread(threadEci); ExecutionContextImpl eci = ecfi.getEci(); String threadUsername = threadEci.userFacade.getUsername(); if (threadUsername != null && !threadUsername.isEmpty()) eci.userFacade.internalLoginUser(threadUsername, false); if (threadEci.artifactExecutionFacade.authzDisabled) eci.artifactExecutionFacade.disableAuthz(); } try { closure.call(); } catch (Throwable t) { loggerDirect.error("Error in EC worker Runnable", t); } finally { // now using separate ECI in thread so always destroy, ie don't do: if (threadEci == null) ecfi.destroyActiveExecutionContext(); } } public ExecutionContextFactoryImpl getEcfi() { return ecfi; } public void setEcfi(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; } public Closure getClosure() { return closure; } public void setClosure(Closure closure) { this.closure = closure; } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/L10nFacadeImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context; import org.moqui.BaseArtifactException; import org.moqui.context.L10nFacade; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityValue; import org.moqui.entity.EntityFind; import groovy.json.JsonOutput; import jakarta.xml.bind.DatatypeConverter; import java.math.BigDecimal; import java.math.RoundingMode; import java.text.DecimalFormat; import java.text.DecimalFormatSymbols; import java.text.NumberFormat; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; import java.util.*; import org.apache.commons.validator.routines.BigDecimalValidator; import org.apache.commons.validator.routines.CalendarValidator; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class L10nFacadeImpl implements L10nFacade { protected final static Logger logger = LoggerFactory.getLogger(L10nFacadeImpl.class); final static BigDecimalValidator bigDecimalValidator = new BigDecimalValidator(false); final static CalendarValidator calendarValidator = new CalendarValidator(); protected final ExecutionContextImpl eci; public L10nFacadeImpl(ExecutionContextImpl eci) { this.eci = eci; } protected Locale getLocale() { return eci.userFacade.getLocale(); } protected TimeZone getTimeZone() { return eci.userFacade.getTimeZone(); } @Override public String localize(String original) { return localize(original, getLocale()); } @Override public String localize(String original, Locale locale) { if (original == null) return ""; int originalLength = original.length(); if (originalLength == 0) return ""; if (originalLength > 255) { throw new BaseArtifactException("Original String cannot be more than 255 characters long, passed in string was " + originalLength + " characters long"); } if (locale == null) locale = getLocale(); String localeString = locale.toString(); String cacheKey = original.concat("::").concat(localeString); String lmsg = eci.getL10nMessageCache().get(cacheKey); if (lmsg != null) return lmsg; String defaultValue = original; int localeUnderscoreIndex = localeString.indexOf('_'); EntityFind find = eci.getEntity().find("moqui.basic.LocalizedMessage") .condition("original", original).condition("locale", localeString).useCache(true); EntityValue localizedMessage = find.one(); if (localizedMessage == null && localeUnderscoreIndex > 0) localizedMessage = find.condition("locale", localeString.substring(0, localeUnderscoreIndex)).one(); if (localizedMessage == null) localizedMessage = find.condition("locale", "default").one(); // if original has a hash and we still don't have a localizedMessage then use what precedes the hash and try again if (localizedMessage == null) { int indexOfCloseCurly = original.lastIndexOf('}'); int indexOfHash = original.lastIndexOf("##"); if (indexOfHash > 0 && indexOfHash > indexOfCloseCurly) { defaultValue = original.substring(0, indexOfHash); EntityFind findHash = eci.getEntity().find("moqui.basic.LocalizedMessage") .condition("original", defaultValue).condition("locale", localeString).useCache(true); localizedMessage = findHash.one(); if (localizedMessage == null && localeUnderscoreIndex > 0) localizedMessage = findHash.condition("locale", localeString.substring(0, localeUnderscoreIndex)).one(); if (localizedMessage == null) localizedMessage = findHash.condition("locale", "default").one(); } } String result = localizedMessage != null ? localizedMessage.getString("localized") : defaultValue; eci.getL10nMessageCache().put(cacheKey, result); return result; } @Override public String formatCurrencyNoSymbol(Object amount, String uomId) { return formatCurrency(amount, uomId, null, getLocale(), true); } @Override public String formatCurrency(Object amount, String uomId) { return formatCurrency(amount, uomId, null, getLocale(), false); } @Override public String formatCurrency(Object amount, String uomId, Integer fractionDigits) { return formatCurrency(amount, uomId, fractionDigits, getLocale(), false); } @Override public String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale) { return formatCurrency(amount, uomId, fractionDigits, locale, false); } public String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale, boolean hideSymbol) { if (amount == null) return ""; if (amount instanceof CharSequence) { if (((CharSequence) amount).length() == 0) { return ""; } else { amount = parseNumber((String) amount, null); } } if (locale == null) locale = getLocale(); NumberFormat nf = NumberFormat.getCurrencyInstance(locale); String currencySymbol = null; if (hideSymbol) currencySymbol = ""; EntityValue uom = null; if (uomId != null && uomId.length() > 0) { List uomList = eci.getEntity().find("moqui.basic.Uom").condition("uomId", uomId) .condition("uomTypeEnumId", "UT_CURRENCY_MEASURE").disableAuthz().list(); if (uomList.size() > 0) { uom = uomList.get(0); String symbol = uom.getString("symbol"); if (currencySymbol == null && symbol != null) currencySymbol = symbol; Object fractionDigitsField = uom.get("fractionDigits"); if (fractionDigits == null && fractionDigitsField != null) { if (fractionDigitsField instanceof Integer) fractionDigits = (Integer)fractionDigitsField; else if (fractionDigitsField instanceof Long) fractionDigits = ((Long)fractionDigitsField).intValue(); } } } Currency currency = null; if (uomId != null && uomId.length() > 0) { try { currency = Currency.getInstance(uomId); if (currencySymbol == null) currencySymbol = currency.getSymbol(); if (fractionDigits == null) fractionDigits = currency.getDefaultFractionDigits(); } catch (Exception e) { if (logger.isTraceEnabled()) logger.trace("Ignoring IllegalArgumentException for Currency parse: " + e.toString()); } } if (currencySymbol == null) currencySymbol = ""; if (fractionDigits == null) fractionDigits = 2; nf.setMaximumFractionDigits(fractionDigits); nf.setMinimumFractionDigits(fractionDigits); DecimalFormatSymbols dfSymbols = new DecimalFormatSymbols(locale); dfSymbols.setCurrencySymbol(currencySymbol); ((DecimalFormat)nf).setDecimalFormatSymbols(dfSymbols); return nf.format(amount); } @Override public BigDecimal roundCurrency(BigDecimal amount, String uomId) { return roundCurrency(amount, uomId, false); } @Override public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise) { return roundCurrency(amount, uomId, false, RoundingMode.HALF_UP); } @Override public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, int roundingMethod) { return roundCurrency(amount, uomId, precise, RoundingMode.valueOf(roundingMethod)); } @Override public BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, RoundingMode mode) { if (amount == null) return null; List uomList = eci.getEntity().find("moqui.basic.Uom").condition("uomId", uomId).condition("uomTypeEnumId", "UT_CURRENCY_MEASURE").list(); Integer fractionDigits = null; if (uomList.size() > 0) { Object fractionDigitsField = uomList.get(0).get("fractionDigits"); if (fractionDigitsField != null) { if (fractionDigitsField instanceof Integer) fractionDigits = (Integer)fractionDigitsField; else if (fractionDigitsField instanceof Long) fractionDigits = ((Long)fractionDigitsField).intValue(); } } if (fractionDigits == null) { Currency currency = Currency.getInstance(uomId); fractionDigits = currency.getDefaultFractionDigits(); } if (fractionDigits == null) { fractionDigits = 2; } if (precise) fractionDigits++; eci.getLogger().info("Rounding to " + fractionDigits + " digits."); return amount.setScale(fractionDigits, mode); } @Override public Time parseTime(String input, String format) { Locale curLocale = getLocale(); TimeZone curTz = getTimeZone(); if (format == null || format.isEmpty()) format = "HH:mm:ss.SSS"; Calendar cal = calendarValidator.validate(input, format, curLocale, curTz); if (cal == null) cal = calendarValidator.validate(input, "HH:mm:ss", curLocale, curTz); if (cal == null) cal = calendarValidator.validate(input, "HH:mm", curLocale, curTz); if (cal == null) cal = calendarValidator.validate(input, "h:mm a", curLocale, curTz); if (cal == null) cal = calendarValidator.validate(input, "h:mm:ss a", curLocale, curTz); // also try the full ISO-8601, times may come in that way (even if funny with a date of 1970-01-01) if (cal == null) cal = calendarValidator.validate(input, "yyyy-MM-dd'T'HH:mm:ssZ", curLocale, curTz); if (cal != null) { Time time = new Time(cal.getTimeInMillis()); // logger.warn("============== parseTime input=${input} cal=${cal} long=${cal.getTimeInMillis()} time=${time} time long=${time.getTime()} util date=${new java.util.Date(cal.getTimeInMillis())} timestamp=${new java.sql.Timestamp(cal.getTimeInMillis())}") return time; } // try interpreting the String as a long try { Long lng = Long.valueOf(input); return new Time(lng); } catch (NumberFormatException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring NumberFormatException for Time parse: " + e.toString()); } return null; } public String formatTime(Time input, String format, Locale locale, TimeZone tz) { if (locale == null) locale = getLocale(); if (tz == null) tz = getTimeZone(); if (format == null || format.isEmpty()) format = "HH:mm:ss"; String timeStr = calendarValidator.format(input, format, locale, tz); // logger.warn("============= formatTime input=${input} timeStr=${timeStr} long=${input.getTime()}") return timeStr; } @Override public java.sql.Date parseDate(String input, String format) { if (format == null || format.isEmpty()) format = "yyyy-MM-dd"; Locale curLocale = getLocale(); // NOTE DEJ 20150317 Date parsing in terms of time zone causes funny issues because the time part of the long // since epoch representation is lost going to/from the DB, especially since the time portion is set to 0 and // with time zone conversion when the system date is in an earlier time zone than the user date it pushes the // Date to the previous day; what seems like the best solution is to parse and save the Date in the // system/default time zone, and format it that way as well. // The BIG dilemma is there is no way to represent a Date (yyyy-MM-dd) in an object that does not use the long // since epoch but rather is an absolute year, month, and day... which is really what we want. /* TimeZone curTz = getTimeZone() Calendar cal = calendarValidator.validate(input, format, curLocale, curTz) if (cal == null) cal = calendarValidator.validate(input, "MM/dd/yyyy", curLocale, curTz) // also try the full ISO-8601, dates may come in that way if (cal == null) cal = calendarValidator.validate(input, "yyyy-MM-dd'T'HH:mm:ssZ", curLocale, curTz) */ Calendar cal = calendarValidator.validate(input, format, curLocale); if (cal == null) cal = calendarValidator.validate(input, "MM/dd/yyyy", curLocale); // also try the full ISO-8601, dates may come in that way if (cal == null) cal = calendarValidator.validate(input, "yyyy-MM-dd'T'HH:mm:ssZ", curLocale); if (cal != null) { java.sql.Date date = new java.sql.Date(cal.getTimeInMillis()); // logger.warn("============== parseDate input=${input} cal=${cal} long=${cal.getTimeInMillis()} date=${date} date long=${date.getTime()} util date=${new java.util.Date(cal.getTimeInMillis())} timestamp=${new java.sql.Timestamp(cal.getTimeInMillis())}") return date; } // try interpreting the String as a long try { long lng = Long.parseLong(input); return new java.sql.Date(lng); } catch (NumberFormatException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring NumberFormatException for Date parse: " + e.toString()); } return null; } public String formatDate(java.util.Date input, String format, Locale locale, TimeZone tz) { if (locale == null) locale = getLocale(); // if (tz == null) tz = getTimeZone(); if (format == null || format.isEmpty()) format = "yyyy-MM-dd"; // See comment in parseDate for why we are ignoring the time zone // String dateStr = calendarValidator.format(input, format, getLocale(), getTimeZone()) String dateStr = calendarValidator.format(input, format, locale); // logger.warn("============= formatDate input=${input} dateStr=${dateStr} long=${input.getTime()}") return dateStr; } static final ArrayList timestampFormats; static { timestampFormats = new ArrayList<>(); timestampFormats.add("yyyy-MM-dd HH:mm"); timestampFormats.add("yyyy-MM-dd HH:mm:ss.SSS"); timestampFormats.add("yyyy-MM-dd'T'HH:mm:ss"); timestampFormats.add("yyyy-MM-dd'T'HH:mm:ssZ"); timestampFormats.add("yyyy-MM-dd'T'HH:mm:ss.SSSZ"); timestampFormats.add("yyyy-MM-dd HH:mm:ss"); timestampFormats.add("yyyy-MM-dd"); timestampFormats.add("yyyy-MM-dd HH:mm:ss.SSS z"); } @Override public Timestamp parseTimestamp(String input, String format) { if (input == null || input.isEmpty()) return null; return parseTimestamp(input, format, null, null); } @Override public Timestamp parseTimestamp(final String input, final String format, final Locale locale, final TimeZone timeZone) { if (input == null || input.isEmpty()) return null; Locale curLocale = locale != null ? locale : getLocale(); TimeZone curTz = timeZone != null ? timeZone : getTimeZone(); Calendar cal = null; if (format != null && !format.isEmpty()) cal = calendarValidator.validate(input, format, curLocale, curTz); // long values are pretty common, so if there are no special characters try that first (fast to check) if (cal == null) { int nonDigits = ObjectUtilities.countChars(input, false, true, true); if (nonDigits == 0 || (nonDigits == 1 && input.startsWith("-"))) { try { long lng = Long.parseLong(input); return new Timestamp(lng); } catch (NumberFormatException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring NumberFormatException for Timestamp parse: " + e.toString()); } } } // try a bunch of other format strings if (cal == null) { int timestampFormatsSize = timestampFormats.size(); for (int i = 0; cal == null && i < timestampFormatsSize; i++) { String tf = timestampFormats.get(i); cal = calendarValidator.validate(input, tf, curLocale, curTz); } } // logger.warn("=========== input=${input}, cal=${cal}, long=${cal?.getTimeInMillis()}, locale=${curLocale}, timeZone=${curTz}, System=${System.currentTimeMillis()}") if (cal != null) return new Timestamp(cal.getTimeInMillis()); try { // NOTE: do this AFTER the long parse because long numbers are interpreted really weird by this // ISO 8601 parsing using JAXB DatatypeConverter.parseDateTime(); on Java 7 can use "X" instead of "Z" in format string, but not in Java 6 cal = DatatypeConverter.parseDateTime(input); if (cal != null) return new Timestamp(cal.getTimeInMillis()); } catch (Exception e) { if (logger.isTraceEnabled()) logger.trace("Ignoring Exception for DatatypeConverter Timestamp parse: " + e.toString()); } return null; } public static String formatTimestamp(java.util.Date input, String format, Locale locale, TimeZone tz) { if (format == null || format.isEmpty()) format = "yyyy-MM-dd HH:mm"; return calendarValidator.format(input, format, locale, tz); } @Override public Calendar parseDateTime(String input, String format) { return calendarValidator.validate(input, format, getLocale(), getTimeZone()); } @Override public String formatDateTime(Calendar input, String format, Locale locale, TimeZone tz) { if (locale == null) locale = getLocale(); if (tz == null) tz = getTimeZone(); return calendarValidator.format(input, format, locale, tz); } @Override public BigDecimal parseNumber(String input, String format) { return bigDecimalValidator.validate(input, format, getLocale()); } @Override public String formatNumber(Number input, String format, Locale locale) { if (locale == null) locale = getLocale(); if (format == null || format.isEmpty()) { // BigDecimalValidator defaults to 3 decimal digits, if no format specified we don't want to truncate so small, use better defaults NumberFormat nf = locale != null ? NumberFormat.getNumberInstance(locale) : NumberFormat.getNumberInstance(); nf.setMinimumFractionDigits(0); nf.setMaximumFractionDigits(12); nf.setMinimumIntegerDigits(1); nf.setGroupingUsed(true); return nf.format(input); } else { return bigDecimalValidator.format(input, format, locale); } } @Override public String format(Object value, String format) { return this.format(value, format, getLocale(), getTimeZone()); } @Override public String format(Object value, String format, Locale locale, TimeZone tz) { if (value == null) return ""; if (locale == null) locale = getLocale(); if (tz == null) tz = getTimeZone(); Class valueClass = value.getClass(); if (valueClass == String.class) return (String) value; if (valueClass == Timestamp.class) return formatTimestamp((Timestamp) value, format, locale, tz); if (valueClass == java.util.Date.class) return formatTimestamp((java.util.Date) value, format, locale, tz); if (valueClass == java.sql.Date.class) return formatDate((Date) value, format, locale, tz); if (valueClass == Time.class) return formatTime((Time) value, format, locale, tz); // this one needs to be instanceof to include the many sub-classes of Number if (value instanceof Number) return formatNumber((Number) value, format, locale); // Calendar is an abstract class, so must use instanceof here as well if (value instanceof Calendar) return formatDateTime((Calendar) value, format, locale, tz); // support formatting of Map and Collection using JSON if (value instanceof Map || value instanceof Collection) { String json = JsonOutput.toJson(value); return (json.length() > 128) ? JsonOutput.prettyPrint(json) : json; } return value.toString(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/LoggerFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import org.moqui.context.LoggerFacade import org.slf4j.Logger import org.slf4j.LoggerFactory class LoggerFacadeImpl implements LoggerFacade { protected final static Logger logger = LoggerFactory.getLogger(LoggerFacadeImpl.class) protected final ExecutionContextFactoryImpl ecfi LoggerFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi } void log(String levelStr, String message, Throwable thrown) { int level switch (levelStr) { case "trace": level = TRACE_INT; break case "debug": level = DEBUG_INT; break case "info": level = INFO_INT; break case "warn": level = WARN_INT; break case "error": level = ERROR_INT; break case "off": // do nothing default: return } log(level, message, thrown) } @Override void log(int level, String message, Throwable thrown) { switch (level) { case TRACE_INT: logger.trace(message, thrown); break case DEBUG_INT: logger.debug(message, thrown); break case INFO_INT: logger.info(message, thrown); break case WARN_INT: logger.warn(message, thrown); break case ERROR_INT: logger.error(message, thrown); break case FATAL_INT: logger.error(message, thrown); break case ALL_INT: logger.warn(message, thrown); break case OFF_INT: break // do nothing } } void trace(String message) { log(TRACE_INT, message, null) } void debug(String message) { log(DEBUG_INT, message, null) } void info(String message) { log(INFO_INT, message, null) } void warn(String message) { log(WARN_INT, message, null) } void error(String message) { log(ERROR_INT, message, null) } void trace(String message, Throwable thrown) { log(TRACE_INT, message, thrown) } void debug(String message, Throwable thrown) { log(DEBUG_INT, message, thrown) } void info(String message, Throwable thrown) { log(INFO_INT, message, thrown) } void warn(String message, Throwable thrown) { log(WARN_INT, message, thrown) } void error(String message, Throwable thrown) { log(ERROR_INT, message, thrown) } @Override boolean logEnabled(int level) { switch (level) { case TRACE_INT: return logger.isTraceEnabled() case DEBUG_INT: return logger.isDebugEnabled() case INFO_INT: return logger.isInfoEnabled() case WARN_INT: return logger.isWarnEnabled() case ERROR_INT: case FATAL_INT: return logger.isErrorEnabled() case ALL_INT: return logger.isWarnEnabled() case OFF_INT: return false default: return false } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/MessageFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.moqui.context.MessageFacade import org.moqui.context.MessageFacade.MessageInfo import org.moqui.context.NotificationMessage.NotificationType import org.moqui.context.ValidationError import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class MessageFacadeImpl implements MessageFacade { protected final static Logger logger = LoggerFactory.getLogger(MessageFacadeImpl.class) private final static List emptyStringList = Collections.unmodifiableList(new ArrayList()) private final static List emptyValidationErrorList = Collections.unmodifiableList(new ArrayList()) private final static List emptyMessageInfoList = Collections.unmodifiableList(new ArrayList()) private ArrayList messageList = (ArrayList) null private ArrayList publicMessageList = (ArrayList) null private ArrayList errorList = (ArrayList) null private ArrayList validationErrorList = (ArrayList) null private boolean hasErrors = false private LinkedList savedErrorsStack = (LinkedList) null MessageFacadeImpl() { } @Override List getMessages() { if (messageList == null) return emptyStringList ArrayList strList = new ArrayList<>(messageList.size()) for (int i = 0; i < messageList.size(); i++) strList.add(((MessageInfo) messageList.get(i)).getMessage()) return strList } @Override List getMessageInfos() { if (messageList == null) return emptyMessageInfoList return Collections.unmodifiableList(messageList) } @Override String getMessagesString() { if (messageList == null) return "" StringBuilder messageBuilder = new StringBuilder() for (MessageInfo message in messageList) messageBuilder.append(message.getMessage()).append("\n") return messageBuilder.toString() } @Override void addMessage(String message) { addMessage(message, info) } @Override void addMessage(String message, NotificationType type) { addMessage(message, type?.toString()) } @Override void addMessage(String message, String type) { if (message == null || message.isEmpty()) return if (messageList == null) messageList = new ArrayList<>() MessageInfo mi = new MessageInfo(message, type) messageList.add(mi) logger.info(mi.toString()) } @Override void addPublic(String message, NotificationType type) { addPublic(message, type?.toString()) } @Override void addPublic(String message, String type) { if (message == null || message.isEmpty()) return if (publicMessageList == null) publicMessageList = new ArrayList<>() if (messageList == null) messageList = new ArrayList<>() MessageInfo mi = new MessageInfo(message, type) publicMessageList.add(mi) messageList.add(mi) logger.info(mi.toString()) } @Override List getPublicMessages() { if (publicMessageList == null) return emptyStringList ArrayList strList = new ArrayList<>(publicMessageList.size()) for (int i = 0; i < publicMessageList.size(); i++) strList.add(((MessageInfo) publicMessageList.get(i)).getMessage()) return strList } @Override List getPublicMessageInfos() { if (publicMessageList == null) return emptyMessageInfoList return Collections.unmodifiableList(publicMessageList) } @Override List getErrors() { if (errorList == null) return emptyStringList return Collections.unmodifiableList(errorList) } @Override void addError(String error) { if (error == null || error.isEmpty()) return if (errorList == null) errorList = new ArrayList<>() errorList.add(error) logger.error(error) hasErrors = true } @Override List getValidationErrors() { if (validationErrorList == null) return emptyValidationErrorList return Collections.unmodifiableList(validationErrorList) } @Override void addValidationError(String form, String field, String serviceName, String message, Throwable nested) { if (message == null || message.isEmpty()) return if (validationErrorList == null) validationErrorList = new ArrayList<>() ValidationError ve = new ValidationError(form, field, serviceName, message, nested) validationErrorList.add(ve) logger.error(ve.getMap().toString()) hasErrors = true } @Override void addError(ValidationError error) { if (error == null) return if (validationErrorList == null) validationErrorList = new ArrayList<>() validationErrorList.add(error) logger.error(error.getMap().toString()) hasErrors = true } @Override boolean hasError() { return hasErrors } @Override String getErrorsString() { StringBuilder errorBuilder = new StringBuilder() if (errorList != null) for (String errorMessage in errorList) errorBuilder.append(errorMessage).append("\n") if (validationErrorList != null) for (ValidationError validationError in validationErrorList) { errorBuilder.append(validationError.toStringPretty()).append("\n") } return errorBuilder.toString() } @Override void clearAll() { clearErrors() if (messageList != null) messageList.clear() if (publicMessageList != null) publicMessageList.clear() } @Override void clearErrors() { if (messageList == null) messageList = new ArrayList<>() if (errorList != null) { for (int i = 0; i < errorList.size(); i++) { String errMsg = (String) errorList.get(i) messageList.add(new MessageInfo(errMsg, NotificationType.danger)) } errorList.clear() } if (validationErrorList != null) { for (int i = 0; i < validationErrorList.size(); i++) { ValidationError error = (ValidationError) validationErrorList.get(i) messageList.add(new MessageInfo(error.toStringPretty(), NotificationType.danger)) } validationErrorList.clear() } hasErrors = false } void moveErrorsToDangerMessages() { if (errorList != null) { for (String errMsg : errorList) addMessage(errMsg, danger) errorList.clear() } if (validationErrorList != null) { for (ValidationError ve : validationErrorList) addMessage(ve.toStringPretty(), danger) validationErrorList.clear() } hasErrors = false } @Override void copyMessages(MessageFacade mf) { if (mf.getMessageInfos()) { if (messageList == null) messageList = new ArrayList<>() messageList.addAll(mf.getMessageInfos()) } if (mf.getErrors()) { if (errorList == null) errorList = new ArrayList<>() errorList.addAll(mf.getErrors()) hasErrors = true } if (mf.getValidationErrors()) { if (validationErrorList == null) validationErrorList = new ArrayList<>() validationErrorList.addAll(mf.getValidationErrors()) hasErrors = true } if (mf.getPublicMessageInfos()) { if (publicMessageList == null) publicMessageList = new ArrayList<>() publicMessageList.addAll(mf.getPublicMessageInfos()) } } @Override void pushErrors() { if (savedErrorsStack == null) savedErrorsStack = new LinkedList() savedErrorsStack.addFirst(new SavedErrors(errorList, validationErrorList)) errorList = null validationErrorList = null hasErrors = false } @Override void popErrors() { if (savedErrorsStack == null || savedErrorsStack.size() == 0) return SavedErrors se = savedErrorsStack.removeFirst() if (se.errorList != null && se.errorList.size() > 0) { if (errorList == null) errorList = new ArrayList<>() errorList.addAll(se.errorList) hasErrors = true } if (se.validationErrorList != null && se.validationErrorList.size() > 0) { if (validationErrorList == null) validationErrorList = new ArrayList<>() validationErrorList.addAll(se.validationErrorList) hasErrors = true } } static class SavedErrors { List errorList List validationErrorList SavedErrors(List errorList, List validationErrorList) { this.errorList = errorList this.validationErrorList = validationErrorList } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/NotificationMessageImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.context.NotificationMessage import org.moqui.entity.EntityFacade import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp @CompileStatic class NotificationMessageImpl implements NotificationMessage, Externalizable { private final static Logger logger = LoggerFactory.getLogger(NotificationMessageImpl.class) private Set userIdSet = new HashSet() private String userGroupId = (String) null private String topic = (String) null private String subTopic = (String) null private transient EntityValue notificationTopic = (EntityValue) null private String messageJson = (String) null private transient Map messageMap = (Map) null private String notificationMessageId = (String) null private Timestamp sentDate = (Timestamp) null private String titleTemplate = (String) null private String linkTemplate = (String) null private String titleText = (String) null private String linkText = (String) null private NotificationType type = (NotificationType) null private Boolean showAlert = (Boolean) null private Boolean alertNoAutoHide = (Boolean) null private Boolean persistOnSend = (Boolean) null private String emailTemplateId = (String) null private Boolean emailMessageSave = (Boolean) null private Map emailMessageIdByUserId = (Map) null private transient ExecutionContextFactoryImpl ecfiTransient = (ExecutionContextFactoryImpl) null /** Default constructor for deserialization */ NotificationMessageImpl() { } NotificationMessageImpl(ExecutionContextFactoryImpl ecfi) { ecfiTransient = ecfi } ExecutionContextFactoryImpl getEcfi() { if (ecfiTransient == null) ecfiTransient = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() return ecfiTransient } EntityValue getNotificationTopic() { if (notificationTopic == null && topic != null && !topic.isEmpty()) notificationTopic = ecfi.entityFacade.fastFindOne("moqui.security.user.NotificationTopic", true, true, topic) return notificationTopic } @Override NotificationMessage userId(String userId) { userIdSet.add(userId); return this } @Override NotificationMessage userIds(Set userIds) { userIdSet.addAll(userIds); return this } @Override Set getUserIds() { userIdSet } @Override NotificationMessage userGroupId(String userGroupId) { this.userGroupId = userGroupId; return this } @Override String getUserGroupId() { userGroupId } @Override Set getNotifyUserIds() { Set notifyUserIds = new HashSet<>() Set checkedUserIds = new HashSet<>() EntityFacade ef = ecfi.entityFacade for (String userId in userIdSet) { checkedUserIds.add(userId) if (checkUserNotify(userId, ef)) notifyUserIds.add(userId) } // notify by group, skipping users already notified if (userGroupId) { ef.find("moqui.security.UserGroupMember") .conditionDate("fromDate", "thruDate", new Timestamp(System.currentTimeMillis())) .condition("userGroupId", userGroupId).disableAuthz().iterator().withCloseable ({eli -> EntityValue nextValue while ((nextValue = (EntityValue) eli.next()) != null) { String userId = (String) nextValue.userId if (checkedUserIds.contains(userId)) continue checkedUserIds.add(userId) if (checkUserNotify(userId, ef)) notifyUserIds.add(userId) } }) } // add all users subscribed to all messages on the topic EntityList allNotificationUsers = ef.find("moqui.security.user.NotificationTopicUser") .condition("topic", topic).condition("allNotifications", "Y").useCache(true).disableAuthz().list() int allNotificationUsersSize = allNotificationUsers.size() for (int i = 0; i < allNotificationUsersSize; i++) { EntityValue allNotificationUser = (EntityValue) allNotificationUsers.get(i) notifyUserIds.add((String) allNotificationUser.userId) } // check each user to see if account terminated (UserAccount.terminateDate != null && < now) long nowTime = System.currentTimeMillis() EntityList notifyUserAccountList = ef.find("moqui.security.UserAccount") .condition("userId", "in", notifyUserIds) .selectField("userId").selectField("terminateDate").disableAuthz().list() int notifyUaSize = notifyUserAccountList.size() for (int i = 0; i < notifyUaSize; i++) { EntityValue userAccount = (EntityValue) notifyUserAccountList.get(i) Timestamp terminateDate = (Timestamp) userAccount.getNoCheckSimple("terminateDate") if (terminateDate != (Timestamp) null && nowTime > terminateDate.getTime()) notifyUserIds.remove(userAccount.get("userId")) } return notifyUserIds } private boolean checkUserNotify(String userId, EntityFacade ef) { EntityValue notTopicUser = ef.find("moqui.security.user.NotificationTopicUser") .condition("topic", topic).condition("userId", userId).useCache(true).disableAuthz().one() boolean notifyUser = true if (notTopicUser != null && notTopicUser.receiveNotifications) { notifyUser = notTopicUser.receiveNotifications == 'Y' } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.receiveNotifications) notifyUser = localNotTopic.receiveNotifications == 'Y' } return notifyUser } @Override NotificationMessage topic(String topic) { this.topic = topic; notificationTopic = null; return this } @Override String getTopic() { topic } @Override String getSubTopic() { subTopic } @Override NotificationMessage subTopic(String st) { subTopic = st; return this } @Override NotificationMessage message(String messageJson) { this.messageJson = messageJson; messageMap = null; return this } @Override NotificationMessage message(Map message) { this.messageMap = Collections.unmodifiableMap(message) as Map messageJson = null return this } @Override String getMessageJson() { if (messageJson == null && messageMap != null) { try { messageJson = JsonOutput.toJson(messageMap) } catch (Exception e) { logger.warn("Error writing JSON for Notification ${topic} message: ${e.toString()}\n${messageMap}") } } return messageJson } @Override Map getMessageMap() { if (messageMap == null && messageJson != null) messageMap = Collections.unmodifiableMap((Map) new JsonSlurper().parseText(messageJson)) return messageMap } @Override NotificationMessage title(String title) { titleTemplate = title; return this } @Override String getTitle() { if (titleText == null) { if (titleTemplate != null && !titleTemplate.isEmpty()) titleText = ecfi.resource.expand(titleTemplate, "", getMessageMap(), true) if (titleText == null || titleText.isEmpty()) { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null) { if (type == danger && localNotTopic.errorTitleTemplate) { titleText = ecfi.resource.expand((String) localNotTopic.errorTitleTemplate, "", getMessageMap(), true) } else if (localNotTopic.titleTemplate) { titleText = ecfi.resource.expand((String) localNotTopic.titleTemplate, "", getMessageMap(), true) } } } } return titleText } @Override NotificationMessage link(String link) { linkTemplate = link; return this } @Override String getLink() { if (linkText == null) { if (linkTemplate) { linkText = ecfi.resource.expand(linkTemplate, "", getMessageMap(), true) } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.linkTemplate) linkText = ecfi.resource.expand((String) localNotTopic.linkTemplate, "", getMessageMap(), true) } } return linkText } @Override NotificationMessage type(NotificationType type) { this.type = type; return this } @Override NotificationMessage type(String type) { this.type = NotificationType.valueOf(type); return this } @Override String getType() { if (type != null) { return type.name() } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.typeString) { return localNotTopic.typeString } else { return info.name() } } } @Override NotificationMessage showAlert(boolean show) { showAlert = show; return this } @Override boolean isShowAlert() { if (showAlert != null) { return showAlert.booleanValue() } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.showAlert) { return localNotTopic.showAlert == 'Y' } else { return false } } } @Override NotificationMessage alertNoAutoHide(boolean noAutoHide) { alertNoAutoHide = noAutoHide; return this } @Override boolean isAlertNoAutoHide() { if (alertNoAutoHide != null) { return alertNoAutoHide.booleanValue() } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.alertNoAutoHide) { return localNotTopic.alertNoAutoHide == 'Y' } else { return false } } } @Override NotificationMessage emailTemplateId(String id) { emailTemplateId = id if (emailTemplateId != null && emailTemplateId.isEmpty()) emailTemplateId = null return this } @Override String getEmailTemplateId() { if (emailTemplateId != null) { return emailTemplateId } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.emailTemplateId) { return localNotTopic.emailTemplateId } else { return null } } } @Override NotificationMessage emailMessageSave(Boolean save) { emailMessageSave = save; return this } @Override boolean isEmailMessageSave() { if (emailMessageSave != null) { return emailMessageSave.booleanValue() } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.emailMessageSave) { return localNotTopic.emailMessageSave == 'Y' } else { return false } } } @Override Map getEmailMessageIdByUserId() { return emailMessageIdByUserId } @Override NotificationMessage persistOnSend(Boolean persist) { persistOnSend = persist; return this } @Override boolean isPersistOnSend() { if (persistOnSend != null) { return persistOnSend.booleanValue() } else { EntityValue localNotTopic = getNotificationTopic() if (localNotTopic != null && localNotTopic.persistOnSend) { return localNotTopic.persistOnSend == 'Y' } else { return false } } } @Override NotificationMessage send(boolean persist) { persistOnSend = persist return send() } @Override NotificationMessage send() { // persist if is persistOnSend if (isPersistOnSend()) { sentDate = new Timestamp(System.currentTimeMillis()) TransactionFacadeImpl tfi = ecfi.transactionFacade // run in separate transaction so that it is saved immediately, NotificationMessage listeners running async are // outside of this transaction and may use these records (like markSent() before the current tx is complete) boolean suspendedTransaction = false try { if (tfi.isTransactionInPlace()) suspendedTransaction = tfi.suspend() boolean beganTransaction = tfi.begin(null) try { Map createResult = ecfi.service.sync().name("create", "moqui.security.user.NotificationMessage") .parameters([topic:this.topic, subTopic:this.subTopic, userGroupId:this.userGroupId, sentDate:this.sentDate, messageJson:this.getMessageJson(), titleText:this.getTitle(), linkText:this.getLink(), typeString:this.getType(), showAlert:(this.showAlert ? 'Y' : 'N')]) .disableAuthz().call() // if it's null we got an error so return from closure if (createResult == null) return this.setNotificationMessageId((String) createResult.notificationMessageId) for (String userId in this.getNotifyUserIds()) ecfi.service.sync().name("create", "moqui.security.user.NotificationMessageUser") .parameters([notificationMessageId:createResult.notificationMessageId, userId:userId]) .disableAuthz().call() } catch (Throwable t) { tfi.rollback(beganTransaction, "Error saving NotificationMessage", t) throw t } finally { tfi.commit(beganTransaction) } } finally { if (suspendedTransaction) tfi.resume() } /* old approach, cleaner and simpler but blows up under Groovy 2.5.13 and later * java.lang.VerifyError: Bad type on operand stack * Exception Details: * Location: org/moqui/impl/context/NotificationMessageImpl$_send_closure1.doCall(Ljava/lang/Object;)Ljava/lang/Object; @223: ifnonnull * Reason: Type integer (current frame, stack[5]) is not assignable to reference type // a little trick so that this is available in the closure NotificationMessageImpl nmi = this // run in runRequireNew so that it is saved immediately, NotificationMessage listeners running async are // outside of this transaction and may use these records (like markSent() before the current tx is complete) ecfi.transactionFacade.runRequireNew(null, "Error saving NotificationMessage", { Map createResult = ecfi.service.sync().name("create", "moqui.security.user.NotificationMessage") .parameters([topic:nmi.topic, userGroupId:nmi.userGroupId, sentDate:nmi.sentDate, messageJson:nmi.getMessageJson(), titleText:nmi.getTitle(), linkText:nmi.getLink(), typeString:nmi.getType(), showAlert:(nmi.showAlert ? 'Y' : 'N')]) .disableAuthz().call() // if it's null we got an error so return from closure if (createResult == null) return nmi.setNotificationMessageId((String) createResult.notificationMessageId) for (String userId in nmi.getNotifyUserIds()) ecfi.service.sync().name("create", "moqui.security.user.NotificationMessageUser") .parameters([notificationMessageId:createResult.notificationMessageId, userId:userId]) .disableAuthz().call() }) */ } // now send it to the topic ecfi.sendNotificationMessageToTopic(this) // send emails if emailTemplateId String localEmailTemplateId = getEmailTemplateId() if (localEmailTemplateId != null && !localEmailTemplateId.isEmpty()) { Map wrappedMessageMap = getWrappedMessageMap() EntityValue notificationTopic = getNotificationTopic() Set curNotifyUserIds = getNotifyUserIds() EntityList notificationTopicUsers = ecfi.entityFacade.find("moqui.security.user.NotificationTopicUser") .condition("topic", topic).condition("userId", "in", curNotifyUserIds).disableAuthz().list() for (String userId in curNotifyUserIds) { EntityValue notificationUser = (EntityValue) notificationTopicUsers.findByAnd("userId", userId) if ("N".equals(notificationUser?.emailNotifications)) continue if (!("Y".equals(notificationUser?.emailNotifications) || "Y".equals(notificationTopic?.emailNotifications))) continue EntityValue userAccount = ecfi.entityFacade.find("moqui.security.UserAccount") .condition("userId", userId).disableAuthz().one() String emailAddress = userAccount?.emailAddress if (emailAddress) { // FUTURE: if there is an option to create EmailMessage record also configure emailTypeEnumId (maybe if emailTypeEnumId is set create EmailMessage) Map sendOut = ecfi.serviceFacade.sync().name("org.moqui.impl.EmailServices.send#EmailTemplate") .parameters([emailTemplateId:localEmailTemplateId, toAddresses:emailAddress, bodyParameters:wrappedMessageMap, toUserId:userId, createEmailMessage:isEmailMessageSave()]).call() String emailMessageId = (String) sendOut.emailMessageId if (emailMessageId) { if (emailMessageIdByUserId == null) emailMessageIdByUserId = new HashMap() emailMessageIdByUserId.put(userId, emailMessageId) String notificationMessageId = getNotificationMessageId() if (notificationMessageId) { // use store to update if was created above or create if not ecfi.service.sync().name("store", "moqui.security.user.NotificationMessageUser") .parameters([notificationMessageId:notificationMessageId, userId:userId, emailMessageId:emailMessageId, sentDate:new Timestamp(System.currentTimeMillis())]) .disableAuthz().call() } } } } } return this } @Override String getNotificationMessageId() { return notificationMessageId } void setNotificationMessageId(String id) { notificationMessageId = id } @Override NotificationMessage markSent(String userId) { // if no notificationMessageId there is nothing to do, this isn't persisted as far as we know if (!notificationMessageId) return this if (!userId) throw new BaseArtifactException("Must specify userId to mark notification message sent") ExecutionContextImpl eci = ecfi.getEci() boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz() try { ecfi.entityFacade.makeValue("moqui.security.user.NotificationMessageUser") .set("userId", userId).set("notificationMessageId", notificationMessageId) .set("sentDate", new Timestamp(System.currentTimeMillis())).update() } catch (Throwable t) { logger.error("Error marking notification message ${notificationMessageId} sent", t) } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() } return this } @Override NotificationMessage markViewed(String userId) { // if no notificationMessageId there is nothing to do, this isn't persisted as far as we know if (!notificationMessageId) return this if (!userId) throw new BaseArtifactException("Must specify userId to mark notification message received") markViewed(notificationMessageId, userId, ecfi.getEci()) return this } static Timestamp markViewed(String notificationMessageId, String userId, ExecutionContext ec) { boolean alreadyDisabled = ec.getArtifactExecution().disableAuthz() try { Timestamp recStamp = new Timestamp(System.currentTimeMillis()) ec.factory.entity.makeValue("moqui.security.user.NotificationMessageUser") .set("userId", userId).set("notificationMessageId", notificationMessageId) .set("viewedDate", recStamp).update() return recStamp } catch (Throwable t) { logger.error("Error marking notification message ${notificationMessageId} sent", t) return null } finally { if (!alreadyDisabled) ec.getArtifactExecution().enableAuthz() } } @Override Map getWrappedMessageMap() { EntityValue localNotTopic = getNotificationTopic() return [topic:topic, subTopic:subTopic, sentDate:sentDate, notificationMessageId:notificationMessageId, topicDescription:localNotTopic?.description, message:getMessageMap(), title:getTitle(), link:getLink(), type:getType(), persistOnSend:isPersistOnSend(), showAlert:isShowAlert(), alertNoAutoHide:isAlertNoAutoHide()] } @Override String getWrappedMessageJson() { Map wrappedMap = getWrappedMessageMap() try { return JsonOutput.toJson(wrappedMap) } catch (Exception e) { logger.warn("Error writing JSON for Notification ${topic} message: ${e.toString()}\n${wrappedMap}") return null } } void populateFromValue(EntityValue nmbu) { this.notificationMessageId = nmbu.notificationMessageId this.topic = nmbu.topic this.subTopic = nmbu.subTopic this.sentDate = nmbu.getTimestamp("sentDate") this.userGroupId = nmbu.userGroupId this.messageJson = nmbu.messageJson this.titleText = nmbu.titleText this.linkText = nmbu.linkText if (nmbu.typeString) this.type = NotificationType.valueOf((String) nmbu.typeString) this.showAlert = nmbu.showAlert == 'Y' this.alertNoAutoHide = nmbu.alertNoAutoHide == 'Y' EntityList nmuList = nmbu.findRelated("moqui.security.user.NotificationMessageUser", [notificationMessageId:notificationMessageId] as Map, null, false, false) for (EntityValue nmu in nmuList) userIdSet.add((String) nmu.userId) } @Override void writeExternal(ObjectOutput out) throws IOException { // NOTE: lots of writeObject because values are nullable out.writeObject(userIdSet) out.writeObject(userGroupId) out.writeUTF(topic) out.writeObject(subTopic) out.writeUTF(getMessageJson()) out.writeObject(notificationMessageId) out.writeObject(sentDate) out.writeObject(getTitle()) out.writeObject(getLink()) out.writeObject(type) out.writeObject(showAlert) out.writeObject(alertNoAutoHide) out.writeObject(persistOnSend) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { userIdSet = (Set) objectInput.readObject() userGroupId = (String) objectInput.readObject() topic = objectInput.readUTF() subTopic = objectInput.readObject() messageJson = objectInput.readUTF() notificationMessageId = (String) objectInput.readObject() sentDate = (Timestamp) objectInput.readObject() titleText = (String) objectInput.readObject() linkText = (String) objectInput.readObject() type = (NotificationType) objectInput.readObject() showAlert = (Boolean) objectInput.readObject() alertNoAutoHide = (Boolean) objectInput.readObject() persistOnSend = (Boolean) objectInput.readObject() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/ResourceFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper import org.moqui.BaseArtifactException import org.moqui.context.* import org.moqui.impl.context.reference.BaseResourceReference import org.moqui.impl.context.renderer.FtlTemplateRenderer import org.moqui.impl.context.renderer.NoTemplateRenderer import org.moqui.impl.context.runner.JavaxScriptRunner import org.moqui.impl.context.runner.XmlActionsScriptRunner import org.moqui.impl.entity.EntityValueBase import org.moqui.jcache.MCache import org.moqui.util.ContextBinding import org.moqui.util.ContextStack import org.moqui.util.MNode import org.moqui.resource.ResourceReference import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.activation.DataSource import jakarta.mail.util.ByteArrayDataSource import javax.cache.Cache import javax.jcr.Repository import javax.jcr.RepositoryFactory import javax.jcr.Session import javax.jcr.SimpleCredentials import javax.script.ScriptEngine import javax.script.ScriptEngineManager import javax.xml.transform.Source import javax.xml.transform.Transformer import javax.xml.transform.TransformerFactory import javax.xml.transform.URIResolver import javax.xml.transform.sax.SAXResult import javax.xml.transform.stream.StreamSource import java.lang.reflect.Method @CompileStatic class ResourceFacadeImpl implements ResourceFacade { protected final static Logger logger = LoggerFactory.getLogger(ResourceFacadeImpl.class) protected final ExecutionContextFactoryImpl ecfi final FtlTemplateRenderer ftlTemplateRenderer final XmlActionsScriptRunner xmlActionsScriptRunner // the groovy Script object is not thread safe, so have one per thread per expression; can be reused as thread is reused protected final ThreadLocal> threadScriptByExpression = new ThreadLocal<>() protected final Map scriptGroovyExpressionCache = new HashMap<>() protected final Cache textLocationCache protected final Cache resourceReferenceByLocation protected final Map resourceReferenceClasses = new HashMap<>() protected final Map templateRenderers = new HashMap<>() protected final ArrayList templateRendererExtensions = new ArrayList<>() protected final ArrayList templateRendererExtensionsDots = new ArrayList<>() protected final Map scriptRunners = new HashMap<>() protected final ScriptEngineManager scriptEngineManager = new ScriptEngineManager() protected final ToolFactory xslFoHandlerFactory protected final Map contentRepositories = new HashMap<>() protected final ThreadLocal> contentSessions = new ThreadLocal>() ResourceFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi ftlTemplateRenderer = new FtlTemplateRenderer() ftlTemplateRenderer.init(ecfi) xmlActionsScriptRunner = new XmlActionsScriptRunner() xmlActionsScriptRunner.init(ecfi) textLocationCache = ecfi.cacheFacade.getCache("resource.text.location", String.class, String.class) // a plain HashMap is faster and just fine here: scriptGroovyExpressionCache = ecfi.cacheFacade.getCache("resource.groovy.expression") resourceReferenceByLocation = ecfi.cacheFacade.getCache("resource.reference.location", String.class, ResourceReference.class) MNode resourceFacadeNode = ecfi.confXmlRoot.first("resource-facade") // Setup resource reference classes for (MNode rrNode in resourceFacadeNode.children("resource-reference")) { try { Class rrClass = Thread.currentThread().getContextClassLoader().loadClass(rrNode.attribute("class")) resourceReferenceClasses.put(rrNode.attribute("scheme"), rrClass) } catch (ClassNotFoundException e) { logger.info("Class [${rrNode.attribute("class")}] not found (${e.toString()})") } } // Setup template renderers for (MNode templateRendererNode in resourceFacadeNode.children("template-renderer")) { TemplateRenderer tr = (TemplateRenderer) Thread.currentThread().getContextClassLoader() .loadClass(templateRendererNode.attribute("class")).newInstance() templateRenderers.put(templateRendererNode.attribute("extension"), tr.init(ecfi)) } for (String ext in templateRenderers.keySet()) { templateRendererExtensions.add(ext) templateRendererExtensionsDots.add(ObjectUtilities.countChars(ext, (char) '.')) } // Setup script runners for (MNode scriptRunnerNode in resourceFacadeNode.children("script-runner")) { if (scriptRunnerNode.attribute("class")) { ScriptRunner sr = (ScriptRunner) Thread.currentThread().getContextClassLoader() .loadClass(scriptRunnerNode.attribute("class")).newInstance() scriptRunners.put(scriptRunnerNode.attribute("extension"), sr.init(ecfi)) } else if (scriptRunnerNode.attribute("engine")) { ScriptRunner sr = new JavaxScriptRunner(scriptRunnerNode.attribute("engine")).init(ecfi) scriptRunners.put(scriptRunnerNode.attribute("extension"), sr) } else { logger.error("Configured script-runner for extension [${scriptRunnerNode.attribute("extension")}] must have either a class or engine attribute and has neither.") } } // Get XSL-FO Handler Factory if (resourceFacadeNode.attribute("xsl-fo-handler-factory")) { xslFoHandlerFactory = ecfi.getToolFactory(resourceFacadeNode.attribute("xsl-fo-handler-factory")) if (xslFoHandlerFactory != null) { logger.info("Using xsl-fo-handler-factory ${resourceFacadeNode.attribute("xsl-fo-handler-factory")} (${xslFoHandlerFactory.class.name})") } else { logger.warn("Could not find xsl-fo-handler-factory with name ${resourceFacadeNode.attribute("xsl-fo-handler-factory")}") } } else { xslFoHandlerFactory = null } // Setup content repositories for (MNode repositoryNode in ecfi.confXmlRoot.first("repository-list").children("repository")) { String repoName = repositoryNode.attribute("name") Repository repo = null Map parameters = new HashMap() for (MNode paramNode in repositoryNode.children("init-param")) parameters.put(paramNode.attribute("name"), paramNode.attribute("value")) try { for (RepositoryFactory factory : ServiceLoader.load(RepositoryFactory.class)) { repo = factory.getRepository(parameters) // factory accepted parameters if (repo != null) break } if (repo != null) { contentRepositories.put(repoName, repo) logger.info("Added JCR Repository ${repoName} of type ${repo.class.name} for workspace ${repositoryNode.attribute("workspace")} using parameters: ${parameters}") } else { logger.error("Could not find JCR RepositoryFactory for repository ${repoName} using parameters: ${parameters}") } } catch (Exception e) { logger.error("Error getting JCR Repository ${repositoryNode.attribute("name")}: ${e.toString()}") } } } void destroyAllInThread() { Map sessionMap = contentSessions.get() if (sessionMap) for (Session openSession in sessionMap.values()) openSession.logout() contentSessions.remove() } ExecutionContextFactoryImpl getEcfi() { ecfi } Map getTemplateRenderers() { Collections.unmodifiableMap(templateRenderers) } TreeSet getTemplateRendererExtensionSet() { new TreeSet(templateRendererExtensions) } Repository getContentRepository(String name) { contentRepositories.get(name) } /** Get the active JCR Session for the context/thread, making sure it is live, and make one if needed. */ Session getContentRepositorySession(String name) { Map sessionMap = contentSessions.get() if (sessionMap == null) { sessionMap = new HashMap() contentSessions.set(sessionMap) } Session newSession = sessionMap.get(name) if (newSession != null) { if (newSession.isLive()) { return newSession } else { sessionMap.remove(name) // newSession = null } } Repository rep = contentRepositories[name] if (!rep) return null MNode repositoryNode = ecfi.confXmlRoot.first("repository-list") .first({ MNode it -> it.name == "repository" && it.attribute("name") == name }) SimpleCredentials credentials = new SimpleCredentials(repositoryNode.attribute("username") ?: "anonymous", (repositoryNode.attribute("password") ?: "").toCharArray()) if (repositoryNode.attribute("workspace")) { newSession = rep.login(credentials, repositoryNode.attribute("workspace")) } else { newSession = rep.login(credentials) } if (newSession != null) sessionMap.put(name, newSession) return newSession } @Override ResourceReference getLocationReference(String location) { if (location == null) return null return internalGetReference(getLocationScheme(location), location) } static String getLocationScheme(String location) { String scheme = "file" // Q: how to get the scheme for windows? the Java URI class doesn't like spaces, the if we look for the first ":" // it may be a drive letter instead of a scheme/protocol // A: ignore colon if only one character before it if (location.indexOf(":") > 1) { String prefix = location.substring(0, location.indexOf(":")) if (!prefix.contains("/") && prefix.length() > 2) scheme = prefix } return scheme } @Override ResourceReference getUriReference(URI uri) { if (uri == null) return null // we care about 2 parts: scheme, path (use full scheme-specific part) String scheme = uri.getScheme() ?: "file" String ssPart = uri.getSchemeSpecificPart() return internalGetReference(scheme, scheme + ":" + ssPart) } private ResourceReference internalGetReference(String scheme, String location) { // version ignored for this call, just strip it int hashIdx = location.indexOf("#") if (hashIdx > 0) location = location.substring(0, hashIdx) ResourceReference cachedRr = resourceReferenceByLocation.get(location) if (cachedRr != null) return cachedRr Class rrClass = resourceReferenceClasses.get(scheme) if (rrClass == null) throw new BaseArtifactException("Prefix (${scheme}) not supported for location ${location}") ResourceReference rr = (ResourceReference) rrClass.newInstance() if (rr instanceof BaseResourceReference) { ((BaseResourceReference) rr).init(location, ecfi) } else { rr.init(location) } resourceReferenceByLocation.put(location, rr) return rr } @Override InputStream getLocationStream(String location) { if (location == null) return null int hashIdx = location.indexOf("#") String versionName = null if (hashIdx > 0) { if ((hashIdx+1) < location.length()) versionName = location.substring(hashIdx+1) location = location.substring(0, hashIdx) } ResourceReference rr = getLocationReference(location) if (rr == null) return null return rr.openStream(versionName) } @Override String getLocationText(String location, boolean cache) { if (location == null) return "" int hashIdx = location.indexOf("#") String versionName = (hashIdx > 0 && (hashIdx+1) < location.length()) ? location.substring(hashIdx+1) : null ResourceReference textRr = getLocationReference(location) if (textRr == null) { logger.info("Cound not get resource reference for location [${location}], returning empty location text String") return "" } // don't cache when getting by version if (versionName != null) cache = false if (cache) { String cachedText if (textLocationCache instanceof MCache) { MCache mCache = (MCache) textLocationCache // if we have a rr and last modified is newer than the cache entry then throw it out (expire when cached entry // updated time is older/less than rr.lastModified) cachedText = (String) mCache.get(location, textRr.getLastModified()) } else { // TODO: doesn't support on the fly reloading without cache expire/clear! cachedText = (String) textLocationCache.get(location) } if (cachedText != null) return cachedText } InputStream locStream = textRr.openStream(versionName) if (locStream == null) logger.info("Cannot get text, no resource found at location [${location}]") String text = ObjectUtilities.getStreamText(locStream) if (cache) textLocationCache.put(location, text) // logger.warn("==== getLocationText at ${location} version ${versionName} text ${text.length() > 100 ? text.substring(0, 100) : text}") return text } @Override DataSource getLocationDataSource(String location) { int hashIdx = location.indexOf("#") String versionName = null if (hashIdx > 0) { if ((hashIdx+1) < location.length()) versionName = location.substring(hashIdx+1) location = location.substring(0, hashIdx) } ResourceReference fileResourceRef = getLocationReference(location) TemplateRenderer tr = getTemplateRendererByLocation(fileResourceRef.location) String fileName = fileResourceRef.fileName // strip template extension(s) to avoid problems with trying to find content types based on them String fileContentType = getContentType(tr != null ? tr.stripTemplateExtension(fileName) : fileName) boolean isBinary = ResourceReference.isBinaryContentType(fileContentType) if (isBinary) { return new ByteArrayDataSource(fileResourceRef.openStream(versionName), fileContentType) } else { // not a binary object (hopefully), get the text and pass it over if (tr != null) { // NOTE: version ignored here StringWriter sw = new StringWriter() tr.render(fileResourceRef.location, sw) return new ByteArrayDataSource(sw.toString(), fileContentType) } else { // no renderer found, just grab the text (cached) and throw it to the writer String textLoc = fileResourceRef.location if (versionName != null && !versionName.isEmpty()) textLoc = textLoc.concat("#").concat(versionName) String text = getLocationText(textLoc, true) return new ByteArrayDataSource(text, fileContentType) } } } @Override void template(String location, Writer writer) { template(location, writer, null) } @Override void template(String location, Writer writer, String defaultExtension) { // NOTE: let version fall through to tr.render() and getLocationText() TemplateRenderer tr = getTemplateRendererByLocation(location) if ((tr == null || tr instanceof NoTemplateRenderer) && defaultExtension != null && !defaultExtension.isEmpty()) tr = getTemplateRendererByLocation(defaultExtension) // logger.info("location ${location} defaultExtension ${defaultExtension} tr ${tr?.class?.name}") if (tr != null) { tr.render(location, writer) } else { // no renderer found, just grab the text and throw it to the writer String text = getLocationText(location, true) if (text) writer.write(text) } } @Override String template(String location, String defaultExtension) { StringWriter sw = new StringWriter() template(location, sw, defaultExtension) return sw.toString() } static final Set binaryExtensions = new HashSet<>(["png", "jpg", "jpeg", "gif", "pdf", "doc", "docx", "xsl", "xslx"]) TemplateRenderer getTemplateRendererByLocation(String location) { int hashIdx = location.indexOf("#") if (hashIdx > 0) location = location.substring(0, hashIdx) // match against extension for template renderer, with as many dots that match as possible (most specific match) int lastSlashIndex = location.lastIndexOf("/") int dotIndex = location.indexOf(".", lastSlashIndex) String fullExt = location.substring(dotIndex + 1) TemplateRenderer tr = (TemplateRenderer) templateRenderers.get(fullExt) if (tr != null || templateRenderers.containsKey(fullExt)) return tr int lastDotIndex = location.lastIndexOf(".", lastSlashIndex) String lastExt = location.substring(lastDotIndex+ 1) if (binaryExtensions.contains(lastExt)) { templateRenderers.put(fullExt, null) return null } int mostDots = -1 int templateRendererExtensionsSize = templateRendererExtensions.size() for (int i = 0; i < templateRendererExtensionsSize; i++) { String ext = (String) templateRendererExtensions.get(i) if (location.endsWith(ext)) { int dots = templateRendererExtensionsDots.get(i).intValue() if (dots > mostDots) { mostDots = dots tr = (TemplateRenderer) templateRenderers.get(ext) } } } // if there is no template renderer for extension remember that if (tr == null) { // logger.warn("No renderer found for ${location}, exts: ${templateRendererExtensions}\ntemplateRenderers: ${templateRenderers}") templateRenderers.put(fullExt, null) } return tr } @Override Object script(String location, String method) { int hashIdx = location.indexOf("#") if (hashIdx > 0) location = location.substring(0, hashIdx) // NOTE: version ignored here ExecutionContextImpl ec = ecfi.getEci() String extension = location.substring(location.lastIndexOf(".")) ScriptRunner sr = scriptRunners.get(extension) if (sr != null) { return sr.run(location, method, ec) } else { // see if the extension is known ScriptEngine engine = scriptEngineManager.getEngineByExtension(extension) if (engine == null) throw new BaseArtifactException("Cannot run script [${location}], unknown extension (not in Moqui Conf file, and unkown to Java ScriptEngineManager).") return JavaxScriptRunner.bindAndRun(location, ec, engine, ecfi.cacheFacade.getCache("resource.script${extension}.location")) } } @Override Object script(String location, String method, Map additionalContext) { ExecutionContextImpl ec = ecfi.getEci() ContextStack cs = ec.contextStack boolean doPushPop = additionalContext != null && additionalContext.size() > 0 try { if (doPushPop) { if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap()) else cs.push(additionalContext) // do another push so writes to the context don't modify the passed in Map cs.push() } return script(location, method) } finally { if (doPushPop) { cs.pop(); cs.pop() } } } Object setInContext(String field, String from, String value, String defaultValue, String type, String setIfEmpty) { def tempValue = getValueFromContext(from, value, defaultValue, type) ecfi.getEci().contextStack.put("_tempValue", tempValue) if (tempValue || setIfEmpty) expression("${field} = _tempValue", "") return tempValue } Object getValueFromContext(String from, String value, String defaultValue, String type) { def tempValue = from ? expression(from, "") : expand(value, "", null, false) if (!tempValue && defaultValue) tempValue = expand(defaultValue, "", null, false) if (type) tempValue = ObjectUtilities.basicConvert(tempValue, type) return tempValue } @Override boolean condition(String expression, String debugLocation) { return conditionInternal(expression, debugLocation, ecfi.getEci()) } protected boolean conditionInternal(String expression, String debugLocation, ExecutionContextImpl ec) { if (expression == null || expression.isEmpty()) return false try { Script script = getGroovyScript(expression, ec) Object result = script.run() script.setBinding(null) return result as boolean } catch (Exception e) { throw new BaseArtifactException("Error in condition [${expression}] from [${debugLocation}]", e) } } @Override boolean condition(String expression, String debugLocation, Map additionalContext) { ExecutionContextImpl ec = ecfi.getEci() ContextStack cs = ec.contextStack boolean doPushPop = additionalContext != null && additionalContext.size() > 0 try { if (doPushPop) { if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap()) else cs.push(additionalContext) // do another push so writes to the context don't modify the passed in Map cs.push() } return conditionInternal(expression, debugLocation, ec) } finally { if (doPushPop) { cs.pop(); cs.pop() } } } @Override Object expression(String expression, String debugLocation) { return expressionInternal(expression, debugLocation, ecfi.getEci()) } protected Object expressionInternal(String expression, String debugLocation, ExecutionContextImpl ec) { if (expression == null || expression.isEmpty()) return null try { Script script = getGroovyScript(expression, ec) Object result = script.run() script.setBinding(null) return result } catch (Exception e) { throw new BaseArtifactException("Error in field expression [${expression}] from [${debugLocation}]", e) } } @Override Object expression(String expr, String debugLocation, Map additionalContext) { ExecutionContextImpl ec = ecfi.getEci() ContextStack cs = ec.contextStack boolean doPushPop = additionalContext != null && additionalContext.size() > 0 try { if (doPushPop) { if (additionalContext instanceof EntityValueBase) cs.push(((EntityValueBase) additionalContext).getValueMap()) else cs.push(additionalContext) // do another push so writes to the context don't modify the passed in Map // TODO: is this really necessary? is very memory inefficient; these expressions are meant to evaluate to a value, not generally to set anything cs.push() } return expressionInternal(expr, debugLocation, ec) } finally { if (doPushPop) { cs.pop(); cs.pop() } } } @Override String expandNoL10n(String inputString, String debugLocation) { return expand(inputString, debugLocation, null, false) } @Override String expand(String inputString, String debugLocation) { return expand(inputString, debugLocation, null, true) } @Override String expand(String inputString, String debugLocation, Map additionalContext) { return expand(inputString, debugLocation, additionalContext, true) } @Override String expand(String inputString, String debugLocation, Map additionalContext, boolean localize) { if (inputString == null) return "" int inputStringLength = inputString.length() if (inputStringLength == 0) return "" ExecutionContextImpl eci = (ExecutionContextImpl) null // localize string before expanding if (localize && inputStringLength < 256) { eci = ecfi.getEci() inputString = eci.l10nFacade.localize(inputString) } // if no $ or $ is the last character then it's a plain String, just return it int lastDollarSignIdx = inputString.lastIndexOf('$') if (lastDollarSignIdx == -1 || lastDollarSignIdx == (inputString.length()-1)) return inputString if (eci == null) eci = ecfi.getEci() boolean doPushPop = additionalContext != null && additionalContext.size() > 0 ContextStack cs = (ContextStack) null if (doPushPop) cs = eci.contextStack try { if (doPushPop) { if (additionalContext instanceof EntityValueBase) { cs.push(((EntityValueBase) additionalContext).getValueMap()) } else { cs.push(additionalContext) } // do another push so writes to the context don't modify the passed in Map cs.push() } String expression = '"""' + inputString + '"""' try { Script script = getGroovyScript(expression, eci) if (script == null) return "" Object result = script.run() script.setBinding(null) return result as String } catch (Exception e) { throw new BaseArtifactException("Error in string expression [${expression}] from ${debugLocation}", e) } } finally { if (doPushPop) { cs.pop(); cs.pop() } } } Script getGroovyScript(String expression, ExecutionContextImpl eci) { ContextBinding curBinding = eci.contextBindingInternal Map curScriptByExpr = (Map) threadScriptByExpression.get() if (curScriptByExpr == null) { curScriptByExpr = new HashMap() threadScriptByExpression.set(curScriptByExpr) } Script script = (Script) curScriptByExpr.get(expression) if (script == null) { script = InvokerHelper.createScript(getGroovyClass(expression), curBinding) curScriptByExpr.put(expression, script) } else { script.setBinding(curBinding) } return script } Class getGroovyClass(String expression) { if (expression == null || expression.isEmpty()) return null Class groovyClass = (Class) scriptGroovyExpressionCache.get(expression) if (groovyClass == null) { groovyClass = ecfi.compileGroovy(expression, StringUtilities.getExpressionClassName(expression)) scriptGroovyExpressionCache.put(expression, groovyClass) // logger.warn("class ${groovyClass.getName()} parsed expression ${expression}") } return groovyClass } @Override String getContentType(String filename) { return ResourceReference.getContentType(filename) } @Override Integer xslFoTransform(StreamSource xslFoSrc, StreamSource xsltSrc, OutputStream out, String contentType) { if (xslFoHandlerFactory == null) throw new BaseArtifactException("No XSL-FO Handler ToolFactory found (from resource-facade.@xsl-fo-handler-factory)") TransformerFactory factory = TransformerFactory.newInstance() factory.setURIResolver(new LocalResolver(ecfi, factory.getURIResolver())) Transformer transformer = xsltSrc == null ? factory.newTransformer() : factory.newTransformer(xsltSrc) transformer.setURIResolver(new LocalResolver(ecfi, transformer.getURIResolver())) final org.xml.sax.ContentHandler contentHandler = xslFoHandlerFactory.getInstance(out, contentType) // There's a ThreadLocal memory leak in XALANJ, reported in 2005 but still not fixed in 2016 // The memory it prevent GC depend on the fo file size and the thread pool size. So use a separate thread to workaround. // https://issues.apache.org/jira/browse/XALANJ-2195 BaseArtifactException transformException = null ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(ecfi.getEci(), { try { transformer.transform(xslFoSrc, new SAXResult(contentHandler)) } catch (Throwable t) { transformException = new BaseArtifactException("Error transforming XSL-FO to ${contentType}", t) } }) Thread transThread = new Thread(runnable) transThread.start() transThread.join() if (transformException != null) throw transformException try { Method pcMethod = xslFoHandlerFactory.class.getMethod("getPageCount", org.xml.sax.ContentHandler.class) return pcMethod.invoke(xslFoHandlerFactory, contentHandler) as Integer } catch (NoSuchMethodException e) { if (logger.isDebugEnabled()) logger.debug("xsl-fo transform factory has no getPageCount method, returning null for page count", e) return null } } @CompileStatic static class LocalResolver implements URIResolver { protected ExecutionContextFactoryImpl ecfi protected URIResolver defaultResolver LocalResolver(ExecutionContextFactoryImpl ecfi, URIResolver defaultResolver) { this.ecfi = ecfi this.defaultResolver = defaultResolver } Source resolve(String href, String base) { // try plain href ResourceReference rr = ecfi.resourceFacade.getLocationReference(href) // if href has no colon try base + href if (rr == null && href.indexOf(':') < 0) rr = ecfi.resourceFacade.getLocationReference(base + href) if (rr != null) { URL url = rr.getUrl() InputStream is = rr.openStream() if (is != null) { if (url != null) { return new StreamSource(is, url.toExternalForm()) } else { return new StreamSource(is) } } } return defaultResolver.resolve(href, base) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/TransactionCache.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityException import org.moqui.entity.EntityValue import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.entity.EntityFindBase import org.moqui.impl.entity.EntityJavaUtil import org.moqui.impl.entity.EntityListImpl import org.moqui.impl.entity.EntityValueBase import org.moqui.impl.entity.EntityJavaUtil.EntityWriteInfo import org.moqui.impl.entity.EntityJavaUtil.FindAugmentInfo import org.moqui.impl.entity.EntityJavaUtil.WriteMode import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.transaction.Synchronization import java.sql.Connection import javax.transaction.xa.XAException /** This is a per-transaction cache that basically pretends to be the database for the scope of the transaction. * Test your code well when using this as it doesn't support everything. * * See notes on limitations in the JavaDoc for ServiceCallSync.useTransactionCache() * */ @CompileStatic class TransactionCache implements Synchronization { protected final static Logger logger = LoggerFactory.getLogger(TransactionCache.class) protected ExecutionContextFactoryImpl ecfi private boolean readOnly private Map readOneCache = new HashMap<>() private Set knownLocked = new HashSet<>() private Map> readListCache = [:] private Map firstWriteInfoMap = new HashMap() private Map lastWriteInfoMap = new HashMap() private ArrayList writeInfoList = new ArrayList(50) private LinkedHashMap> createByEntityRef = new LinkedHashMap<>() TransactionCache(ExecutionContextFactoryImpl ecfi, boolean readOnly) { this.ecfi = ecfi this.readOnly = readOnly } boolean isReadOnly() { return readOnly } void makeReadOnly() { if (readOnly) return flushCache(false) readOnly = true } void makeWriteThrough() { readOnly = false } LinkedHashMap getCreateByEntityMap(String entityName) { LinkedHashMap createMap = createByEntityRef.get(entityName) if (createMap == null) { createMap = new LinkedHashMap<>() createByEntityRef.put(entityName, createMap) } return createMap } static Map makeKey(EntityValueBase evb) { if (evb == null) return null Map key = evb.getPrimaryKeys() if (!key) return null key.put("_entityName", evb.resolveEntityName()) return key } static Map makeKeyFind(EntityFindBase efb) { // NOTE: this should never come in null (EntityFindBase.one() => oneGet() => this is only call path) if (efb == null) return null Map key = efb.getSimpleMapPrimaryKeys() if (!key) return null key.put("_entityName", efb.getEntityDef().getFullEntityName()) return key } void addWriteInfo(Map key, EntityWriteInfo newEwi) { writeInfoList.add(newEwi) if (!firstWriteInfoMap.containsKey(key)) firstWriteInfoMap.put(key, newEwi) lastWriteInfoMap.put(key, newEwi) } /** Returns true if create handled, false if not; if false caller should handle the operation */ boolean create(EntityValueBase evb) { Map key = makeKey(evb) if (key == null) return false if (!readOnly) { // if create info already exists blow up EntityWriteInfo currentEwi = lastWriteInfoMap.get(key) if (readOneCache.get(key) != null) throw new EntityException("Tried to create a value that already exists in database, entity ${evb.resolveEntityName()}, PK ${evb.getPrimaryKeys()}") if (currentEwi != null && currentEwi.writeMode != WriteMode.DELETE) throw new EntityException("Tried to create a value that already exists in write cache, entity ${evb.resolveEntityName()}, PK ${evb.getPrimaryKeys()}") EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.CREATE) addWriteInfo(key, newEwi) if (currentEwi == null || currentEwi.writeMode != WriteMode.DELETE) { getCreateByEntityMap(evb.resolveEntityName()).put(evb.getPrimaryKeys(), evb) } } // add to readCache after so we don't think it already exists readOneCache.put(key, evb) // add to any matching list cache entries Map entityListCache = readListCache.get(evb.resolveEntityName()) if (entityListCache != null) { for (Map.Entry entry in entityListCache.entrySet()) { if (entry.getKey().mapMatches(evb)) entry.getValue().add(evb) } } // consider created records locked to avoid forUpdate queries knownLocked.add(key) return !readOnly } boolean update(EntityValueBase evb) { Map key = makeKey(evb) if (key == null) return false if (!readOnly) { // with writeInfoList as plain list approach no need to look for existing create or update, just add to the list if (!evb.getIsFromDb()) { EntityValueBase cacheEvb = readOneCache.get(key) if (cacheEvb != null) { cacheEvb.setFields(evb, true, null, false) evb = cacheEvb } else { EntityValueBase dbEvb = (EntityValueBase) evb.cloneValue() dbEvb.refresh() dbEvb.setFields(evb, true, null, false) logger.warn("====== tx cache update not from db\nevb: ${evb}\ndbEvb: ${dbEvb}") evb = dbEvb } } EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.UPDATE) addWriteInfo(key, newEwi) } // add to readCache if (evb.getIsFromDb()) { readOneCache.put(key, evb) } else { // not from DB, may have partial values so find existing and put all from valueMap EntityValueBase existingEv = readOneCache.get(key) if (existingEv != null) { existingEv.putAll(evb) } else { // NOTE: should put a not from DB value if not read only? if read only definitely no if (!readOnly) readOneCache.put(key, evb) } } // NOTE: issue here if the evb is partial, not full from DB/cache, and doesn't have field value that would match; solve higher up by getting full value? // update any matching list cache entries, add to list cache if not there (though generally should be, depending on the condition) Map entityListCache = readListCache.get(evb.resolveEntityName()) if (entityListCache != null) { for (Map.Entry entry in entityListCache.entrySet()) { if (entry.getKey().mapMatches(evb)) { // find an existing entry and update it boolean foundEntry = false EntityListImpl eli = entry.getValue() int eliSize = eli.size() for (int i = 0; i < eliSize; i++) { EntityValueBase existingEv = (EntityValueBase) eli.get(i) if (evb.primaryKeyMatches(existingEv)) { existingEv.putAll(evb) foundEntry = true } } // if no existing entry found add this if (!foundEntry) entry.getValue().add(evb) } } } knownLocked.add(key) return !readOnly } boolean delete(EntityValueBase evb) { Map key = makeKey(evb) if (key == null) return false // logger.warn("txc delete ${key}") if (!readOnly) { EntityWriteInfo currentEwi = firstWriteInfoMap.get(key) if (currentEwi != null && currentEwi.writeMode == WriteMode.CREATE) { // if was created in TX cache but never written to DB just clear all changes firstWriteInfoMap.remove(key) lastWriteInfoMap.remove(key) for (int i = 0; i < writeInfoList.size(); ) { EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i) if (key.equals(makeKey(ewi.evb))) { writeInfoList.remove(i) } else { i++ } } getCreateByEntityMap(evb.resolveEntityName()).remove(evb.getPrimaryKeys()) } else { EntityWriteInfo newEwi = new EntityWriteInfo(evb, WriteMode.DELETE) addWriteInfo(key, newEwi) } } // remove from readCache if needed readOneCache.remove(key) // remove any matching list cache entries Map entityListCache = readListCache.get(evb.resolveEntityName()) if (entityListCache != null) { for (Map.Entry entry in entityListCache.entrySet()) { if (entry.getKey().mapMatches(evb)) { Iterator existingEvIter = entry.getValue().iterator() while (existingEvIter.hasNext()) { EntityValue existingEv = (EntityValue) existingEvIter.next() if (evb.getPrimaryKeys() == existingEv.getPrimaryKeys()) existingEvIter.remove() } } } } knownLocked.add(key) return !readOnly } boolean refresh(EntityValueBase evb) { Map key = makeKey(evb) if (key == null) return false EntityValueBase curEvb = readOneCache.get(key) if (curEvb != null) { ArrayList nonPkFieldList = evb.getEntityDefinition().getNonPkFieldNames() int nonPkSize = nonPkFieldList.size() for (int j = 0; j < nonPkSize; j++) { String fieldName = nonPkFieldList.get(j) evb.getValueMap().put(fieldName, curEvb.getValueMap().get(fieldName)) } evb.setSyncedWithDb() return true } else { return false } } boolean isTxCreate(EntityValueBase evb) { if (readOnly || writeInfoList.size() == 0) return false Map key = makeKey(evb) if (key == null) return false return isTxCreate(key) } protected boolean isTxCreate(Map key) { if (readOnly || writeInfoList.size() == 0) return false EntityWriteInfo currentEwi = firstWriteInfoMap.get(key) if (currentEwi == null) return false return currentEwi.writeMode == WriteMode.CREATE } boolean isKnownLocked(EntityValueBase evb) { if (readOnly || knownLocked.size() == 0) return false Map key = makeKey(evb) if (key == null) return false return knownLocked.contains(key) } EntityValueBase oneGet(EntityFindBase efb) { // NOTE: do nothing here on forUpdate, handled by caller Map key = makeKeyFind(efb) if (key == null) return null if (!readOnly) { // if this has been deleted return a DeletedEntityValue instance so caller knows it was deleted and doesn't look in the DB for another record EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key) if (currentEwi != null && currentEwi.writeMode == WriteMode.DELETE) return new EntityValueBase.DeletedEntityValue(efb.getEntityDef(), ecfi.entityFacade) } // cloneValue() so that updates aren't in the read cache until an update is done EntityValueBase evb = (EntityValueBase) readOneCache.get(key)?.cloneValue() return evb } void onePut(EntityValueBase evb, boolean forUpdate) { Map key = makeKey(evb) if (key == null) return EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key) // if this has been deleted we don't want to add it, but in general if we have a ewi then it's already in the // cache and we don't want to update from this (generally from DB and may be older than value already there) // clone the value before putting it into the cache so that the caller can't change it later with an update call if (currentEwi == null || currentEwi.writeMode != WriteMode.DELETE) readOneCache.put(key, (EntityValueBase) evb.cloneValue()) // if (evb.getEntityDefinition().getEntityName() == "Asset") logger.warn("=========== onePut of Asset ${evb.get('assetId')}", new Exception("Location")) if (forUpdate) knownLocked.add(key) } EntityListImpl listGet(EntityDefinition ed, EntityCondition whereCondition, List orderByExpanded) { Map entityListCache = readListCache.get(ed.getFullEntityName()) // always clone this so that filters/sorts/etc by callers won't change this EntityListImpl cacheList = entityListCache != null ? entityListCache.get(whereCondition)?.deepCloneList() : null // if we are searching by a field that is a PK on a related entity to the one being searched it can only exist // in the read cache so find here and don't bother with a DB query if (cacheList == null) { // if the condition depends on a record that was created in this tx cache, then build the list from here // instead of letting it drop to the DB, finding nothing, then being expanded from the txCache Map condMap = new LinkedHashMap<>() if (whereCondition != null && whereCondition.populateMap(condMap)) { boolean foundCreatedDependent = false for (EntityJavaUtil.RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue // would be nice to skip this, but related-entity-name may not be full entity name EntityDefinition relEd = relInfo.relatedEd String relEntityName = relEd.getFullEntityName() // first see if there is a create Map for this, then do the more expensive operation of getting the // expanded key Map and the related entity's PK Map Map relCreateMap = getCreateByEntityMap(relEntityName) if (relCreateMap) { Map relKeyMap = relInfo.keyMap Map relPk = [:] boolean foundAllPks = true for (Map.Entry entry in relKeyMap.entrySet()) { Object relValue = condMap.get(entry.getKey()) if (relValue) relPk.put(entry.getValue(), relValue) else foundAllPks = false } // if (ed.getFullEntityName().contains("OrderItem")) logger.warn("==== listGet ${relEntityName} foundAllPks=${foundAllPks} relPk=${relPk} relCreateMap=${relCreateMap}") if (!foundAllPks) continue if (relCreateMap.containsKey(relPk)) { foundCreatedDependent = true break } } } if (foundCreatedDependent) { EntityListImpl createdValueList = new EntityListImpl(ecfi.entityFacade) Map createMap = createByEntityRef.get(ed.getFullEntityName()) if (createMap != null) { for (Object createEvbObj in createMap.values()) { if (createEvbObj instanceof EntityValueBase) { EntityValueBase createEvb = (EntityValueBase) createEvbObj if (whereCondition.mapMatches(createEvb)) createdValueList.add(createEvb) } } } if (createdValueList.size() > 0) { listPut(ed, whereCondition, createdValueList) cacheList = createdValueList.deepCloneList() } } } } if (cacheList && orderByExpanded) cacheList.orderByFields(orderByExpanded) return cacheList } Map getEntityListCache(String entityName) { Map entityListCache = readListCache.get(entityName) if (entityListCache == null) { entityListCache = [:] readListCache.put(entityName, entityListCache) } return entityListCache } void listPut(EntityDefinition ed, EntityCondition whereCondition, EntityListImpl eli) { if (eli.isFromCache()) return Map entityListCache = getEntityListCache(ed.getFullEntityName()) // don't need to do much else here; list will already have values created/updated/deleted in this TX Cache entityListCache.put(whereCondition, (EntityListImpl) eli.cloneList()) } // NOTE: no need to filter EntityList or EntityListIterator, they do it internally by calling this method WriteMode checkUpdateValue(EntityValueBase evb, FindAugmentInfo fai) { Map key = makeKey(evb) if (key == null) return null EntityWriteInfo firstEwi = (EntityWriteInfo) firstWriteInfoMap.get(key) EntityWriteInfo currentEwi = (EntityWriteInfo) lastWriteInfoMap.get(key) if (currentEwi == null) { // add to readCache for future reference onePut(evb, false) return null } if (WriteMode.CREATE.is(firstEwi.writeMode)) { throw new EntityException("Found value from database that matches a value created in the write-through transaction cache, throwing error now instead of waiting to fail on commit") } if (WriteMode.UPDATE.is(currentEwi.writeMode)) { if (fai != null && ((fai.econd != null && !fai.econd.mapMatches(currentEwi.evb)) || fai.foundUpdated.contains(currentEwi.evb.getPrimaryKeys()))) { // current value no longer matches, tell ELII to skip it (same as DELETE) return WriteMode.DELETE } evb.setFields(currentEwi.evb, true, null, false) // add to readCache onePut(evb, false) } return currentEwi.writeMode } FindAugmentInfo getFindAugmentInfo(String entityName, EntityCondition econd) { ArrayList valueList = new ArrayList<>() // also get values that have been updated so that they should now be included in the list Set foundUpdated = new HashSet<>() if (econd != null) { int writeInfoListSize = writeInfoList.size() // go through backwards to get the most recent only for (int i = (writeInfoListSize - 1); i >= 0 ; i--) { EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i) if (WriteMode.UPDATE.is(ewi.writeMode) && entityName.equals(ewi.evb.resolveEntityName()) && econd.mapMatches(ewi.evb)) { Map pkMap = ewi.evb.getPrimaryKeys() if (!foundUpdated.contains(pkMap)) { foundUpdated.add(pkMap) valueList.add(ewi.evb) } } } } Map createMap = getCreateByEntityMap(entityName) if (createMap.size() > 0) for (EntityValueBase evb in createMap.values()) { if (econd.mapMatches(evb) && (foundUpdated.size() == 0 || !foundUpdated.contains(evb.getPrimaryKeys()))) valueList.add(evb) } // if (entityName.contains("OrderPart")) logger.warn("OP tx cache list: ${valueList}") return new FindAugmentInfo(valueList, foundUpdated, econd) } void flushCache(boolean clearRead) { Map connectionByGroup = new HashMap<>() try { int writeInfoListSize = writeInfoList.size() if (writeInfoListSize > 0) { // logger.error("Tx cache flush at", new BaseException("txc flush")) EntityFacadeImpl efi = ecfi.entityFacade long startTime = System.currentTimeMillis() int createCount = 0 int updateCount = 0 int deleteCount = 0 // for (EntityWriteInfo ewi in writeInfoList) logger.warn("===== TX Cache value to ${ewi.writeMode} ${ewi.evb.resolveEntityName()}: \n${ewi.evb}") if (readOnly && writeInfoListSize > 0) logger.warn("Read only TX cache has ${writeInfoListSize} values to write") for (int i = 0; i < writeInfoListSize; i++) { EntityWriteInfo ewi = (EntityWriteInfo) writeInfoList.get(i) String groupName = ewi.evb.getEntityDefinition().getEntityGroupName() Connection con = connectionByGroup.get(groupName) if (con == null) { con = efi.getConnection(groupName) connectionByGroup.put(groupName, con) } if (ewi.writeMode.is(WriteMode.CREATE)) { ewi.evb.basicCreate(con) createCount++ } else if (ewi.writeMode.is(WriteMode.DELETE)) { ewi.evb.deleteExtended(con) deleteCount++ } else { ewi.evb.basicUpdate(con) updateCount++ } } if (logger.isDebugEnabled()) logger.debug("Flushed TransactionCache in ${System.currentTimeMillis() - startTime}ms: ${createCount} creates, ${updateCount} updates, ${deleteCount} deletes, ${readOneCache.size()} read entries, ${readListCache.size()} entities with list cache") } writeInfoList.clear() firstWriteInfoMap.clear() lastWriteInfoMap.clear() createByEntityRef.clear() if (clearRead) { readOneCache.clear() readListCache.clear() // set to readOnly to avoid any other write through readOnly = true } } catch (Throwable t) { logger.error("Error writing values from TransactionCache: ${t.toString()}", t) throw new XAException("Error writing values from TransactionCache: + ${t.toString()}") } finally { // now close connections for (Connection con in connectionByGroup.values()) con.close() } } @Override void beforeCompletion() { flushCache(true) } @Override void afterCompletion(int i) { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/TransactionFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.TransactionException import org.moqui.context.TransactionFacade import org.moqui.context.TransactionInternal import org.moqui.impl.context.ContextJavaUtil.ConnectionWrapper import org.moqui.impl.context.ContextJavaUtil.EntityRecordLock import org.moqui.impl.context.ContextJavaUtil.RollbackInfo import org.moqui.impl.context.ContextJavaUtil.TxStackInfo import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.naming.Context import javax.naming.InitialContext import javax.naming.NamingException import javax.sql.XAConnection import jakarta.transaction.* import javax.transaction.xa.XAException import javax.transaction.xa.XAResource import java.sql.* import java.util.concurrent.ConcurrentHashMap @CompileStatic class TransactionFacadeImpl implements TransactionFacade { protected final static Logger logger = LoggerFactory.getLogger(TransactionFacadeImpl.class) protected final static boolean isTraceEnabled = logger.isTraceEnabled() protected final ExecutionContextFactoryImpl ecfi protected TransactionInternal transactionInternal = null protected UserTransaction ut protected TransactionManager tm protected boolean useTransactionCache = true protected boolean useConnectionStash = true protected boolean useLockTrack = false protected boolean useStatementTimeout = false private ThreadLocal txStackInfoCurThread = new ThreadLocal() private ThreadLocal> txStackInfoListThread = new ThreadLocal>() protected final ConcurrentHashMap> recordLockByEntityPk = new ConcurrentHashMap<>() TransactionFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi MNode transactionFacadeNode = ecfi.getConfXmlRoot().first("transaction-facade") transactionFacadeNode.setSystemExpandAttributes(true) useLockTrack = "true".equals(transactionFacadeNode.attribute("use-lock-track")) useStatementTimeout = "true".equals(transactionFacadeNode.attribute("use-statement-timeout")) if (transactionFacadeNode.hasChild("transaction-jndi")) { this.populateTransactionObjectsJndi() } else if (transactionFacadeNode.hasChild("transaction-internal")) { // initialize internal MNode transactionInternalNode = transactionFacadeNode.first("transaction-internal") String tiClassName = transactionInternalNode.attribute("class") transactionInternal = (TransactionInternal) Thread.currentThread().getContextClassLoader() .loadClass(tiClassName).newInstance() transactionInternal.init(ecfi) ut = transactionInternal.getUserTransaction() tm = transactionInternal.getTransactionManager() logger.info("Internal transaction manager initialized: UserTransaction class ${ut?.class?.name}, TransactionManager class ${tm?.class?.name}") } else { throw new IllegalArgumentException("No transaction-jndi or transaction-internal elements found in Moqui Conf XML file") } if (transactionFacadeNode.attribute("use-transaction-cache") == "false") useTransactionCache = false if (transactionFacadeNode.attribute("use-connection-stash") == "false") useConnectionStash = false } void destroy() { // set to null first to avoid additional operations this.tm = null this.ut = null // destroy internal if applicable; nothing for JNDI if (transactionInternal != null) transactionInternal.destroy() txStackInfoCurThread.remove() txStackInfoListThread.remove() } /** This is called to make sure all transactions, etc are closed for the thread. * It commits any active transactions, clears out internal data for the thread, etc. */ void destroyAllInThread() { if (isTransactionInPlace()) { logger.warn("Thread ending with a transaction in place. Trying to commit.") commit() } LinkedList txStackInfoList = txStackInfoListThread.get() if (txStackInfoList) { int numSuspended = 0 for (TxStackInfo txStackInfo in txStackInfoList) { Transaction tx = txStackInfo.suspendedTx if (tx != null) { resume() commit() numSuspended++ } } if (numSuspended > 0) logger.warn("Cleaned up [" + numSuspended + "] suspended transactions.") } txStackInfoCurThread.remove() txStackInfoListThread.remove() } boolean getUseLockTrack() { return useLockTrack } boolean getUseStatementTimeout() { return useStatementTimeout } TransactionInternal getTransactionInternal() { return transactionInternal } TransactionManager getTransactionManager() { return tm } UserTransaction getUserTransaction() { return ut } Long getCurrentTransactionStartTime() { TxStackInfo txStackInfo = getTxStackInfo() Long time = txStackInfo != null ? (Long) txStackInfo.transactionBeginStartTime : (Long) null if (time == null && isTraceEnabled) logger.trace("No transaction begin start time, transaction in place? [${this.isTransactionInPlace()}]", new BaseException("Empty transactionBeginStackList location")) return time } protected LinkedList getTxStackInfoList() { LinkedList list = (LinkedList) txStackInfoListThread.get() if (list == null) { list = new LinkedList() txStackInfoListThread.set(list) TxStackInfo txStackInfo = new TxStackInfo(this) list.add(txStackInfo) txStackInfoCurThread.set(txStackInfo) } return list } protected TxStackInfo getTxStackInfo() { TxStackInfo txStackInfo = (TxStackInfo) txStackInfoCurThread.get() if (txStackInfo == null) { LinkedList list = getTxStackInfoList() txStackInfo = list.getFirst() } return txStackInfo } protected void pushTxStackInfo(Transaction tx, Exception txLocation) { TxStackInfo txStackInfo = new TxStackInfo(this) txStackInfo.suspendedTx = tx txStackInfo.suspendedTxLocation = txLocation getTxStackInfoList().addFirst(txStackInfo) txStackInfoCurThread.set(txStackInfo) } protected void popTxStackInfo() { LinkedList list = getTxStackInfoList() list.removeFirst() txStackInfoCurThread.set(list.getFirst()) } @Override Object runUseOrBegin(Integer timeout, String rollbackMessage, Closure closure) { if (rollbackMessage == null) rollbackMessage = "" boolean beganTransaction = begin(timeout) try { return closure.call() } catch (Throwable t) { rollback(beganTransaction, rollbackMessage, t) throw t } finally { commit(beganTransaction) } } @Override Object runRequireNew(Integer timeout, String rollbackMessage, Closure closure) { return runRequireNew(timeout, rollbackMessage, true, true, closure) } protected final static boolean requireNewThread = true Object runRequireNew(Integer timeout, String rollbackMessage, boolean beginTx, boolean threadReuseEci, Closure closure) { Object result = null if (requireNewThread) { // if there is a timeout for this thread wait 10x the timeout (so multiple seconds by 10k instead of 1k) long threadWait = timeout != null ? timeout * 10000 : 60000 Thread txThread = null ExecutionContextImpl eci = ecfi.getEci() Throwable threadThrown = null try { txThread = Thread.start('RequireNewTx', { if (threadReuseEci) ecfi.useExecutionContextInThread(eci) try { if (beginTx) { result = runUseOrBegin(timeout, rollbackMessage, closure) } else { result = closure.call() } } catch (Throwable t) { threadThrown = t } }) } finally { if (txThread != null) { txThread.join(threadWait) if (txThread.state != Thread.State.TERMINATED) { // TODO: do more than this? logger.warn("New transaction thread not terminated, in state ${txThread.state}") } } } if (threadThrown != null) throw threadThrown } else { boolean suspendedTransaction = false try { if (isTransactionInPlace()) suspendedTransaction = suspend() if (beginTx) { result = runUseOrBegin(timeout, rollbackMessage, closure) } else { result = closure.call() } } finally { if (suspendedTransaction) resume() } } return result } @Override XAResource getActiveXaResource(String resourceName) { return getTxStackInfo().getActiveXaResourceMap().get(resourceName) } @Override void putAndEnlistActiveXaResource(String resourceName, XAResource xar) { enlistResource(xar) getTxStackInfo().getActiveXaResourceMap().put(resourceName, xar) } @Override Synchronization getActiveSynchronization(String syncName) { return getTxStackInfo().getActiveSynchronizationMap().get(syncName) } @Override void putAndEnlistActiveSynchronization(String syncName, Synchronization sync) { registerSynchronization(sync) getTxStackInfo().getActiveSynchronizationMap().put(syncName, sync) } @Override int getStatus() { if (ut == null) return Status.STATUS_NO_TRANSACTION try { return ut.getStatus() } catch (SystemException e) { throw new TransactionException("System error, could not get transaction status", e) } } @Override String getStatusString() { int statusInt = getStatus() /* * javax.transaction.Status * STATUS_ACTIVE 0 * STATUS_MARKED_ROLLBACK 1 * STATUS_PREPARED 2 * STATUS_COMMITTED 3 * STATUS_ROLLEDBACK 4 * STATUS_UNKNOWN 5 * STATUS_NO_TRANSACTION 6 * STATUS_PREPARING 7 * STATUS_COMMITTING 8 * STATUS_ROLLING_BACK 9 */ switch (statusInt) { case Status.STATUS_ACTIVE: return "Active (${statusInt})" case Status.STATUS_COMMITTED: return "Committed (${statusInt})" case Status.STATUS_COMMITTING: return "Committing (${statusInt})" case Status.STATUS_MARKED_ROLLBACK: return "Marked Rollback-Only (${statusInt})" case Status.STATUS_NO_TRANSACTION: return "No Transaction (${statusInt})" case Status.STATUS_PREPARED: return "Prepared (${statusInt})" case Status.STATUS_PREPARING: return "Preparing (${statusInt})" case Status.STATUS_ROLLEDBACK: return "Rolledback (${statusInt})" case Status.STATUS_ROLLING_BACK: return "Rolling Back (${statusInt})" case Status.STATUS_UNKNOWN: return "Status Unknown (${statusInt})" default: return "Not a valid status code (${statusInt})" } } @Override boolean isTransactionInPlace() { getStatus() != Status.STATUS_NO_TRANSACTION } boolean isTransactionActive() { getStatus() == Status.STATUS_ACTIVE } boolean isTransactionOperable() { int curStatus = getStatus() return curStatus == Status.STATUS_ACTIVE || curStatus == Status.STATUS_NO_TRANSACTION } int getTransactionTimeout() { return getTxStackInfo().transactionTimeout } long getTxTimeoutRemainingMillis() { TxStackInfo txStackInfo = getTxStackInfo() long txTimeoutMs = txStackInfo.transactionTimeout * 1000L long txSinceBeginMs = txStackInfo.transactionBeginStartTime != null ? System.currentTimeMillis() - txStackInfo.transactionBeginStartTime : 0L return txSinceBeginMs > 0 ? txTimeoutMs - txSinceBeginMs : txTimeoutMs } @Override boolean begin(Integer timeout) { if (ut == null) throw new IllegalStateException("No transaction manager in place") int currentStatus = ut.getStatus() // logger.warn("================ begin TX, currentStatus=${currentStatus}", new BaseException("beginning transaction at")) if (currentStatus == Status.STATUS_ACTIVE) { // don't begin, and return false so caller knows we didn't return false } else if (currentStatus == Status.STATUS_MARKED_ROLLBACK) { TxStackInfo txStackInfo = getTxStackInfo() if (txStackInfo.transactionBegin != null) { logger.warn("Current transaction marked for rollback, so no transaction begun. This stack trace shows where the transaction began: ", txStackInfo.transactionBegin) } else { logger.warn("Current transaction marked for rollback, so no transaction begun (NOTE: No stack trace to show where transaction began).") } if (txStackInfo.rollbackOnlyInfo != null) { logger.warn("Current transaction marked for rollback, not beginning a new transaction. The rollback-only was set here: ", txStackInfo.rollbackOnlyInfo.rollbackLocation) throw new TransactionException((String) "Current transaction marked for rollback, so no transaction begun. The rollback was originally caused by: " + txStackInfo.rollbackOnlyInfo.causeMessage, txStackInfo.rollbackOnlyInfo.causeThrowable) } else { return false } } try { // NOTE: Since JTA 1.1 setTransactionTimeout() is local to the thread, so this doesn't need to be synchronized. if (timeout != null) ut.setTransactionTimeout(timeout) ut.begin() TxStackInfo txStackInfo = getTxStackInfo() txStackInfo.transactionBegin = new Exception("Tx Begin Placeholder") txStackInfo.transactionBeginStartTime = System.currentTimeMillis() if (timeout != null) txStackInfo.transactionTimeout = timeout // logger.warn("================ begin TX, getActiveSynchronizationStack()=${getActiveSynchronizationStack()}") if (txStackInfo.txCache != null) logger.warn("Begin TX, tx cache is not null!") /* FUTURE: this is an interesting possibility, always use tx cache in read only mode, but currently causes issues (needs more work with cache clear, etc) if (useTransactionCache) { txStackInfo.txCache = new TransactionCache(ecfi, true) registerSynchronization(txStackInfo.txCache) } */ return true } catch (NotSupportedException e) { throw new TransactionException("Could not begin transaction (could be a nesting problem)", e) } catch (SystemException e) { throw new TransactionException("Could not begin transaction", e) } finally { // make sure the timeout always gets reset to the default if (timeout != null) ut.setTransactionTimeout(0) } } @Override void commit(boolean beganTransaction) { if (beganTransaction) this.commit() } @Override void commit() { if (ut == null) throw new IllegalStateException("No transaction manager in place") TxStackInfo txStackInfo = getTxStackInfo() try { int status = ut.getStatus() // logger.warn("================ commit TX, currentStatus=${status}") txStackInfo.closeTxConnections() if (status == Status.STATUS_MARKED_ROLLBACK) { if (txStackInfo.rollbackOnlyInfo != null) { logger.warn("Tried to commit transaction but marked rollback only, doing rollback instead; rollback-only was set here:", txStackInfo.rollbackOnlyInfo.rollbackLocation) } else { logger.warn("Tried to commit transaction but marked rollback only, doing rollback instead; no rollback-only info, current location:", new BaseException("Rollback instead of commit location")) } ut.rollback() } else if (status != Status.STATUS_NO_TRANSACTION && status != Status.STATUS_COMMITTING && status != Status.STATUS_COMMITTED && status != Status.STATUS_ROLLING_BACK && status != Status.STATUS_ROLLEDBACK) { ut.commit() } else { if (status != Status.STATUS_NO_TRANSACTION) logger.warn((String) "Not committing transaction because status is " + getStatusString(), new Exception("Bad TX status location")) } } catch (RollbackException e) { if (txStackInfo.rollbackOnlyInfo != null) { logger.warn("Could not commit transaction, was marked rollback-only. The rollback-only was set here: ", txStackInfo.rollbackOnlyInfo.rollbackLocation) throw new TransactionException("Could not commit transaction, was marked rollback-only. The rollback was originally caused by: " + txStackInfo.rollbackOnlyInfo.causeMessage, txStackInfo.rollbackOnlyInfo.causeThrowable) } else { throw new TransactionException("Could not commit transaction, was rolled back instead (and we don't have a rollback-only cause)", e) } } catch (IllegalStateException e) { throw new TransactionException("Could not commit transaction", e) } catch (HeuristicMixedException e) { throw new TransactionException("Could not commit transaction", e) } catch (HeuristicRollbackException e) { throw new TransactionException("Could not commit transaction", e) } catch (SystemException e) { throw new TransactionException("Could not commit transaction", e) } finally { // there shouldn't be a TX around now, but if there is the commit may have failed so rollback to clean things up if (ut != null) { int status = ut.getStatus() if (status != Status.STATUS_NO_TRANSACTION && status != Status.STATUS_COMMITTING && status != Status.STATUS_COMMITTED && status != Status.STATUS_ROLLING_BACK && status != Status.STATUS_ROLLEDBACK) { rollback("Commit failed, rolling back to clean up", null) } } txStackInfo.clearCurrent() } } @Override void rollback(boolean beganTransaction, String causeMessage, Throwable causeThrowable) { if (beganTransaction) { this.rollback(causeMessage, causeThrowable) } else { this.setRollbackOnly(causeMessage, causeThrowable) } } @Override void rollback(String causeMessage, Throwable causeThrowable) { if (ut == null) throw new IllegalStateException("No transaction manager in place") TxStackInfo txStackInfo = getTxStackInfo() try { txStackInfo.closeTxConnections() // logger.warn("================ rollback TX, currentStatus=${getStatus()}") if (getStatus() == Status.STATUS_NO_TRANSACTION) { logger.warn("Transaction not rolled back, status is STATUS_NO_TRANSACTION") return } if (causeThrowable != null) { String causeString = causeThrowable.toString() if (causeString.contains("org.eclipse.jetty.io.EofException")) { logger.warn("Transaction rollback. The rollback was originally caused by: ${causeMessage}\n${causeString}") } else { logger.warn("Transaction rollback. The rollback was originally caused by: ${causeMessage}", causeThrowable) logger.warn("Transaction rollback for [${causeMessage}]. Here is the current location: ", new BaseException("Rollback location")) } } else { logger.warn("Transaction rollback for [${causeMessage}]. Here is the current location: ", new BaseException("Rollback location")) } ut.rollback() } catch (IllegalStateException e) { throw new TransactionException("Could not rollback transaction", e) } catch (SystemException e) { throw new TransactionException("Could not rollback transaction", e) } finally { // NOTE: should this really be in finally? maybe we only want to do this if there is a successful rollback // to avoid removing things that should still be there, or maybe here in finally it will match up the adds // and removes better txStackInfo.clearCurrent() } } @Override void setRollbackOnly(String causeMessage, Throwable causeThrowable) { if (ut == null) throw new IllegalStateException("No transaction manager in place") try { int status = getStatus() if (status != Status.STATUS_NO_TRANSACTION) { if (status != Status.STATUS_MARKED_ROLLBACK) { Exception rbLocation = new BaseException("Set rollback only location") if (causeThrowable != null) { String causeString = causeThrowable.toString() if (causeString.contains("org.eclipse.jetty.io.EofException")) { logger.warn("Transaction set rollback only. The rollback was originally caused by: ${causeMessage}\n${causeString}") } else { logger.warn("Transaction set rollback only. The rollback was originally caused by: ${causeMessage}", causeThrowable) logger.warn("Transaction set rollback only for [${causeMessage}]. Here is the current location: ", rbLocation) } } else { logger.warn("Transaction rollback for [${causeMessage}]. Here is the current location: ", rbLocation) } ut.setRollbackOnly() // do this after setRollbackOnly so it only tracks it if rollback-only was actually set getTxStackInfo().rollbackOnlyInfo = new RollbackInfo(causeMessage, causeThrowable, rbLocation) } } else { logger.warn("Rollback only not set on current transaction, status is STATUS_NO_TRANSACTION") } } catch (IllegalStateException e) { throw new TransactionException("Could not set rollback only on current transaction", e) } catch (SystemException e) { throw new TransactionException("Could not set rollback only on current transaction", e) } } @Override boolean suspend() { if (ut == null) throw new IllegalStateException("No transaction manager in place") try { if (getStatus() == Status.STATUS_NO_TRANSACTION) { logger.warn("No transaction in place so not suspending") return false } // close connections before suspend, let the pool reuse them TxStackInfo txStackInfo = getTxStackInfo() txStackInfo.closeTxConnections() Transaction tx = tm.suspend() // only do these after successful suspend pushTxStackInfo(tx, new Exception("Transaction Suspend Location")) return true } catch (SystemException e) { throw new TransactionException("Could not suspend transaction", e) } } @Override void resume() { if (ut == null) throw new IllegalStateException("No transaction manager in place") if (isTransactionInPlace()) { logger.warn("Resume with transaction in place, trying commit to close") commit() } try { TxStackInfo txStackInfo = getTxStackInfo() if (txStackInfo.suspendedTx != null) { tm.resume(txStackInfo.suspendedTx) // only do this after successful resume popTxStackInfo() } else { logger.warn("No transaction suspended, so not resuming") } } catch (InvalidTransactionException e) { throw new TransactionException("Could not resume transaction", e) } catch (SystemException e) { throw new TransactionException("Could not resume transaction", e) } } @Override Connection enlistConnection(XAConnection con) { if (con == null) return null try { XAResource resource = con.getXAResource() this.enlistResource(resource) return con.getConnection() } catch (SQLException e) { throw new TransactionException("Could not enlist connection in transaction", e) } } @Override void enlistResource(XAResource resource) { if (resource == null) return if (getStatus() != Status.STATUS_ACTIVE) { logger.warn("Not enlisting XAResource: transaction not ACTIVE", new Exception("Warning Location")) return } try { Transaction tx = tm.getTransaction() if (tx != null) { tx.enlistResource(resource) } else { logger.warn("Not enlisting XAResource: transaction was null", new Exception("Warning Location")) } } catch (RollbackException e) { throw new TransactionException("Could not enlist XAResource in transaction", e) } catch (SystemException e) { // This is deprecated, hopefully errors are adequate without, but leaving here for future reference // if (e instanceof ExtendedSystemException) { // for (Throwable se in e.errors) logger.error("Extended Atomikos error: ${se.toString()}", se) // } throw new TransactionException("Could not enlist XAResource in transaction", e) } } @Override void registerSynchronization(Synchronization sync) { if (sync == null) return if (getStatus() != Status.STATUS_ACTIVE) { logger.warn("Not registering Synchronization: transaction not ACTIVE", new Exception("Warning Location")) return } try { Transaction tx = tm.getTransaction() if (tx != null) { tx.registerSynchronization(sync) } else { logger.warn("Not registering Synchronization: transaction was null", new Exception("Warning Location")) } } catch (RollbackException e) { throw new TransactionException("Could not register Synchronization in transaction", e) } catch (SystemException e) { throw new TransactionException("Could not register Synchronization in transaction", e) } } @Override void initTransactionCache(boolean readOnly) { if (!useTransactionCache) return TxStackInfo txStackInfo = getTxStackInfo() if (txStackInfo.txCache == null) { if (isTraceEnabled) { StringBuilder infoString = new StringBuilder() infoString.append("Initializing TX cache at:") for (infoAei in ecfi.getEci().artifactExecutionFacade.getStack()) infoString.append(infoAei.getName()) logger.trace(infoString.toString()) // } else if (logger.isInfoEnabled()) { // logger.info("Initializing TX cache in ${ecfi.getEci().getArtifactExecutionImpl().peek()?.getName()}") } if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException("Cannot enlist: no transaction manager or transaction not active") TransactionCache txCache = new TransactionCache(this.ecfi, readOnly) txStackInfo.txCache = txCache registerSynchronization(txCache) } else if (txStackInfo.txCache.isReadOnly()) { if (isTraceEnabled) logger.trace("Making TX cache write through in ${ecfi.getEci().artifactExecutionFacade.peek()?.getName()}") txStackInfo.txCache.makeWriteThrough() // doing on read only init: registerSynchronization(txStackInfo.txCache) } } @Override boolean isTransactionCacheActive() { TxStackInfo txStackInfo = getTxStackInfo() return txStackInfo.txCache != null && !txStackInfo.txCache.isReadOnly() } TransactionCache getTransactionCache() { return getTxStackInfo().txCache } @Override void flushAndDisableTransactionCache() { TxStackInfo txStackInfo = getTxStackInfo() if (txStackInfo.txCache != null) { txStackInfo.txCache.makeReadOnly() // would be safer to flush and remove it completely, but trying just switching to read only mode // txStackInfo.txCache.flushCache(true) // txStackInfo.txCache = null } } Connection getTxConnection(String groupName) { if (!useConnectionStash) return null String conKey = groupName TxStackInfo txStackInfo = getTxStackInfo() ConnectionWrapper con = (ConnectionWrapper) txStackInfo.txConByGroup.get(conKey) if (con == null) return null if (con.isClosed()) { txStackInfo.txConByGroup.remove(conKey) logger.info("Stashed connection closed elsewhere for group ${groupName}: ${con.toString()}") return null } if (!isTransactionActive()) { con.close() txStackInfo.txConByGroup.remove(conKey) logger.info("Stashed connection found but transaction is not active (${getStatusString()}) for group ${groupName}: ${con.toString()}") return null } return con } Connection stashTxConnection(String groupName, Connection con) { if (!useConnectionStash || !isTransactionActive()) return con TxStackInfo txStackInfo = getTxStackInfo() // if transactionBeginStartTime is null we didn't begin the transaction, so can't count on commit/rollback through this if (txStackInfo.transactionBeginStartTime == null) return con String conKey = groupName ConnectionWrapper existing = (ConnectionWrapper) txStackInfo.txConByGroup.get(conKey) try { if (existing != null && !existing.isClosed()) existing.closeInternal() } catch (Throwable t) { logger.error("Error closing previously stashed connection for group ${groupName}: ${existing.toString()}", t) } ConnectionWrapper newCw = new ConnectionWrapper(con, this, groupName) txStackInfo.txConByGroup.put(conKey, newCw) return newCw } /* ================== */ /* Lock Track Methods */ /* ================== */ void registerRecordLock(EntityRecordLock erl) { if (!useLockTrack) return erl.register(recordLockByEntityPk, getTxStackInfo()) } // ========== Initialize/Populate Methods ========== void populateTransactionObjectsJndi() { MNode transactionJndiNode = this.ecfi.getConfXmlRoot().first("transaction-facade").first("transaction-jndi") String userTxJndiName = transactionJndiNode.attribute("user-transaction-jndi-name") String txMgrJndiName = transactionJndiNode.attribute("transaction-manager-jndi-name") MNode serverJndi = this.ecfi.getConfXmlRoot().first("transaction-facade").first("server-jndi") try { InitialContext ic if (serverJndi != null) { Hashtable h = new Hashtable() h.put(Context.INITIAL_CONTEXT_FACTORY, serverJndi.attribute("initial-context-factory")) h.put(Context.PROVIDER_URL, serverJndi.attribute("context-provider-url")) if (serverJndi.attribute("url-pkg-prefixes")) h.put(Context.URL_PKG_PREFIXES, serverJndi.attribute("url-pkg-prefixes")) if (serverJndi.attribute("security-principal")) h.put(Context.SECURITY_PRINCIPAL, serverJndi.attribute("security-principal")) if (serverJndi.attribute("security-credentials")) h.put(Context.SECURITY_CREDENTIALS, serverJndi.attribute("security-credentials")) ic = new InitialContext(h) } else { ic = new InitialContext() } this.ut = (UserTransaction) ic.lookup(userTxJndiName) this.tm = (TransactionManager) ic.lookup(txMgrJndiName) } catch (NamingException ne) { logger.error("Error while finding JNDI Transaction objects [${userTxJndiName}] and [${txMgrJndiName}] from server [${serverJndi ? serverJndi.attribute("context-provider-url") : "default"}].", ne) } if (this.ut == null) logger.error("Could not find UserTransaction with name [${userTxJndiName}] in JNDI server [${serverJndi ? serverJndi.attribute("context-provider-url") : "default"}].") if (this.tm == null) logger.error("Could not find TransactionManager with name [${txMgrJndiName}] in JNDI server [${serverJndi ? serverJndi.attribute("context-provider-url") : "default"}].") } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/TransactionInternalBitronix.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import bitronix.tm.BitronixTransactionManager import bitronix.tm.TransactionManagerServices import bitronix.tm.resource.jdbc.PoolingDataSource import bitronix.tm.utils.ClassLoaderUtils import bitronix.tm.utils.PropertyUtils import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.TransactionInternal import org.moqui.entity.EntityFacade import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.sql.DataSource import javax.sql.XADataSource import jakarta.transaction.TransactionManager import jakarta.transaction.UserTransaction import java.sql.Connection @CompileStatic class TransactionInternalBitronix implements TransactionInternal { protected final static Logger logger = LoggerFactory.getLogger(TransactionInternalBitronix.class) protected ExecutionContextFactoryImpl ecfi protected BitronixTransactionManager btm protected UserTransaction ut protected TransactionManager tm protected List pdsList = [] @Override TransactionInternal init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf // NOTE: see the bitronix-default-config.properties file for more config btm = TransactionManagerServices.getTransactionManager() this.ut = btm this.tm = btm return this } @Override TransactionManager getTransactionManager() { return tm } @Override UserTransaction getUserTransaction() { return ut } @Override DataSource getDataSource(EntityFacade ef, MNode datasourceNode) { // NOTE: this is called during EFI init, so use the passed one and don't try to get from ECFI EntityFacadeImpl efi = (EntityFacadeImpl) ef EntityFacadeImpl.DatasourceInfo dsi = new EntityFacadeImpl.DatasourceInfo(efi, datasourceNode) PoolingDataSource pds = new PoolingDataSource() pds.setUniqueName(dsi.uniqueName) if (dsi.xaDsClass) { pds.setClassName(dsi.xaDsClass) pds.setDriverProperties(dsi.xaProps) Class xaFactoryClass = ClassLoaderUtils.loadClass(dsi.xaDsClass) Object xaFactory = xaFactoryClass.newInstance() if (!(xaFactory instanceof XADataSource)) throw new IllegalArgumentException("xa-ds-class " + xaFactory.getClass().getName() + " does not implement XADataSource") XADataSource xaDataSource = (XADataSource) xaFactory for (Map.Entry entry : dsi.xaProps.entrySet()) { String name = (String) entry.getKey() Object value = entry.getValue() try { PropertyUtils.setProperty(xaDataSource, name, value) } catch (Exception e) { logger.warn("Error setting ${dsi.uniqueName} property ${name}, ignoring: ${e.toString()}") } } pds.setXaDataSource(xaDataSource) } else { pds.setClassName("bitronix.tm.resource.jdbc.lrc.LrcXADataSource") pds.getDriverProperties().setProperty("driverClassName", dsi.jdbcDriver) pds.getDriverProperties().setProperty("url", dsi.jdbcUri) pds.getDriverProperties().setProperty("user", dsi.jdbcUsername) pds.getDriverProperties().setProperty("password", dsi.jdbcPassword) } String txIsolationLevel = dsi.inlineJdbc.attribute("isolation-level") ? dsi.inlineJdbc.attribute("isolation-level") : dsi.database.attribute("default-isolation-level") int isolationInt = efi.getTxIsolationFromString(txIsolationLevel) if (txIsolationLevel && isolationInt != -1) { switch (isolationInt) { case Connection.TRANSACTION_SERIALIZABLE: pds.setIsolationLevel("SERIALIZABLE"); break case Connection.TRANSACTION_REPEATABLE_READ: pds.setIsolationLevel("REPEATABLE_READ"); break case Connection.TRANSACTION_READ_UNCOMMITTED: pds.setIsolationLevel("READ_UNCOMMITTED"); break case Connection.TRANSACTION_READ_COMMITTED: pds.setIsolationLevel("READ_COMMITTED"); break case Connection.TRANSACTION_NONE: pds.setIsolationLevel("NONE"); break } } // no need for this, just sets min and max sizes: ads.setPoolSize pds.setMinPoolSize((dsi.inlineJdbc.attribute("pool-minsize") ?: "5") as int) pds.setMaxPoolSize((dsi.inlineJdbc.attribute("pool-maxsize") ?: "50") as int) if (dsi.inlineJdbc.attribute("pool-time-idle")) pds.setMaxIdleTime(dsi.inlineJdbc.attribute("pool-time-idle") as int) // if (dsi.inlineJdbc."@pool-time-reap") ads.setReapTimeout(dsi.inlineJdbc."@pool-time-reap" as int) // if (dsi.inlineJdbc."@pool-time-maint") ads.setMaintenanceInterval(dsi.inlineJdbc."@pool-time-maint" as int) if (dsi.inlineJdbc.attribute("pool-time-wait")) pds.setAcquisitionTimeout(dsi.inlineJdbc.attribute("pool-time-wait") as int) pds.setAllowLocalTransactions(true) // allow mixing XA and non-XA transactions pds.setAutomaticEnlistingEnabled(true) // automatically enlist/delist this resource in the tx pds.setShareTransactionConnections(true) // share connections within a transaction pds.setDeferConnectionRelease(true) // only one transaction per DB connection (can be false if supported by DB) // pds.setShareTransactionConnections(false) // don't share connections in the ACCESSIBLE, needed? // pds.setIgnoreRecoveryFailures(false) // something to consider for XA recovery errors, quarantines by default pds.setEnableJdbc4ConnectionTest(true) // use faster jdbc4 connection test // default is 0, disabled PreparedStatement cache (cache size per Connection) // NOTE: make this configurable? value too high or low? pds.setPreparedStatementCacheSize(100) // use-tm-join defaults to true, so does Bitronix so just set to false if false if (dsi.database.attribute("use-tm-join") == "false") pds.setUseTmJoin(false) if (dsi.inlineJdbc.attribute("pool-test-query")) { pds.setTestQuery(dsi.inlineJdbc.attribute("pool-test-query")) } else if (dsi.database.attribute("default-test-query")) { pds.setTestQuery(dsi.database.attribute("default-test-query")) } logger.info("Initializing DataSource ${dsi.uniqueName} (${dsi.database.attribute('name')}) with properties: ${dsi.dsDetails}") // init the DataSource pds.init() logger.info("Init DataSource ${dsi.uniqueName} (${dsi.database.attribute('name')}) isolation ${pds.getIsolationLevel()} (${isolationInt}), max pool ${pds.getMaxPoolSize()}") pdsList.add(pds) return pds } @Override void destroy() { logger.info("Shutting down Bitronix") // close the DataSources for (PoolingDataSource pds in pdsList) pds.close() // shutdown Bitronix btm.shutdown() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/UserFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import org.apache.shiro.authc.AuthenticationToken import org.apache.shiro.authc.ExpiredCredentialsException import org.moqui.context.PasswordChangeRequiredException import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpSession import jakarta.websocket.server.HandshakeRequest import java.sql.Timestamp import org.apache.shiro.authc.AuthenticationException import org.apache.shiro.authc.UsernamePasswordToken import org.apache.shiro.subject.Subject import org.apache.shiro.subject.support.DefaultSubjectContext import org.apache.shiro.web.subject.WebSubjectContext import org.apache.shiro.web.subject.support.DefaultWebSubjectContext import org.apache.shiro.web.session.HttpServletSession import org.moqui.context.ArtifactExecutionInfo import org.moqui.context.AuthenticationRequiredException import org.moqui.context.SecondFactorRequiredException import org.moqui.context.UserFacade import org.moqui.entity.EntityCondition import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactAuthzCheck import org.moqui.impl.entity.EntityValueBase import org.moqui.impl.screen.ScreenUrlInfo import org.moqui.impl.util.MoquiShiroRealm import org.moqui.util.MNode import org.moqui.util.StringUtilities import org.moqui.util.WebUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class UserFacadeImpl implements UserFacade { protected final static Logger logger = LoggerFactory.getLogger(UserFacadeImpl.class) protected final static Set allUserGroupIdOnly = new HashSet(["ALL_USERS"]) protected ExecutionContextImpl eci protected Timestamp effectiveTime = (Timestamp) null protected UserInfo currentInfo protected Deque userInfoStack = new LinkedList() // there may be non-web visits, so keep a copy of the visitId here protected String visitId = (String) null protected EntityValue visitInternal = (EntityValue) null protected String visitorIdInternal = (String) null protected String clientIpInternal = (String) null // we mostly want this for the Locale default, and may be useful for other things protected HttpServletRequest request = (HttpServletRequest) null protected HttpServletResponse response = (HttpServletResponse) null // NOTE: a better practice is to always get from the request, but for WebSocket handshakes we don't have a request protected HttpSession session = (HttpSession) null UserFacadeImpl(ExecutionContextImpl eci) { this.eci = eci pushUser(null) } Subject makeEmptySubject() { if (session != null) { WebSubjectContext wsc = new DefaultWebSubjectContext() if (request != null) wsc.setServletRequest(request) if (response != null) wsc.setServletResponse(response) wsc.setSession(new HttpServletSession(session, request?.getServerName())) return eci.ecfi.getSecurityManager().createSubject(wsc) } else { return eci.ecfi.getSecurityManager().createSubject(new DefaultSubjectContext()) } } void initFromHttpRequest(HttpServletRequest request, HttpServletResponse response) { this.request = request this.response = response this.session = request.getSession() // get client IP address, handle proxy upstream address if added in a header clientIpInternal = getClientIp(request, null, eci.ecfi) String preUsername = getUsername() Subject webSubject = makeEmptySubject() if (webSubject.isAuthenticated()) { String sesUsername = (String) webSubject.getPrincipal() if (preUsername != null && !preUsername.isEmpty()) { if (!preUsername.equals(sesUsername)) { logger.warn("Found user ${sesUsername} in session but UserFacade has user ${preUsername}, popping user") popUser() } } // user found in session so no login needed, but make sure hasLoggedOut != "Y" EntityValue userAccount = (EntityValue) null if (sesUsername != null && !sesUsername.isEmpty()) { EntityCondition usernameCond = eci.entityFacade.getConditionFactory() .makeCondition("username", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase() userAccount = eci.getEntity().find("moqui.security.UserAccount") .condition(usernameCond).useCache(false).disableAuthz().one() } if (userAccount != null && "Y".equals(userAccount.getNoCheckSimple("hasLoggedOut"))) { // logout user through Shiro, invalidate session, continue logger.info("User ${sesUsername} is authenticated in session but hasLoggedOut elsewhere, logging out") webSubject.logout() // Shiro invalidates session, but make sure just in case HttpSession oldSession = request.getSession(false) if (oldSession != null) oldSession.invalidate() this.session = request.getSession() } else { // effectively login the user for framework (already logged in for session through Shiro) pushUserSubject(webSubject) if (logger.traceEnabled) logger.trace("For new request found user [${getUsername()}] in the session") } } else { if (logger.traceEnabled) logger.trace("For new request NO user authenticated in the session") if (preUsername != null && !preUsername.isEmpty()) { logger.warn("Found NO user in session but UserFacade has user ${preUsername}, popping user") popUser() } } this.visitId = session.getAttribute("moqui.visitId") // check for HTTP Basic Authorization for Authentication purposes // NOTE: do this even if there is another user logged in, will go on stack Map secureParameters = eci.webImpl != null ? eci.webImpl.getSecureRequestParameters() : WebUtilities.simplifyRequestParameters(request, true) String authzHeader = request.getHeader("Authorization") if (authzHeader != null && authzHeader.length() > 6 && authzHeader.startsWith("Basic ")) { String basicAuthEncoded = authzHeader.substring(6).trim() String basicAuthAsString = new String(basicAuthEncoded.decodeBase64()) int indexOfColon = basicAuthAsString.indexOf(":") if (indexOfColon > 0) { String username = basicAuthAsString.substring(0, indexOfColon) String password = basicAuthAsString.substring(indexOfColon + 1) this.loginUser(username, password) } else { logger.warn("For HTTP Basic Authorization got bad credentials string. Base64 encoded is [${basicAuthEncoded}] and after decoding is [${basicAuthAsString}].") } } if (currentInfo.username == null && (request.getHeader("api_key") || request.getHeader("login_key"))) { String loginKey = request.getHeader("api_key") ?: request.getHeader("login_key") loginKey = loginKey.trim() if (loginKey != null && !loginKey.isEmpty() && !"null".equals(loginKey) && !"undefined".equals(loginKey)) this.loginUserKey(loginKey) } if (currentInfo.username == null && (secureParameters.api_key || secureParameters.login_key)) { String loginKey = secureParameters.api_key ?: secureParameters.login_key loginKey = loginKey.trim() if (loginKey != null && !loginKey.isEmpty() && !"null".equals(loginKey) && !"undefined".equals(loginKey)) this.loginUserKey(loginKey) } if (currentInfo.username == null && secureParameters.authUsername) { // try the Moqui-specific parameters for instant login // if we have credentials coming in anywhere other than URL parameters, try logging in String authUsername = secureParameters.authUsername String authPassword = secureParameters.authPassword this.loginUser(authUsername, authPassword) } if (eci.messageFacade.hasError()) request.setAttribute("moqui.login.error", "true") // NOTE: only tracking Visitor and Visit if there is a WebFacadeImpl in place if (eci.webImpl != null && !this.visitId && !eci.getSkipStats()) { MNode serverStatsNode = eci.ecfi.getServerStatsNode() ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(eci.screenFacade, request) // before doing anything with the visit, etc make sure exists sui.checkExists() boolean isJustContent = sui.fileResourceRef != null // handle visitorId and cookie String cookieVisitorId = (String) null if (!isJustContent && !"false".equals(serverStatsNode.attribute('visitor-enabled'))) { Cookie[] cookies = request.getCookies() if (cookies != null) { for (int i = 0; i < cookies.length; i++) { if (cookies[i].getName().equals("moqui.visitor")) { cookieVisitorId = cookies[i].getValue() break } } } if (cookieVisitorId) { // make sure the Visitor record actually exists, if not act like we got no moqui.visitor cookie EntityValue visitor = eci.entity.find("moqui.server.Visitor").condition("visitorId", cookieVisitorId).disableAuthz().one() if (visitor == null) { logger.info("Got invalid visitorId [${cookieVisitorId}] in moqui.visitor cookie in session [${session.id}], throwing away and making a new one") cookieVisitorId = null } } if (!cookieVisitorId) { // NOTE: disable authz for this call, don't normally want to allow create of Visitor, but this is a special case Map cvResult = eci.service.sync().name("create", "moqui.server.Visitor") .parameter("createdDate", getNowTimestamp()).disableAuthz().call() cookieVisitorId = (String) cvResult?.visitorId if (logger.traceEnabled) logger.trace("Created new Visitor with ID [${cookieVisitorId}] in session [${session.id}]") } if (cookieVisitorId) { // whether it existed or not, add it again to keep it fresh; stale cookies get thrown away Cookie visitorCookie = new Cookie("moqui.visitor", cookieVisitorId) visitorCookie.setMaxAge(60 * 60 * 24 * 365) visitorCookie.setPath("/") visitorCookie.setHttpOnly(true) if (request.isSecure()) visitorCookie.setSecure(true) response.addCookie(visitorCookie) session.setAttribute("moqui.visitorId", cookieVisitorId) } } visitorIdInternal = cookieVisitorId if (!isJustContent && !"false".equals(serverStatsNode.attribute('visit-enabled'))) { // create and persist Visit String contextPath = session.getServletContext().getContextPath() String webappId = contextPath.length() > 1 ? contextPath.substring(1) : "ROOT" String fullUrl = eci.webImpl.requestUrl fullUrl = (fullUrl.length() > 255) ? fullUrl.substring(0, 255) : fullUrl.toString() String curUserAgent = request.getHeader("User-Agent") ?: "" if (curUserAgent != null && curUserAgent.length() > 255) curUserAgent = curUserAgent.substring(0, 255) Map parameters = new HashMap([sessionId:session.id, webappName:webappId, fromDate:new Timestamp(session.getCreationTime()), initialLocale:getLocale().toString(), initialRequest:fullUrl, initialReferrer:request.getHeader("Referer")?:"", initialUserAgent:curUserAgent, clientHostName:request.getRemoteHost(), clientUser:request.getRemoteUser()]) InetAddress address = eci.ecfi.getLocalhostAddress() parameters.serverIpAddress = address?.getHostAddress() ?: "127.0.0.1" parameters.serverHostName = address?.getHostName() ?: "localhost" parameters.clientIpAddress = clientIpInternal if (cookieVisitorId) parameters.visitorId = cookieVisitorId // NOTE: disable authz for this call, don't normally want to allow create of Visit, but this is special case Map visitResult = eci.service.sync().name("create", "moqui.server.Visit").parameters(parameters) .disableAuthz().call() // put visitId in session as "moqui.visitId" if (visitResult) { session.setAttribute("moqui.visitId", visitResult.visitId) this.visitId = visitResult.visitId if (logger.traceEnabled) logger.trace("Created new Visit with ID [${this.visitId}] in session [${session.id}]") } } } } void initFromHandshakeRequest(HandshakeRequest request) { try { this.session = (HttpSession) request.getHttpSession() } catch (Throwable t) { // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809 logger.trace("Failed to get HttpSession from WebSocket HandshakeRequest", t) } // get client IP address, handle proxy original address if exists clientIpInternal = getClientIp(null, request, eci.ecfi) // WebSocket handshake request is the HTTP upgrade request so this will be the original session // login user from value in session Subject webSubject = makeEmptySubject() if (webSubject.isAuthenticated()) { // effectively login the user pushUserSubject(webSubject) if (logger.traceEnabled) logger.trace("For new request found user [${username}] in the session") } else { if (logger.traceEnabled) logger.trace("For new request NO user authenticated in the session") } Map> headers = request.getHeaders() Map> parameters = request.getParameterMap() String authzHeader = headers.get("Authorization") ? headers.get("Authorization").get(0) : null if (authzHeader != null && authzHeader.length() > 6 && authzHeader.substring(0, 6).equals("Basic ")) { String basicAuthEncoded = authzHeader.substring(6).trim() String basicAuthAsString = new String(basicAuthEncoded.decodeBase64()) if (basicAuthAsString.indexOf(":") > 0) { String username = basicAuthAsString.substring(0, basicAuthAsString.indexOf(":")) String password = basicAuthAsString.substring(basicAuthAsString.indexOf(":") + 1) this.loginUser(username, password) } else { logger.warn("For HTTP Basic Authorization got bad credentials string. Base64 encoded is [${basicAuthEncoded}] and after decoding is [${basicAuthAsString}].") } } if (currentInfo.username == null && (headers.api_key || headers.login_key)) { String loginKey = headers.api_key ? headers.api_key.get(0) : (headers.login_key ? headers.login_key.get(0) : null) loginKey = loginKey.trim() if (loginKey != null && !loginKey.isEmpty() && !"null".equals(loginKey) && !"undefined".equals(loginKey)) this.loginUserKey(loginKey) } if (currentInfo.username == null && (parameters.api_key || parameters.login_key)) { String loginKey = parameters.api_key ? parameters.api_key.get(0) : (parameters.login_key ? parameters.login_key.get(0) : null) loginKey = loginKey.trim() logger.warn("loginKey2 ${loginKey}") if (loginKey != null && !loginKey.isEmpty() && !"null".equals(loginKey) && !"undefined".equals(loginKey)) this.loginUserKey(loginKey) } if (currentInfo.username == null && parameters.authUsername) { // try the Moqui-specific parameters for instant login // if we have credentials coming in anywhere other than URL parameters, try logging in String authUsername = parameters.authUsername.get(0) String authPassword = parameters.authPassword ? parameters.authPassword.get(0) : null this.loginUser(authUsername, authPassword) } } void initFromHttpSession(HttpSession session) { this.session = session Subject webSubject = makeEmptySubject() if (webSubject.isAuthenticated()) { // effectively login the user pushUserSubject(webSubject) if (logger.traceEnabled) logger.trace("For new request found user [${username}] in the session") } else { if (logger.traceEnabled) logger.trace("For new request NO user authenticated in the session") } } @Override Locale getLocale() { return currentInfo.localeCache } @Override void setLocale(Locale locale) { if (currentInfo.userAccount != null) { eci.transaction.runUseOrBegin(null, "Error saving locale", { boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz() try { EntityValue userAccountClone = currentInfo.userAccount.cloneValue() userAccountClone.set("locale", locale.toString()) userAccountClone.update() } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() } }) } currentInfo.localeCache = locale } @Override TimeZone getTimeZone() { return currentInfo.tzCache } Calendar getCalendarSafe() { return Calendar.getInstance(currentInfo.tzCache != null ? currentInfo.tzCache : TimeZone.getDefault(), currentInfo.localeCache != null ? currentInfo.localeCache : (request != null ? request.getLocale() : Locale.getDefault())) } @Override void setTimeZone(TimeZone tz) { if (currentInfo.userAccount != null) { eci.transaction.runUseOrBegin(null, "Error saving timeZone", { boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz() try { EntityValue userAccountClone = currentInfo.userAccount.cloneValue() userAccountClone.set("timeZone", tz.getID()) userAccountClone.update() } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() } }) } currentInfo.tzCache = tz } @Override String getCurrencyUomId() { return currentInfo.currencyUomId } @Override void setCurrencyUomId(String uomId) { if (currentInfo.userAccount != null) { eci.transaction.runUseOrBegin(null, "Error saving currencyUomId", { boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz() try { EntityValue userAccountClone = currentInfo.userAccount.cloneValue() userAccountClone.set("currencyUomId", uomId) userAccountClone.update() } finally { if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() } }) } currentInfo.currencyUomId = uomId } @Override String getPreference(String preferenceKey) { String userId = getUserId() return getPreference(preferenceKey, userId) } String getPreference(String preferenceKey, String userId) { if (preferenceKey == null || preferenceKey.isEmpty()) return null // look in system properties for preferenceKey or key with '.' replaced by '_'; overrides DB values String sysPropVal = System.getProperty(preferenceKey) if (sysPropVal == null || sysPropVal.isEmpty()) { String underscoreKey = preferenceKey.replace('.' as char, '_' as char) sysPropVal = System.getProperty(underscoreKey) } if (sysPropVal != null && !sysPropVal.isEmpty()) return sysPropVal EntityValue up = userId != null ? eci.entityFacade.fastFindOne("moqui.security.UserPreference", true, true, userId, preferenceKey) : null if (up == null) { // try UserGroupPreference EntityList ugpList = eci.getEntity().find("moqui.security.UserGroupPreference") .condition("userGroupId", EntityCondition.IN, getUserGroupIdSet(userId)) .condition("preferenceKey", preferenceKey) .orderBy("groupPriority").orderBy("-userGroupId") .useCache(true).disableAuthz().list() if (ugpList != null && ugpList.size() > 0) up = ugpList.get(0) } return up?.preferenceValue } @Override Map getPreferences(String keyRegexp) { String userId = getUserId() boolean hasKeyFilter = keyRegexp != null && !keyRegexp.isEmpty() Map prefMap = new HashMap<>() // start with UserGroupPreference, put UserPreference values over top to override // NOTE: sort in reverse order from normal query so that later values in list overwrite earlier values EntityList ugpList = eci.getEntity().find("moqui.security.UserGroupPreference") .condition("userGroupId", EntityCondition.IN, getUserGroupIdSet(userId)) .orderBy("-groupPriority").orderBy("userGroupId") .disableAuthz().list() int ugpListSize = ugpList.size() for (int i = 0; i < ugpListSize; i++) { EntityValue ugp = (EntityValue) ugpList.get(i) String prefKey = (String) ugp.getNoCheckSimple("preferenceKey") if (hasKeyFilter && !prefKey.matches(keyRegexp)) continue String prefValue = (String) ugp.getNoCheckSimple("preferenceValue") if (prefValue != null && !prefValue.isEmpty()) prefMap.put(prefKey, prefValue) } if (userId != null) { EntityList uprefList = eci.getEntity().find("moqui.security.UserPreference") .condition("userId", userId).disableAuthz().list() int uprefListSize = uprefList.size() for (int i = 0; i < uprefListSize; i++) { EntityValue upref = (EntityValue) uprefList.get(i) String prefKey = (String) upref.getNoCheckSimple("preferenceKey") if (hasKeyFilter && !prefKey.matches(keyRegexp)) continue String prefValue = (String) upref.getNoCheckSimple("preferenceValue") if (prefValue != null && !prefValue.isEmpty()) prefMap.put(prefKey, prefValue) } } return prefMap } @Override void setPreference(String preferenceKey, String preferenceValue) { String userId = getUserId() if (!userId) throw new IllegalStateException("Cannot set preference with key ${preferenceKey}, no user logged in.") boolean alreadyDisabled = eci.getArtifactExecution().disableAuthz() boolean beganTransaction = eci.transaction.begin(null) try { eci.getEntity().makeValue("moqui.security.UserPreference").set("userId", getUserId()) .set("preferenceKey", preferenceKey).set("preferenceValue", preferenceValue).createOrUpdate() } catch (Throwable t) { eci.transaction.rollback(beganTransaction, "Error saving UserPreference", t) } finally { if (eci.transaction.isTransactionInPlace()) eci.transaction.commit(beganTransaction) if (!alreadyDisabled) eci.getArtifactExecution().enableAuthz() } } @Override Map getContext() { return currentInfo.getUserContext() } @Override Timestamp getNowTimestamp() { // NOTE: review Timestamp and nowTimestamp use, have things use this by default (except audit/etc where actual date/time is needed return ((Object) this.effectiveTime != null) ? this.effectiveTime : new Timestamp(System.currentTimeMillis()) } @Override Calendar getNowCalendar() { Calendar nowCal = getCalendarSafe() nowCal.setTimeInMillis(getNowTimestamp().getTime()) return nowCal } @Override ArrayList getPeriodRange(String period, String poffset) { return getPeriodRange(period, poffset, null) } @Override ArrayList getPeriodRange(String period, String poffset, String pdate) { int offset = (poffset ?: "0") as int java.sql.Date sqlDate = (pdate != null && !pdate.isEmpty()) ? eci.l10nFacade.parseDate(pdate, null) : null return getPeriodRange(period, offset, sqlDate) } @Override ArrayList getPeriodRange(String period, int offset, java.sql.Date sqlDate) { period = (period ?: "day").toLowerCase() boolean perIsNumber = Character.isDigit(period.charAt(0)) Calendar basisCal = getCalendarSafe() if (sqlDate != null) basisCal.setTimeInMillis(sqlDate.getTime()) basisCal.set(Calendar.HOUR_OF_DAY, 0); basisCal.set(Calendar.MINUTE, 0) basisCal.set(Calendar.SECOND, 0); basisCal.set(Calendar.MILLISECOND, 0) // this doesn't seem to work to set the time to midnight: basisCal.setTime(new java.sql.Date(nowTimestamp.time)) Calendar fromCal = (Calendar) basisCal.clone() Calendar thruCal if (perIsNumber && period.endsWith("d")) { int days = Integer.parseInt(period.substring(0, period.length() - 1)) if (offset < 0) { fromCal.add(Calendar.DAY_OF_YEAR, offset * days) thruCal = (Calendar) basisCal.clone() // also include today (or anchor date in pdate) thruCal.add(Calendar.DAY_OF_YEAR, 1) } else { // fromCal already set to basisCal, just set thruCal thruCal = (Calendar) basisCal.clone() thruCal.add(Calendar.DAY_OF_YEAR, (offset + 1) * days) } } else if (perIsNumber && period.endsWith("r")) { int days = Integer.parseInt(period.substring(0, period.length() - 1)) if (offset < 0) offset = -offset fromCal.add(Calendar.DAY_OF_YEAR, -offset * days) thruCal = (Calendar) basisCal.clone() thruCal.add(Calendar.DAY_OF_YEAR, offset * days) } else if (period == "week") { fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek()) fromCal.add(Calendar.WEEK_OF_YEAR, offset) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.WEEK_OF_YEAR, 1) } else if (period == "weeks") { if (offset < 0) { // from end of month of basis date go back offset months (add negative offset to from after copying for thru) fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek()) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.WEEK_OF_YEAR, 1) fromCal.add(Calendar.WEEK_OF_YEAR, offset + 1) } else { // from beginning of month of basis date go forward offset months (add offset to thru) fromCal.set(Calendar.DAY_OF_WEEK, fromCal.getFirstDayOfWeek()) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.WEEK_OF_YEAR, offset == 0 ? 1 : offset) } } else if (period == "month") { fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH)) fromCal.add(Calendar.MONTH, offset) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.MONTH, 1) } else if (period == "months") { if (offset < 0) { // from end of month of basis date go back offset months (add negative offset to from after copying for thru) fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH)) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.MONTH, 1) fromCal.add(Calendar.MONTH, offset + 1) } else { // from beginning of month of basis date go forward offset months (add offset to thru) fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH)) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.MONTH, offset == 0 ? 1 : offset) } } else if (period == "quarter") { fromCal.set(Calendar.DAY_OF_MONTH, fromCal.getActualMinimum(Calendar.DAY_OF_MONTH)) int quarterNumber = (fromCal.get(Calendar.MONTH) / 3) as int fromCal.set(Calendar.MONTH, (quarterNumber * 3)) fromCal.add(Calendar.MONTH, (offset * 3)) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.MONTH, 3) } else if (period == "year") { fromCal.set(Calendar.DAY_OF_YEAR, fromCal.getActualMinimum(Calendar.DAY_OF_YEAR)) fromCal.add(Calendar.YEAR, offset) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.YEAR, 1) } else { // default to day fromCal.add(Calendar.DAY_OF_YEAR, offset) thruCal = (Calendar) fromCal.clone() thruCal.add(Calendar.DAY_OF_YEAR, 1) } ArrayList rangeList = new ArrayList<>(2) rangeList.add(new Timestamp(fromCal.getTimeInMillis())) rangeList.add(new Timestamp(thruCal.getTimeInMillis())) // logger.warn("fromCal first ${fromCal.getFirstDayOfWeek()} TZ ${fromCal.getTimeZone().getDisplayName()} basisCal first ${basisCal.getFirstDayOfWeek()} TZ ${basisCal.getTimeZone().getDisplayName()} range ${rangeList} default TZ ${TimeZone.getDefault().getDisplayName()}") return rangeList } @Override String getPeriodDescription(String period, String poffset, String pdate) { ArrayList rangeList = getPeriodRange(period, poffset, pdate) StringBuilder desc = new StringBuilder() if (poffset == "0") desc.append(eci.getL10n().localize("This")) else if (poffset == "-1") desc.append(eci.getL10n().localize("Last")) else if (poffset == "1") desc.append(eci.getL10n().localize("Next")) else desc.append(poffset) desc.append(' ') if (period == "day") desc.append(eci.getL10n().localize("Day")) else if (period == "7d") desc.append('7 ').append(eci.getL10n().localize("Days")) else if (period == "30d") desc.append('30 ').append(eci.getL10n().localize("Days")) else if (period == "week") desc.append(eci.getL10n().localize("Week")) else if (period == "weeks") desc.append(eci.getL10n().localize("Weeks")) else if (period == "month") desc.append(eci.getL10n().localize("Month")) else if (period == "months") desc.append(eci.getL10n().localize("Months")) else if (period == "quarter") desc.append(eci.getL10n().localize("Quarter")) else if (period == "year") desc.append(eci.getL10n().localize("Year")) else if (period == "7r") desc.append("+/-7d") else if (period == "30r") desc.append("+/-30d") if (pdate) desc.append(" ").append(eci.getL10n().localize("from##period")).append(" ").append(pdate) desc.append(" (").append(eci.l10n.format(rangeList[0], 'yyyy-MM-dd')).append(' ') .append(eci.getL10n().localize("to##period")).append(' ') .append(eci.l10n.format(rangeList[1] - 1, 'yyyy-MM-dd')).append(')') return desc.toString() } @Override ArrayList getPeriodRange(String baseName, Map inputFieldsMap) { if (inputFieldsMap.get(baseName + "_period")) { return getPeriodRange((String) inputFieldsMap.get(baseName + "_period"), (String) inputFieldsMap.get(baseName + "_poffset"), (String) inputFieldsMap.get(baseName + "_pdate")) } else { ArrayList rangeList = new ArrayList<>(2) rangeList.add(null); rangeList.add(null) Object fromValue = inputFieldsMap.get(baseName + "_from") if (fromValue && fromValue instanceof CharSequence) { if (fromValue.length() < 12) rangeList.set(0, eci.l10nFacade.parseTimestamp(fromValue.toString() + " 00:00:00.000", "yyyy-MM-dd HH:mm:ss.SSS")) else rangeList.set(0, eci.l10nFacade.parseTimestamp(fromValue.toString(), null)) } else if (fromValue instanceof Timestamp) { rangeList.set(0, (Timestamp) fromValue) } Object thruValue = inputFieldsMap.get(baseName + "_thru") if (thruValue && thruValue instanceof CharSequence) { if (thruValue.length() < 12) rangeList.set(1, eci.l10nFacade.parseTimestamp(thruValue.toString() + " 23:59:59.999", "yyyy-MM-dd HH:mm:ss.SSS")) else rangeList.set(1, eci.l10nFacade.parseTimestamp(thruValue.toString(), null)) } else if (thruValue instanceof Timestamp) { rangeList.set(1, (Timestamp) thruValue) } return rangeList } } @Override void setEffectiveTime(Timestamp effectiveTime) { this.effectiveTime = effectiveTime } @Override boolean loginUser(String username, String password) { if (username == null || username.isEmpty()) { eci.messageFacade.addError(eci.l10n.localize("No username specified")) return false } if (password == null || password.isEmpty()) { eci.messageFacade.addError(eci.l10n.localize("No password specified")) return false } // if there is a web session invalidate it so there is a new session for the login (prevent Session Fixation attacks) if (eci.getWebImpl() != null) eci.getWebImpl().makeNewSession() UsernamePasswordToken token = new UsernamePasswordToken(username, password, true) return internalLoginToken(username, token) } /** For internal framework use only, does a login without authc. */ boolean internalLoginUser(String username) { return internalLoginUser(username, true) } boolean internalLoginUser(String username, boolean saveHistory) { if (username == null || username.isEmpty()) { eci.message.addError(eci.l10n.localize("No username specified")) return false } UsernamePasswordToken token = new MoquiShiroRealm.ForceLoginToken(username, true, saveHistory) return internalLoginToken(username, token) } boolean internalLoginToken(String username, AuthenticationToken token) { if (eci.web != null) { // this ensures that after correctly logging in, a previously attempted login user's "Second Factor" screen isn't displayed eci.web.sessionAttributes.remove("moquiPreAuthcUsername") eci.web.sessionAttributes.remove("moquiAuthcFactorRequired") } Subject loginSubject = makeEmptySubject() try { // do the actual login through Shiro loginSubject.login(token) // do this first so that the rest will be done as this user // just in case there is already a user authenticated push onto a stack to remember pushUserSubject(loginSubject) // after successful login trigger the after-login actions if (eci.getWebImpl() != null) { eci.getWebImpl().runAfterLoginActions() eci.getWebImpl().getRequest().setAttribute("moqui.request.authenticated", "true") } } catch (SecondFactorRequiredException ae) { if (eci.web != null) { // This makes the session realize the this user needs to verify login with an authentication factor eci.web.sessionAttributes.put("moquiPreAuthcUsername", username) eci.web.sessionAttributes.put("moquiAuthcFactorRequired", "true") } // don't add this particular error, causes problems when this is followed immediately by an attempt to verify a submitted code in the same tx: eci.messageFacade.addError(ae.message) return false } catch (PasswordChangeRequiredException ae) { if (eci.web != null) { eci.web.sessionAttributes.put("moquiPreAuthcUsername", username) eci.web.sessionAttributes.put("moquiPasswordChangeRequired", "true") } eci.messageFacade.addError(ae.message) return false } catch (ExpiredCredentialsException ae) { if (eci.web != null) { eci.web.sessionAttributes.put("moquiPreAuthcUsername", username) eci.web.sessionAttributes.put("moquiExpiredCredentials", "true") } eci.messageFacade.addError(ae.message) return false } catch (AuthenticationException ae) { // others to consider handling differently (these all inherit from AuthenticationException): // UnknownAccountException, IncorrectCredentialsException, ExpiredCredentialsException, // CredentialsException, LockedAccountException, DisabledAccountException, ExcessiveAttemptsException eci.messageFacade.addError(ae.message) return false } return true } /** For internal use only, quick login using a Subject already logged in from another thread, etc */ boolean internalLoginSubject(Subject loginSubject) { if (loginSubject == null || !loginSubject.getPrincipal() || !loginSubject.isAuthenticated()) return false pushUserSubject(loginSubject) return true } void logoutLocal() { // before logout trigger the before-logout actions if (eci.getWebImpl() != null) eci.getWebImpl().runBeforeLogoutActions() // pop from user stack, also calls Shiro logout() popUser() } @Override void logoutUser() { String userId = getUserId() // if userId set hasLoggedOut if (userId != null && !userId.isEmpty()) { logger.info("Setting hasLoggedOut for user ${userId}") eci.serviceFacade.sync().name("update", "moqui.security.UserAccount") .parameters([userId:userId, hasLoggedOut:"Y"]).disableAuthz().call() } logoutLocal() // if there is a request and session invalidate and get new if (request != null) { HttpSession oldSession = request.getSession(false) if (oldSession != null) oldSession.invalidate() session = request.getSession() } } @Override boolean loginUserKey(String loginKey) { if (!loginKey) { eci.message.addError(eci.l10n.localize("No login key specified")) return false } // lookup login key, by hashed key String hashedKey = eci.ecfi.getSimpleHash(loginKey, "", eci.ecfi.getLoginKeyHashType(), false) EntityValue userLoginKey = eci.getEntity().find("moqui.security.UserLoginKey") .condition("loginKey", hashedKey).disableAuthz().one() // see if we found a record for the login key if (userLoginKey == null) { eci.message.addError(eci.l10n.localize("Login key not valid")) return false } // check expire date Timestamp nowDate = getNowTimestamp() Timestamp thruDate = userLoginKey.getTimestamp("thruDate") if (thruDate != (Timestamp) null && nowDate > thruDate) { eci.message.addError(eci.l10n.localize("Login key expired")) return false } // login user with internalLoginUser() EntityValue userAccount = eci.getEntity().find("moqui.security.UserAccount") .condition("userId", userLoginKey.userId).disableAuthz().one() return internalLoginUser(userAccount.getString("username")) } @Override String getLoginKey() { return getLoginKey(eci.ecfi.getLoginKeyExpireHours()) } @Override String getLoginKey(float expireHours) { String userId = getUserId() if (!userId) throw new AuthenticationRequiredException("No active user, cannot get login key") // generate login key String loginKey = StringUtilities.getRandomString(40) // save hashed in UserLoginKey, calc expire and set from/thru dates String hashedKey = eci.ecfi.getSimpleHash(loginKey, "", eci.ecfi.getLoginKeyHashType(), false) Timestamp fromDate = getNowTimestamp() long thruTime = fromDate.getTime() + Math.round(expireHours * 60*60*1000) eci.serviceFacade.sync().name("create", "moqui.security.UserLoginKey") .parameters([loginKey:hashedKey, userId:userId, fromDate:fromDate, thruDate:new Timestamp(thruTime)]) .disableAuthz().requireNewTransaction(true).call() // clean out expired keys eci.entity.find("moqui.security.UserLoginKey").condition("userId", userId) .condition("thruDate", EntityCondition.LESS_THAN, fromDate).disableAuthz().deleteAll() return loginKey } @Override boolean loginAnonymousIfNoUser() { if (currentInfo.username == null && !currentInfo.loggedInAnonymous) { currentInfo.loggedInAnonymous = true return true } else { return false } } void logoutAnonymousOnly() { currentInfo.loggedInAnonymous = false } boolean getLoggedInAnonymous() { return currentInfo.loggedInAnonymous } @Override boolean hasPermission(String userPermissionId) { return hasPermissionById(getUserId(), userPermissionId, getNowTimestamp(), eci) } static boolean hasPermission(String username, String userPermissionId, Timestamp whenTimestamp, ExecutionContextImpl eci) { EntityValue ua = eci.entityFacade.fastFindOne("moqui.security.UserAccount", true, true, username) if (ua == null) ua = eci.entityFacade.find("moqui.security.UserAccount").condition("username", username).useCache(true).disableAuthz().one() if (ua == null) return false hasPermissionById((String) ua.userId, userPermissionId, whenTimestamp, eci) } static boolean hasPermissionById(String userId, String userPermissionId, Timestamp whenTimestamp, ExecutionContextImpl eci) { if (!userId) return false if ((Object) whenTimestamp == null) whenTimestamp = new Timestamp(System.currentTimeMillis()) return (eci.getEntity().find("moqui.security.UserPermissionCheck") .condition([userId:userId, userPermissionId:userPermissionId] as Map).useCache(true).disableAuthz().list() .filterByDate("groupFromDate", "groupThruDate", whenTimestamp) .filterByDate("permissionFromDate", "permissionThruDate", whenTimestamp)) as boolean } @Override boolean isInGroup(String userGroupId) { return isInGroup(getUserId(), userGroupId, getNowTimestamp(), eci) } static boolean isInGroup(String username, String userGroupId, Timestamp whenTimestamp, ExecutionContextImpl eci) { EntityValue ua = eci.entityFacade.fastFindOne("moqui.security.UserAccount", true, true, username) if (ua == null) ua = eci.entityFacade.find("moqui.security.UserAccount").condition("username", username).useCache(true).disableAuthz().one() return isInGroupById((String) ua?.userId, userGroupId, whenTimestamp, eci) } static boolean isInGroupById(String userId, String userGroupId, Timestamp whenTimestamp, ExecutionContextImpl eci) { if (userGroupId == "ALL_USERS") return true if (!userId) return false if ((Object) whenTimestamp == null) whenTimestamp = new Timestamp(System.currentTimeMillis()) return (eci.getEntity().find("moqui.security.UserGroupMember").condition("userId", userId).condition("userGroupId", userGroupId) .useCache(true).disableAuthz().list().filterByDate("fromDate", "thruDate", whenTimestamp)) as boolean } @Override Set getUserGroupIdSet() { // first get the groups the user is in (cached), always add the "ALL_USERS" group to it if (!currentInfo.userId) return allUserGroupIdOnly if (currentInfo.internalUserGroupIdSet == null) currentInfo.internalUserGroupIdSet = getUserGroupIdSet(currentInfo.userId) return currentInfo.internalUserGroupIdSet } Set getUserGroupIdSet(String userId) { Set groupIdSet = new HashSet(allUserGroupIdOnly) if (userId) { // expand the userGroupId Set with UserGroupMember EntityList ugmList = eci.getEntity().find("moqui.security.UserGroupMember").condition("userId", userId) .useCache(true).disableAuthz().list().filterByDate(null, null, null) for (EntityValue userGroupMember in ugmList) groupIdSet.add((String) userGroupMember.userGroupId) } return groupIdSet } ArrayList> getArtifactTarpitCheckList(ArtifactExecutionInfo.ArtifactType artifactTypeEnum) { ArrayList> checkList = (ArrayList>) currentInfo.internalArtifactTarpitCheckListMap.get(artifactTypeEnum) if (checkList == null) { // get the list for each group separately to increase cache hits/efficiency checkList = new ArrayList<>() for (String userGroupId in getUserGroupIdSet()) { EntityList atcvList = eci.getEntity().find("moqui.security.ArtifactTarpitCheckView") .condition("userGroupId", userGroupId).condition("artifactTypeEnumId", artifactTypeEnum.name()) .useCache(true).disableAuthz().list() int atcvListSize = atcvList.size() for (int i = 0; i < atcvListSize; i++) checkList.add(((EntityValueBase) atcvList.get(i)).getValueMap()) } currentInfo.internalArtifactTarpitCheckListMap.put(artifactTypeEnum, checkList) } return checkList } ArrayList getArtifactAuthzCheckList() { // NOTE: even if there is no user, still consider part of the ALL_USERS group and such: if (usernameStack.size() == 0) return EntityListImpl.EMPTY if (currentInfo.internalArtifactAuthzCheckList == null) { // get the list for each group separately to increase cache hits/efficiency ArrayList newList = new ArrayList<>() for (String userGroupId in getUserGroupIdSet()) { EntityList aacvList = eci.getEntity().find("moqui.security.ArtifactAuthzCheckView") .condition("userGroupId", userGroupId).useCache(true).disableAuthz().list() int aacvListSize = aacvList.size() for (int i = 0; i < aacvListSize; i++) newList.add(new ArtifactAuthzCheck((EntityValueBase) aacvList.get(i))) } currentInfo.internalArtifactAuthzCheckList = newList } return currentInfo.internalArtifactAuthzCheckList } @Override String getUserId() { return currentInfo.userId } @Override String getUsername() { return currentInfo.username } @Override EntityValue getUserAccount() { return currentInfo.getUserAccount() } @Override String getVisitUserId() { return visitId ? getVisit().userId : null } @Override String getVisitId() { return visitId } @Override EntityValue getVisit() { if (visitInternal != null) return visitInternal if (visitId == null || visitId.isEmpty()) return null visitInternal = eci.entityFacade.fastFindOne("moqui.server.Visit", false, true, visitId) return visitInternal } @Override String getVisitorId() { if (visitorIdInternal != null) return visitorIdInternal EntityValue visitLocal = getVisit() visitorIdInternal = visitLocal != null ? visitLocal.getNoCheckSimple("visitorId") : null return visitorIdInternal } @Override String getClientIp() { return clientIpInternal } // ========== UserInfo ========== UserInfo pushUserSubject(Subject subject) { UserInfo userInfo = pushUser((String) subject.getPrincipal()) userInfo.subject = subject return userInfo } UserInfo pushUser(String username) { if (currentInfo != null && currentInfo.username == username) return currentInfo if (currentInfo == null || currentInfo.isPopulated()) { // logger.info("Pushing UserInfo for ${username} to stack, was ${currentInfo.username}") UserInfo userInfo = new UserInfo(this, username) userInfoStack.addFirst(userInfo) currentInfo = userInfo return userInfo } else { currentInfo.setInfo(username) return currentInfo } } Subject getCurrentSubject() { return currentInfo.subject != null && currentInfo.subject.isAuthenticated() ? currentInfo.subject : null } void popUser() { if (currentInfo.subject != null && currentInfo.subject.isAuthenticated()) currentInfo.subject.logout() userInfoStack.removeFirst() // always leave at least an empty UserInfo on the stack if (userInfoStack.size() == 0) userInfoStack.addFirst(new UserInfo(this, null)) UserInfo newCurInfo = userInfoStack.getFirst() // logger.info("Popping UserInfo ${currentInfo.username}, new current is ${newCurInfo.username}") // whether previous user on stack or new one, set the currentInfo currentInfo = newCurInfo } static String getClientIp(HttpServletRequest httpRequest, HandshakeRequest handshakeRequest, ExecutionContextFactoryImpl ecfi) { // use configured client-ip-header to support more than the unreliable X-Forwarded-For header String webappName = null if (httpRequest != null) { webappName = httpRequest.servletContext.getInitParameter("moqui-name") } else if (handshakeRequest != null) { try { Object hsrSession = handshakeRequest.getHttpSession() if (hsrSession instanceof HttpSession) webappName = hsrSession.getServletContext().getInitParameter("moqui-name") } catch (Throwable t) { // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809 logger.trace("Failed to get HttpSession from WebSocket HandshakeRequest for client IP lookup", t) } } String clientIpHeaderValue = null if (webappName != null && !webappName.isEmpty()) { ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName) String clientIpHeader = webappInfo?.clientIpHeader // get the header value from http or handshake request if (clientIpHeader != null && !clientIpHeader.isEmpty()) { if (httpRequest != null) { clientIpHeaderValue = httpRequest.getHeader(clientIpHeader) } else if (handshakeRequest != null) { clientIpHeaderValue = handshakeRequest.getHeaders().get(clientIpHeader)?.first() } if (httpRequest != null && (clientIpHeaderValue == null || clientIpHeaderValue.isEmpty())) { logger.warn("No value found in HTTP request Client IP header ${clientIpHeader}, servlet container reports ${httpRequest.getRemoteAddr()}") } } } // get first entry in header value or request's remote addr String clientIp = null if (clientIpHeaderValue != null && !clientIpHeaderValue.isEmpty()) { clientIp = clientIpHeaderValue.split(",")[0].trim() } else { if (httpRequest != null) { clientIp = httpRequest.getRemoteAddr() // logger.info("httpRequest remote addr clientIp ${clientIp}") } else if (handshakeRequest != null) { // any other way to get websocket client IP? else { clientIpInternal = request.getRemoteAddr() } } } if (clientIp != null) { // some headers, like CloudFront-Viewer-Address, contain a port as well so remove that int cipColonIdx = clientIp.lastIndexOf(':') if (cipColonIdx >= 0) { // for IPv6 addresses with square braces, only strip before colon if colon after closing square brace int closeSqBrIdx = clientIp.indexOf(']') if (closeSqBrIdx == -1 || closeSqBrIdx < cipColonIdx) clientIp = clientIp.substring(cipColonIdx + 1) } // strip IPv6 square braces if present if (clientIp != null && !clientIp.isEmpty()) { if (clientIp.charAt(0) == (char) '[') clientIp = clientIp.substring(1) if (clientIp.charAt(clientIp.length() - 1) == (char) ']') clientIp = clientIp.substring(0, clientIp.length() - 1) } } return clientIp } static class UserInfo { final UserFacadeImpl ufi // keep a reference to a UserAccount for performance reasons, avoid repeated cached queries protected EntityValueBase userAccount = (EntityValueBase) null protected String username = (String) null protected String userId = (String) null Set internalUserGroupIdSet = (Set) null // these two are used by ArtifactExecutionFacadeImpl but are maintained here to be cleared when user changes, are based on current user's groups final EnumMap>> internalArtifactTarpitCheckListMap = new EnumMap>>(ArtifactExecutionInfo.ArtifactType.class) ArrayList internalArtifactAuthzCheckList = (ArrayList) null Locale localeCache = (Locale) null TimeZone tzCache = (TimeZone) null String currencyUomId = (String) null /** The Shiro Subject (user) */ Subject subject = (Subject) null /** This is set instead of adding _NA_ user as logged in to pass authc tests but not generally behave as if a user is logged in */ boolean loggedInAnonymous = false protected Map userContext = (Map) null UserInfo(UserFacadeImpl ufi, String username) { this.ufi = ufi setInfo(username) } boolean isPopulated() { return (username != null && username.length() > 0) || loggedInAnonymous } void setInfo(String username) { // this shouldn't happen unless there is a bug in the framework if (isPopulated()) throw new IllegalStateException("Cannot set user info, UserInfo already populated") this.username = username EntityValueBase ua = (EntityValueBase) null if (username != null && username.length() > 0) { EntityCondition usernameCond = ufi.eci.entityFacade.getConditionFactory() .makeCondition("username", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase() ua = (EntityValueBase) ufi.eci.getEntity().find("moqui.security.UserAccount") .condition(usernameCond).useCache(false).disableAuthz().one() } if (ua != null) { userAccount = ua this.username = ua.username userId = ua.userId String localeStr = ua.locale if (localeStr != null && localeStr.length() > 0) { int usIdx = localeStr.indexOf("_") localeCache = usIdx < 0 ? new Locale(localeStr) : new Locale(localeStr.substring(0, usIdx), localeStr.substring(usIdx+1).toUpperCase()) } else { localeCache = ufi.request != null ? ufi.request.getLocale() : Locale.getDefault() } String tzStr = ua.timeZone tzCache = tzStr ? TimeZone.getTimeZone(tzStr) : TimeZone.getDefault() currencyUomId = userAccount.currencyUomId } else { // set defaults if no user localeCache = ufi.request != null ? ufi.request.getLocale() : Locale.getDefault() tzCache = TimeZone.getDefault() } internalUserGroupIdSet = (Set) null internalArtifactTarpitCheckListMap.clear() internalArtifactAuthzCheckList = (ArrayList) null } String getUsername() { return username } String getUserId() { return userId } EntityValueBase getUserAccount() { return userAccount } Map getUserContext() { if (userContext == null) userContext = new HashMap<>() return userContext } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/WebFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context import groovy.transform.CompileStatic import com.fasterxml.jackson.core.io.JsonStringEncoder import com.fasterxml.jackson.databind.JsonNode import org.apache.commons.fileupload2.core.DiskFileItemFactory import org.apache.commons.fileupload2.core.FileItem import org.apache.commons.fileupload2.core.FileItemFactory import org.apache.commons.fileupload2.jakarta.servlet6.JakartaServletFileUpload import org.apache.commons.io.IOUtils import org.apache.commons.io.output.StringBuilderWriter import org.moqui.context.* import org.moqui.context.MessageFacade.MessageInfo import org.moqui.entity.EntityNotFoundException import org.moqui.entity.EntityValue import org.moqui.entity.EntityValueNotFoundException import org.moqui.impl.context.ExecutionContextFactoryImpl.WebappInfo import org.moqui.impl.screen.ScreenDefinition import org.moqui.impl.screen.ScreenUrlInfo import org.moqui.impl.service.RestApi import org.moqui.impl.service.ServiceJsonRpcDispatcher import org.moqui.impl.util.SimpleSigner import org.moqui.resource.ResourceReference import org.moqui.util.ContextStack import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.moqui.util.WebUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.ServletContext import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpSession import java.nio.charset.StandardCharsets import java.sql.Timestamp import javax.crypto.Mac import javax.crypto.spec.SecretKeySpec /** This class is a facade to easily get information from and about the web context. */ @CompileStatic class WebFacadeImpl implements WebFacade { protected final static Logger logger = LoggerFactory.getLogger(WebFacadeImpl.class) static SimpleSigner qzSigner = new SimpleSigner("qz-private-key.pem") // Not using shared root URL cache because causes issues when requests come to server through different hosts/etc: // protected static final Map webappRootUrlByParms = new HashMap() protected ExecutionContextImpl eci protected String webappMoquiName protected HttpServletRequest request protected HttpServletResponse response protected String requestBodyText = (String) null protected Map savedParameters = (Map) null protected Map multiPartParameters = (Map) null protected Map jsonParameters = (Map) null protected Map declaredPathParameters = (Map) null protected ContextStack parameters = (ContextStack) null protected Map requestAttributes = (Map) null protected Map requestParameters = (Map) null protected Map sessionAttributes = (Map) null protected Map applicationAttributes = (Map) null protected Map errorParameters = (Map) null protected List savedMessages = (List) null protected List savedPublicMessages = (List) null protected List savedErrors = (List) null protected List savedValidationErrors = (List) null WebFacadeImpl(String webappMoquiName, HttpServletRequest request, HttpServletResponse response, ExecutionContextImpl eci) { this.eci = eci this.webappMoquiName = webappMoquiName this.request = request this.response = response MNode webappNode = eci.ecfi.getWebappNode(webappMoquiName) boolean uploadExecutableAllow = "true".equals(webappNode.attribute("upload-executable-allow")) // NOTE: the Visit is not setup here but rather in the MoquiSessionListener (for init and destroy) // don't set 'ec' in request attributes, not serializable: request.setAttribute("ec", eci) // get any parameters saved to the session from the last request, and clear that session attribute if there savedParameters = (Map) request.session.getAttribute("moqui.saved.parameters") if (savedParameters != null) request.session.removeAttribute("moqui.saved.parameters") errorParameters = (Map) request.session.getAttribute("moqui.error.parameters") if (errorParameters != null) request.session.removeAttribute("moqui.error.parameters") // get any messages saved to the session, and clear them from the session if (session.getAttribute("moqui.message.messageInfos") != null) { savedMessages = (List) session.getAttribute("moqui.message.messageInfos") session.removeAttribute("moqui.message.messageInfos") } if (session.getAttribute("moqui.message.publicMessageInfos") != null) { savedPublicMessages = (List) session.getAttribute("moqui.message.publicMessageInfos") session.removeAttribute("moqui.message.publicMessageInfos") } if (session.getAttribute("moqui.message.errors") != null) { savedErrors = (List) session.getAttribute("moqui.message.errors") session.removeAttribute("moqui.message.errors") } if (session.getAttribute("moqui.message.validationErrors") != null) { savedValidationErrors = (List) session.getAttribute("moqui.message.validationErrors") session.removeAttribute("moqui.message.validationErrors") } // if there is a JSON document submitted consider those as parameters too String contentType = request.getHeader("Content-Type") if (ResourceReference.isTextContentType(contentType)) { // read the body first to make sure it isn't empty, better support clients that pass a Content-Type but no content (even though they shouldn't) BufferedReader reader = request.getReader() StringBuilderWriter bodyBuilder = new StringBuilderWriter() if (reader != null) IOUtils.copyLarge(reader, bodyBuilder) if (bodyBuilder.builder.length() > 0) { String bodyString = bodyBuilder.toString() requestBodyText = bodyString multiPartParameters = new HashMap() multiPartParameters.put("_requestBodyText", bodyString) if ((contentType.contains("application/json") || contentType.contains("text/json"))) { try { JsonNode jsonNode = ContextJavaUtil.jacksonMapper.readTree(bodyString) if (jsonNode.isObject()) { jsonParameters = ContextJavaUtil.jacksonMapper.treeToValue(jsonNode, Map.class) } else if (jsonNode.isArray()) { jsonParameters = [_requestBodyJsonList:ContextJavaUtil.jacksonMapper.treeToValue(jsonNode, List.class)] as Map } } catch (Throwable t) { logger.error("Error parsing HTTP request body JSON: ${t.toString()}", t) jsonParameters = [_requestBodyJsonParseError:t.getMessage()] as Map } // logger.warn("=========== Got JSON HTTP request body: ${jsonParameters}") } } } else if (JakartaServletFileUpload.isMultipartContent(request)) { // if this is a multi-part request, get the data for it multiPartParameters = new HashMap() FileItemFactory factory = makeDiskFileItemFactory() JakartaServletFileUpload upload = new JakartaServletFileUpload(factory) List items = (List) upload.parseRequest(request) List fileUploadList = [] multiPartParameters.put("_fileUploadList", fileUploadList) for (FileItem item in items) { if (item.isFormField()) { addValueToMultipartParameterMap(item.getFieldName(), item.getString(StandardCharsets.UTF_8)) } else { if (!uploadExecutableAllow) { if (WebUtilities.isExecutable(item)) { logger.warn("Found executable upload file ${item.getName()}") throw new WebMediaTypeException("Executable file ${item.getName()} upload not allowed") } } // put the FileItem itself in the Map to be used by the application code addValueToMultipartParameterMap(item.getFieldName(), item) fileUploadList.add(item) /* Stuff to do with the FileItem: - get info about the uploaded file String fieldName = item.getFieldName() String fileName = item.getName() String contentType = item.getContentType() boolean isInMemory = item.isInMemory() long sizeInBytes = item.getSize() - get the bytes in memory byte[] data = item.get() - write the data to a File File uploadedFile = new File(...) item.write(uploadedFile) - get the bytes in a stream InputStream uploadedStream = item.getInputStream() ... uploadedStream.close() */ } } } // create the session token if needed (protection against CSRF/XSRF attacks; see ScreenRenderImpl) String sessionToken = session.getAttribute("moqui.session.token") if (sessionToken == null || sessionToken.length() == 0) { sessionToken = StringUtilities.getRandomString(20) session.setAttribute("moqui.session.token", sessionToken) request.setAttribute("moqui.session.token.created", "true") response.setHeader("moquiSessionToken", sessionToken) response.setHeader("X-CSRF-Token", sessionToken) } } /** Apache Commons FileUpload does not support string array so when using multiple select and there's a duplicate * fieldName convert value to an array list when fieldName is already in multipart parameters. */ private void addValueToMultipartParameterMap(String key, Object value) { // change   (\u00a0) to null, used as a placeholder when empty string doesn't work if ("\u00a0".equals(value)) value = null Object previousValue = multiPartParameters.put(key, value) if (previousValue != null) { List valueList = new ArrayList<>() multiPartParameters.put(key, valueList) if(previousValue instanceof Collection) { valueList.addAll((Collection) previousValue) } else { valueList.add(previousValue) } valueList.add(value) } } @Override String getSessionToken() { return getSession().getAttribute("moqui.session.token") } void runFirstHitInVisitActions() { WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName) if (wi.firstHitInVisitActions) wi.firstHitInVisitActions.run(eci) } void runBeforeRequestActions() { WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName) if (wi.beforeRequestActions) wi.beforeRequestActions.run(eci) } void runAfterRequestActions() { WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName) if (wi.afterRequestActions) wi.afterRequestActions.run(eci) } void runAfterLoginActions() { WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName) if (wi.afterLoginActions) wi.afterLoginActions.run(eci) } void runBeforeLogoutActions() { WebappInfo wi = eci.ecfi.getWebappInfo(webappMoquiName) if (wi.beforeLogoutActions) wi.beforeLogoutActions.run(eci) } void saveScreenHistory(ScreenUrlInfo.UrlInstance urlInstanceOrig) { ScreenUrlInfo sui = urlInstanceOrig.sui ScreenDefinition targetScreen = urlInstanceOrig.sui.targetScreen // logger.warn("save hist ${urlInstanceOrig.path} standalone ${sui.lastStandalone} ${targetScreen.isStandalone()} transition ${urlInstanceOrig.getTargetTransition()}") // don't save standalone screens (for sui.lastStandalone int only exclude negative so vapps, etc are saved) if (sui.lastStandalone < 0 || targetScreen.isStandalone()) return // don't save transition requests, just screens if (urlInstanceOrig.getTargetTransition() != null) return // if history=false on the screen don't save if ("false".equals(targetScreen.screenNode.attribute("history"))) return List screenHistoryList = (List) session.getAttribute("moqui.screen.history") if (screenHistoryList == null) { screenHistoryList = Collections.synchronizedList(new LinkedList()) session.setAttribute("moqui.screen.history", screenHistoryList) } ScreenUrlInfo.UrlInstance urlInstance = urlInstanceOrig.cloneUrlInstance() // instead of ignoring page index for history (old approach), retain but exclude in history duplicate search urlInstance.getParameterMap().remove("pageIndex") // logger.warn("======= parameters: ${urlInstance.getParameterMap()}") String urlWithAllParams = urlInstanceOrig.getUrlWithParams() String urlWithParamsNoPageIndex = urlInstance.getUrlWithParams() String urlNoParams = urlInstance.getUrl() // logger.warn("======= urlWithParams: ${urlWithParams}") // if is the same as last screen skip it Map firstItem = screenHistoryList.size() > 0 ? screenHistoryList.get(0) : null if (firstItem != null && firstItem.url == urlWithParamsNoPageIndex) return String targetMenuName = targetScreen.getDefaultMenuName() StringBuilder nameBuilder = new StringBuilder() // append parent screen name ScreenDefinition parentScreen = sui.getParentScreen() if (parentScreen != null) { if (parentScreen.getLocation() != sui.rootSd.getLocation()) nameBuilder.append(parentScreen.getDefaultMenuName()).append(' - ') } // append target screen name if (targetMenuName.contains('${')) { nameBuilder.append(eci.getResource().expand(targetMenuName, targetScreen.getLocation())) } else { nameBuilder.append(targetMenuName) // append parameter values Map parameters = urlInstance.getParameterMap() StringBuilder paramBuilder = new StringBuilder() if (parameters) { int pCount = 0 Iterator> entryIter = parameters.entrySet().iterator() while (entryIter.hasNext() && pCount < 2) { Map.Entry entry = entryIter.next() if (entry.key.contains("_op")) continue if (entry.key.contains("_not")) continue if (entry.key.contains("_ic")) continue if ("moquiSessionToken".equals(entry.key)) continue if (entry.value.trim().length() == 0) continue // injection issue with name field: userId=%3Cscript%3Ealert(%27Test%20Crack!%27)%3C/script%3E String parmValue = entry.value if (parmValue) parmValue = URLEncoder.encode(parmValue, "UTF-8") paramBuilder.append(parmValue) pCount++ if (entryIter.hasNext() && pCount < 2) paramBuilder.append(', ') } } if (paramBuilder.length() > 0) nameBuilder.append(' (').append(paramBuilder.toString()).append(')') } synchronized (screenHistoryList) { // remove existing item(s) from list with same URL Iterator screenHistoryIter = screenHistoryList.iterator() while (screenHistoryIter.hasNext()) { Map screenHistory = screenHistoryIter.next() if (screenHistory.urlNoPageIndex == urlWithParamsNoPageIndex) screenHistoryIter.remove() } // add to history list screenHistoryList.add(0, [name:nameBuilder.toString(), url:urlWithAllParams, urlNoParams:urlNoParams, urlNoPageIndex:urlWithParamsNoPageIndex, path:urlInstance.path, pathWithParams:urlInstance.pathWithParams, image:sui.menuImage, imageType:sui.menuImageType, screenLocation:targetScreen.getLocation()]) // trim the list if needed; keep 40, whatever uses it may display less while (screenHistoryList.size() > 40) screenHistoryList.remove(40) } } @Override List getScreenHistory() { List histList = (List) session.getAttribute("moqui.screen.history") if (histList == null) histList = Collections.synchronizedList(new LinkedList()) return histList } @Override String getRequestUrl() { StringBuilder requestUrl = new StringBuilder() requestUrl.append(request.getScheme()) requestUrl.append("://" + request.getServerName()) if (request.getServerPort() != 80 && request.getServerPort() != 443) requestUrl.append(":" + request.getServerPort()) requestUrl.append(request.getRequestURI()) if (request.getQueryString()) requestUrl.append("?" + request.getQueryString()) return requestUrl.toString() } void addDeclaredPathParameter(String name, String value) { if (declaredPathParameters == null) declaredPathParameters = new HashMap() declaredPathParameters.put(name, value) } @Override Map getParameters() { // NOTE: no blocking in these methods because the WebFacadeImpl is created for each thread // only create when requested, then keep for additional requests if (parameters != null) return parameters // Uses the approach of creating a series of this objects wrapping the other non-Map attributes/etc instead of // copying everything from the various places into a single combined Map; this should be much faster to create // and only slightly slower when running. ContextStack cs = new ContextStack(false) cs.push(getRequestParameters()) cs.push(getApplicationAttributes()) cs.push(getSessionAttributes()) cs.push(getRequestAttributes()) // add an extra Map for anything added so won't go in request attributes (can put there explicitly if desired) cs.push() parameters = cs return parameters } @Override HttpServletRequest getRequest() { return request } @Override Map getRequestAttributes() { if (requestAttributes != null) return requestAttributes requestAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.ServletRequestContainer(request)) return requestAttributes } @Override Map getRequestParameters() { if (requestParameters != null) return requestParameters ContextStack cs = new ContextStack(false) if (savedParameters != null) cs.push(savedParameters) if (multiPartParameters != null) cs.push(multiPartParameters) if (jsonParameters != null) cs.push(jsonParameters) if (declaredPathParameters != null) cs.push(new WebUtilities.CanonicalizeMap(declaredPathParameters)) // no longer uses CanonicalizeMap, search Map for String[] of size 1 and change to String Map reqParmMap = WebUtilities.simplifyRequestParameters(request, false) if (reqParmMap.size() > 0) cs.push(reqParmMap) // NOTE: We decode path parameter ourselves, so use getRequestURI instead of getPathInfo Map pathInfoParameterMap = WebUtilities.getPathInfoParameterMap(request.getRequestURI()) if (pathInfoParameterMap != null && pathInfoParameterMap.size() > 0) cs.push(pathInfoParameterMap) // NOTE: the CanonicalizeMap cleans up character encodings, and unwraps lists of values with a single entry // do one last push so writes don't modify whatever was at the top of the stack cs.push() requestParameters = cs return requestParameters } @Override Map getSecureRequestParameters() { ContextStack cs = new ContextStack(false) if (savedParameters) cs.push(savedParameters) if (multiPartParameters) cs.push(multiPartParameters) if (jsonParameters) cs.push(jsonParameters) Map reqParmMap = WebUtilities.simplifyRequestParameters(request, true) if (reqParmMap.size() > 0) cs.push(reqParmMap) return cs } @Override String getHostName(boolean withPort) { URL requestUrl = new URL(getRequest().getRequestURL().toString()) String hostName = null Integer port = null try { hostName = requestUrl.getHost() port = requestUrl.getPort() // logger.info("Got hostName [${hostName}] from getRequestURL [${webFacade.getRequest().getRequestURL()}]") } catch (Exception e) { /* ignore it, default to getServerName() result */ logger.trace("Error getting hostName from getRequestURL: ", e) } if (!hostName) hostName = getRequest().getServerName() if (!port || port == -1) port = getRequest().getServerPort() if (!port || port == -1) port = getRequest().isSecure() ? 443 : 80 return withPort ? hostName + ":" + port : hostName } @Override String getPathInfo() { return getPathInfo(request) } static String getPathInfo(HttpServletRequest request) { ArrayList pathList = getPathInfoList(request) // as per spec if no extra path info return null if (pathList == null) return null int pathListSize = pathList.size() if (pathListSize == 0) return null StringBuilder pathSb = new StringBuilder(255) for (int i = 0; i < pathListSize; i++) { String pathSegment = (String) pathList.get(i) pathSb.append("/").append(pathSegment) } return pathSb.toString() } @Override ArrayList getPathInfoList() { return getPathInfoList(request) } static ArrayList getPathInfoList(HttpServletRequest request) { // generated URL path segments are encoded with URLEncoder, to match use URLDecoder instead of servlet container's decoding // this uses the application/x-www-form-urlencoded MIME format for screen path segments // was: String pathInfo = request.getPathInfo() String reqURI = request.getRequestURI() // exclude servlet path segments String servletPath = request.getServletPath() // subtract 1 to exclude empty string before leading '/' that will always be there int servletPathSize = servletPath.isEmpty() ? 0 : (servletPath.split("/").length - 1) // exclude context path segments String contextPath = request.getContextPath() int contextPathSize = contextPath.isEmpty() ? 0 : (contextPath.split("/").length - 1) ArrayList pathList = StringUtilities.pathStringToList(reqURI, servletPathSize + contextPathSize) // logger.warn("pathInfo ${request.getPathInfo()} servletPath ${servletPath} reqURI ${request.getRequestURI()} pathList ${pathList}") return pathList } @Override String getRequestBodyText() { return requestBodyText } @Override String getResourceDistinctValue() { return eci.ecfi.initStartHex } @Override HttpServletResponse getResponse() { return response } @Override HttpSession getSession() { return request.getSession() } /** Invalidate the current session (if there is one) and create a new session for the request, copies attributes. * NOTE that this must be called before any response is sent and more generally before screen rendering begins. */ HttpSession makeNewSession() { HttpSession oldSession = request.getSession(false) Map oldSessionAttributes = (Map) null if (oldSession != null) { // get old session attributes and put in new HashMap so that are copied right away, because this wraps the session won't work after invalidate oldSessionAttributes = new HashMap<>(new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(oldSession))) oldSession.invalidate() } HttpSession newSession = request.getSession(true) if (oldSessionAttributes != null) for (Map.Entry attrEntry in oldSessionAttributes.entrySet()) { newSession.setAttribute(attrEntry.getKey(), attrEntry.getValue()) // logger.warn("Copying attr ${attrEntry.getKey()}:${attrEntry.getValue()}") } // force a new moqui.session.token String sessionToken = StringUtilities.getRandomString(20) newSession.setAttribute("moqui.session.token", sessionToken) request.setAttribute("moqui.session.token.created", "true") if (response != null) { response.setHeader("moquiSessionToken", sessionToken) response.setHeader("X-CSRF-Token", sessionToken) } // remake sessionAttributes to use newSession sessionAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(newSession)) // UserFacadeImpl keeps a session reference, update it if (eci.userFacade != null) eci.userFacade.session = newSession // done return newSession } @Override Map getSessionAttributes() { if (sessionAttributes != null) return sessionAttributes sessionAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.HttpSessionContainer(getSession())) return sessionAttributes } @Override ServletContext getServletContext() { return getSession().getServletContext() } @Override Map getApplicationAttributes() { if (applicationAttributes != null) return applicationAttributes applicationAttributes = new WebUtilities.AttributeContainerMap(new WebUtilities.ServletContextContainer(getServletContext())) return applicationAttributes } String getWebappMoquiName() { return webappMoquiName } @Override String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption) { return getWebappRootUrl(this.webappMoquiName, null, requireFullUrl, useEncryption, eci) } static String getWebappRootUrl(String webappName, String servletContextPath, boolean requireFullUrl, Boolean useEncryption, ExecutionContextImpl eci) { WebFacade webFacade = eci.getWeb() HttpServletRequest request = webFacade?.getRequest() boolean requireEncryption = useEncryption == null && request != null ? request.isSecure() : (useEncryption != null ? useEncryption.booleanValue() : false) boolean needFullUrl = requireFullUrl || request == null || (requireEncryption && !request.isSecure()) || (!requireEncryption && request.isSecure()) /* Not using shared root URL cache because causes issues when requests come to server through different hosts/etc: String cacheKey = webappName + servletContextPath + needFullUrl.toString() + requireEncryption.toString() String cachedRootUrl = webappRootUrlByParms.get(cacheKey) if (cachedRootUrl != null) return cachedRootUrl String urlValue = makeWebappRootUrl(webappName, servletContextPath, eci, webFacade, requireEncryption, needFullUrl) webappRootUrlByParms.put(cacheKey, urlValue) return urlValue */ // cache the root URLs just within the request, common to generate various URLs in a single request String cacheKey = (String) null if (request != null) { StringBuilder keyBuilder = new StringBuilder(200) keyBuilder.append(webappName).append(servletContextPath) if (needFullUrl) keyBuilder.append("T") else keyBuilder.append("F") if (requireEncryption) keyBuilder.append("T") else keyBuilder.append("F") cacheKey = keyBuilder.toString() String cachedRootUrl = request.getAttribute(cacheKey) if (cachedRootUrl != null) return cachedRootUrl } String urlValue = makeWebappRootUrl(webappName, servletContextPath, eci, webFacade, requireEncryption, needFullUrl) if (cacheKey != null) request.setAttribute(cacheKey, urlValue) return urlValue } static String makeWebappHost(String webappName, ExecutionContextImpl eci, WebFacade webFacade, boolean requireEncryption) { WebappInfo webappInfo = eci.ecfi.getWebappInfo(webappName) // can't get these settings, hopefully a URL from the root will do if (webappInfo == null) return "" StringBuilder urlBuilder = new StringBuilder() HttpServletRequest request = webFacade?.getRequest() if ("https".equals(request?.getScheme()) || (requireEncryption && webappInfo.httpsEnabled)) { urlBuilder.append("https://") if (webappInfo.httpsHost != null) { urlBuilder.append(webappInfo.httpsHost) } else { if (webFacade != null) { urlBuilder.append(webFacade.getHostName(false)) } else { // uh-oh, no web context, default to localhost urlBuilder.append("localhost") } } String httpsPort = webappInfo.httpsPort // try the local port; this won't work when switching from http to https, conf required for that if (httpsPort == null && request != null && request.isSecure()) httpsPort = request.getServerPort() as String if (httpsPort != null && !httpsPort.isEmpty() && !"443".equals(httpsPort)) urlBuilder.append(":").append(httpsPort) } else { urlBuilder.append("http://") if (webappInfo.httpHost != null) { urlBuilder.append(webappInfo.httpHost) } else { if (webFacade != null) { urlBuilder.append(webFacade.getHostName(false)) } else { // uh-oh, no web context, default to localhost urlBuilder.append("localhost") logger.trace("No webapp http-host and no webFacade in place, defaulting to localhost for hostName") } } String httpPort = webappInfo.httpPort // try the server port; this won't work when switching from https to http, conf required for that if (!httpPort && request != null && !request.isSecure()) httpPort = request.getServerPort() as String if (httpPort != null && !httpPort.isEmpty() && !"80".equals(httpPort)) urlBuilder.append(":").append(httpPort) } return urlBuilder.toString() } static String makeWebappRootUrl(String webappName, String servletContextPath, ExecutionContextImpl eci, WebFacade webFacade, boolean requireEncryption, boolean needFullUrl) { StringBuilder urlBuilder = new StringBuilder() // build base from conf if (needFullUrl) urlBuilder.append(makeWebappHost(webappName, eci, webFacade, requireEncryption)) urlBuilder.append("/") // add servletContext.contextPath if (!servletContextPath && webFacade) servletContextPath = webFacade.getServletContext().getContextPath() if (servletContextPath) { if (servletContextPath.startsWith("/")) servletContextPath = servletContextPath.substring(1) urlBuilder.append(servletContextPath) } // make sure we don't have a trailing slash if (urlBuilder.charAt(urlBuilder.length()-1) == (char) '/') urlBuilder.deleteCharAt(urlBuilder.length()-1) String urlValue = urlBuilder.toString() return urlValue } String getRequestDetails() { StringBuilder sb = new StringBuilder() sb.append("Request: ").append(request.getMethod()).append(" ").append(request.getRequestURL()).append("\n") sb.append("Scheme: ").append(request.getScheme()).append(", Secure? ").append(request.isSecure()).append("\n") sb.append("Remote: ").append(request.getRemoteAddr()).append(" - ").append(request.getRemoteHost()).append("\n") for (String hn in request.getHeaderNames()) { sb.append("Header: ").append(hn).append(" = ") for (String hv in request.getHeaders(hn)) sb.append("[").append(hv).append("] ") sb.append("\n") } for (String pn in request.getParameterNames()) sb.append("Parameter: ").append(pn).append(" = ").append(request.getParameterValues(pn)).append("\n") return sb.toString() } @Override Map getErrorParameters() { return errorParameters } @Override List getSavedMessages() { return savedMessages } @Override List getSavedPublicMessages() { return savedPublicMessages } @Override List getSavedErrors() { return savedErrors } @Override List getSavedValidationErrors() { return savedValidationErrors } @Override List getFieldValidationErrors(String fieldName) { List errorList = null if (savedValidationErrors != null && savedValidationErrors.size() > 0) { for (ValidationError ve in savedValidationErrors) if (fieldName == null || fieldName.equals(ve.field)) { if (errorList == null) errorList = new ArrayList(5) errorList.add(ve) } } List mfErrorList = eci.messageFacade.getValidationErrors() if (mfErrorList != null && mfErrorList.size() > 0) { for (ValidationError ve in mfErrorList) if (fieldName == null || fieldName.equals(ve.field)) { if (errorList == null) errorList = new ArrayList(5) errorList.add(ve) } } return errorList } @Override void sendJsonResponse(Object responseObj) { sendJsonResponseInternal(responseObj, eci, request, response, requestAttributes) } static void sendJsonResponseInternal(Object responseObj, ExecutionContextImpl eci, HttpServletRequest request, HttpServletResponse response, Map requestAttributes) { String jsonStr = null if (responseObj instanceof CharSequence) { jsonStr = responseObj.toString() responseObj = null } else { Map responseMap = responseObj instanceof Map ? (Map) responseObj : null if (eci.message.messages) { if (responseObj == null) { responseObj = [messages:eci.message.getMessagesString()] as Map } else if (responseMap != null && !responseMap.containsKey("messages")) { responseMap = new HashMap(responseMap) responseMap.put("messages", eci.message.getMessagesString()) responseObj = responseMap } } if (eci.getMessage().hasError()) { // if the responseObj is a Map add all of it's data // only add an errors if it is not a jsonrpc response (JSON RPC has it's own error handling) if (responseMap != null && !responseMap.containsKey("errors") && !responseMap.containsKey("jsonrpc")) { responseMap = new HashMap(responseMap) responseMap.put("errors", eci.message.errorsString) responseObj = responseMap } else if (responseObj != null && !(responseObj instanceof Map)) { logger.error("Error found when sending JSON string, JSON object is not a Map so not adding errors to return: ${eci.message.errorsString}") } response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) } else { response.setStatus(HttpServletResponse.SC_OK) } } // logger.warn("========== Sending JSON for object: ${responseObj}") if (responseObj != null) jsonStr = ContextJavaUtil.jacksonMapper.writeValueAsString(responseObj) if (!jsonStr) return // logger.warn("========== Sending JSON string: ${jsonStr}") response.setContentType("application/json") // NOTE: String.length not correct for byte length String charset = response.getCharacterEncoding() ?: "UTF-8" int length = jsonStr.getBytes(charset).length response.setContentLength(length) try { response.writer.write(jsonStr) response.writer.flush() if (logger.isTraceEnabled()) { Long startTime = (Long) requestAttributes.get("moquiRequestStartTime") String timeMsg = "" if (startTime) timeMsg = "in ${(System.currentTimeMillis()-startTime)}ms" logger.trace("Sent JSON response ${length} bytes ${charset} encoding ${timeMsg} for ${request.getMethod()} to ${request.getPathInfo()}") } } catch (IOException e) { logger.error("Error sending JSON string response", e) } } @Override void sendJsonError(int statusCode, String message, Throwable origThrowable) { sendJsonErrorInternal(statusCode, message, origThrowable, response) } static void sendJsonErrorInternal(int statusCode, String message, Throwable origThrowable, HttpServletResponse response) { if ((message == null || message.isEmpty()) && origThrowable != null) message = origThrowable.message // NOTE: uses same field name as sendJsonResponseInternal String jsonStr = ContextJavaUtil.jacksonMapper.writeValueAsString([errorCode:statusCode, errors:message]) response.setContentType("application/json") // NOTE: String.length not correct for byte length String charset = response.getCharacterEncoding() ?: "UTF-8" int length = jsonStr.getBytes(charset).length response.setContentLength(length) response.setStatus(statusCode) response.writer.write(jsonStr) response.writer.flush() } @Override void sendTextResponse(String text) { sendTextResponseInternal(text, "text/plain", null, eci, request, response, requestAttributes) } @Override void sendTextResponse(String text, String contentType, String filename) { sendTextResponseInternal(text, contentType, filename, eci, request, response, requestAttributes) } static void sendTextResponseInternal(String text, String contentType, String filename, ExecutionContextImpl eci, HttpServletRequest request, HttpServletResponse response, Map requestAttributes) { if (!contentType) contentType = "text/plain" String responseText if (eci.getMessage().hasError()) { responseText = eci.message.errorsString response.setStatus(HttpServletResponse.SC_INTERNAL_SERVER_ERROR) } else { responseText = text response.setStatus(HttpServletResponse.SC_OK) } response.setContentType(contentType) // NOTE: String.length not correct for byte length String charset = response.getCharacterEncoding() ?: "UTF-8" int length = responseText != null ? responseText.getBytes(charset).length : 0I response.setContentLength(length) if (!filename) { response.setHeader("Content-Disposition", "inline") } else { response.setHeader("Content-Disposition", "attachment; filename=\"${filename}\"; filename*=utf-8''${StringUtilities.encodeAsciiFilename(filename)}") } try { if (responseText) response.writer.write(responseText) response.writer.flush() if (logger.infoEnabled) { Long startTime = (Long) requestAttributes.get("moquiRequestStartTime") String timeMsg = "" if (startTime) timeMsg = "in [${(System.currentTimeMillis()-startTime)/1000}] seconds" logger.info("Sent text (${contentType}) response of length [${length}] with [${charset}] encoding ${timeMsg} for ${request.getMethod()} request to ${request.getPathInfo()}") } } catch (IOException e) { logger.error("Error sending text response", e) } } @Override void sendResourceResponse(String location) { sendResourceResponseInternal(location, false, eci, response) } @Override void sendResourceResponse(String location, boolean inline) { sendResourceResponseInternal(location, inline, eci, response) } static void sendResourceResponseInternal(String location, boolean inline, ExecutionContextImpl eci, HttpServletResponse response) { ResourceReference rr = eci.resource.getLocationReference(location) if (rr == null || (rr.supportsExists() && !rr.getExists())) { logger.warn("Sending not found response, resource not found at: ${location}") response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found at ${location}") return } String contentType = rr.getContentType() if (contentType) response.setContentType(contentType) if (inline) { response.setHeader("Content-Disposition", "inline") WebappInfo webappInfo = eci.ecfi.getWebappInfo(eci.webImpl?.webappMoquiName) if (webappInfo != null) { webappInfo.addHeaders("web-resource-inline", response) } else { response.setHeader("Cache-Control", "max-age=86400, must-revalidate, public") } } else { response.setHeader("Content-Disposition", "attachment; filename=\"${rr.getFileName()}\"; filename*=utf-8''${StringUtilities.encodeAsciiFilename(rr.getFileName())}") } if (contentType == null || contentType.isEmpty() || ResourceReference.isBinaryContentType(contentType)) { InputStream is = rr.openStream() if (is == null) { logger.warn("Sending not found response, openStream returned null for location: ${location}") response.sendError(HttpServletResponse.SC_NOT_FOUND, "Resource not found at ${location}") return } try { OutputStream os = response.outputStream try { int totalLen = ObjectUtilities.copyStream(is, os) logger.info("Streamed ${totalLen} bytes from location ${location}") } finally { os.close() } } finally { is.close() } } else { String rrText = rr.getText() if (rrText) response.writer.append(rrText) response.writer.flush() } } void sendQzSignedResponse(String message) { try { String signature = qzSigner.sign(message) response.setContentType("text/plain") response.getWriter().write(signature) } catch (Exception e) { logger.error("Error signing QZ message, sending error response: " + e.toString()) response.sendError(500, e.message) } } static Map errorCodeNames = [401:"Authentication Required", 403:"Access Forbidden", 404:"Not Found", 429:"Too Many Requests", 500:"Internal Server Error"] @Override void sendError(int errorCode, String message, Throwable origThrowable) { sendError(errorCode, message, origThrowable, request, response) } static void sendError(int errorCode, String message, Throwable origThrowable, HttpServletRequest request, HttpServletResponse response) { if ((message == null || message.isEmpty()) && origThrowable != null) message = origThrowable.message String errorCodeName = errorCodeNames.get(errorCode) ?: "" if (message == null || message.isEmpty()) message = errorCodeName String acceptHeader = request.getHeader("Accept") if (acceptHeader == null) acceptHeader = "" if (acceptHeader.contains("text/html")) { // logger.warn("sendError html ${errorCode} ${message}") response.setStatus(errorCode) response.setContentType("text/html") response.setCharacterEncoding("UTF-8") Writer writer = response.getWriter() writer.write('') writer.write("Error ${errorCode} ${errorCodeName}") writer.write("\n") writer.write("

Error ${errorCode} ${errorCodeName}

\n") writer.write("

Problem accessing ${WebUtilities.encodeHtml(getPathInfo(request))}

\n") if (message != null && !message.isEmpty()) writer.write("

Reason: ${WebUtilities.encodeHtml(message)}

\n") writer.write("\n") writer.flush() // NOTE: maybe include throwable info, do we ever want that? } else if (acceptHeader.contains("application/json") || acceptHeader.contains("text/json")) { // logger.warn("sendError json ${errorCode} ${message}") response.setStatus(errorCode) response.setContentType("application/json") response.setCharacterEncoding("UTF-8") JsonStringEncoder jsonEncoder = JsonStringEncoder.getInstance() Writer writer = response.getWriter() writer.write("{'message':'") writer.write(jsonEncoder.quoteAsString(message)) writer.write("','errorName':'") writer.write(errorCodeName) writer.write("','error':") writer.write(Integer.toString(errorCode)) writer.write(",'path':'") writer.write(jsonEncoder.quoteAsString(getPathInfo(request))) writer.write("'}") writer.flush() } else { // logger.warn("sendError default ${errorCode} ${message}") response.setStatus(errorCode) response.setContentType("text/plain") response.setCharacterEncoding("UTF-8") Writer writer = response.getWriter() writer.write(Integer.toString(errorCode)) writer.write(" ") writer.write(message) writer.write(" ") writer.write(getPathInfo(request)) writer.flush() } } @Override void handleJsonRpcServiceCall() { new ServiceJsonRpcDispatcher(eci).dispatch() } @Override void handleEntityRestCall(List extraPathNameList, boolean masterNameInPath) { ContextStack parmStack = (ContextStack) getParameters() // check for parsing error, send a 400 response if (parmStack._requestBodyJsonParseError) { sendJsonError(HttpServletResponse.SC_BAD_REQUEST, (String) parmStack._requestBodyJsonParseError, null) return } // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth if (!eci.getUser().getUsername()) { // if there was a login error there will be a MessageFacade error message String errorMessage = eci.message.errorsString if (!errorMessage) errorMessage = "Authentication required for entity REST operations" sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null) return } String method = request.getMethod() if ("post".equalsIgnoreCase(method)) { String ovdMethod = request.getHeader("X-HTTP-Method-Override") if (ovdMethod != null && !ovdMethod.isEmpty()) method = ovdMethod.toLowerCase() } try { // logger.warn("====== parameters: ${parmStack.toString()}") long startTime = System.currentTimeMillis() // if _requestBodyJsonList do multiple calls if (parmStack._requestBodyJsonList) { // TODO: Consider putting all of this in a transaction for non-find operations (currently each is run in // TODO: a separate transaction); or handle errors per-row instead of blowing up the whole request List responseList = [] for (Object bodyListObj in parmStack._requestBodyJsonList) { if (!(bodyListObj instanceof Map)) { String errMsg = "If request body JSON is a list/array it must contain only object/map values, found non-map entry of type ${bodyListObj.getClass().getName()} with value: ${bodyListObj}" logger.warn(errMsg) sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errMsg, null) return } // logger.warn("========== REST ${method} ${request.getPathInfo()} ${extraPathNameList}; body list object: ${bodyListObj}") parmStack.push() parmStack.putAll((Map) bodyListObj) Object responseObj = eci.entityFacade.rest(method, extraPathNameList, parmStack, masterNameInPath) responseList.add(responseObj ?: [:]) parmStack.pop() } response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int) sendJsonResponse(responseList) } else { Object responseObj = eci.entityFacade.rest(method, extraPathNameList, parmStack, masterNameInPath) response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int) if (parmStack.xTotalCount != null) response.addIntHeader('X-Total-Count', parmStack.xTotalCount as int) if (parmStack.xPageIndex != null) response.addIntHeader('X-Page-Index', parmStack.xPageIndex as int) if (parmStack.xPageSize != null) response.addIntHeader('X-Page-Size', parmStack.xPageSize as int) if (parmStack.xPageMaxIndex != null) response.addIntHeader('X-Page-Max-Index', parmStack.xPageMaxIndex as int) if (parmStack.xPageRangeLow != null) response.addIntHeader('X-Page-Range-Low', parmStack.xPageRangeLow as int) if (parmStack.xPageRangeHigh != null) response.addIntHeader('X-Page-Range-High', parmStack.xPageRangeHigh as int) // NOTE: This will always respond with 200 OK, consider using 201 Created (for successful POST, create PUT) // and 204 No Content (for DELETE and other when no content is returned) sendJsonResponse(responseObj) } } catch (ArtifactAuthorizationException e) { // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures logger.warn("REST Access Forbidden (403 no authz): " + e.message) sendJsonError(HttpServletResponse.SC_FORBIDDEN, null, e) } catch (ArtifactTarpitException e) { logger.warn("REST Too Many Requests (429 tarpit): " + e.message) if (e.getRetryAfterSeconds()) response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details sendJsonError(429, null, e) } catch (EntityNotFoundException e) { logger.warn((String) "REST Entity Not Found (404): " + e.message, e) // send 404 Not Found for entities that don't exist (along with records that don't exist) sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e) } catch (EntityValueNotFoundException e) { logger.warn("REST Entity Value Not Found (404): " + e.message) // record doesn't exist, send 404 Not Found sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e) } catch (Throwable t) { String errorMessage = t.toString() if (eci.message.hasError()) { String errorsString = eci.message.errorsString logger.error(errorsString, t) errorMessage = errorMessage + ' ' + errorsString } logger.warn((String) "General error in entity REST: " + t.toString(), t) sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorMessage, null) } } @Override void handleServiceRestCall(List extraPathNameList) { ContextStack parmStack = (ContextStack) getParameters() logger.info("Service REST for ${request.getMethod()} to ${request.getPathInfo()} headers ${request.headerNames.collect()} parameters ${getRequestParameters().keySet()}") // check for login, etc error messages if (eci.message.hasError()) { String errorsString = eci.message.errorsString if ("true".equals(request.getAttribute("moqui.login.error"))) { logger.warn((String) "Login error in Service REST API: " + errorsString) sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorsString, null) } else { logger.warn((String) "General error in Service REST API: " + errorsString) sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString, null) } return } // check for parsing error, send a 400 response if (parmStack._requestBodyJsonParseError) { sendJsonError(HttpServletResponse.SC_BAD_REQUEST, (String) parmStack._requestBodyJsonParseError, null) return } try { long startTime = System.currentTimeMillis() // if _requestBodyJsonList do multiple calls if (parmStack._requestBodyJsonList) { // TODO: Consider putting all of this in a transaction for non-find operations (currently each is run in // TODO: a separate transaction); or handle errors per-row instead of blowing up the whole request List responseList = [] for (Object bodyListObj in parmStack._requestBodyJsonList) { if (!(bodyListObj instanceof Map)) { String errMsg = "If request body JSON is a list/array it must contain only object/map values, found non-map entry of type ${bodyListObj.getClass().getName()} with value: ${bodyListObj}" logger.warn(errMsg) sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errMsg, null) return } // logger.warn("========== REST ${request.getMethod()} ${request.getPathInfo()} ${extraPathNameList}; body list object: ${bodyListObj}") parmStack.push() parmStack.putAll((Map) bodyListObj) eci.contextStack.push(parmStack) RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci) responseList.add(restResult.responseObj ?: [:]) eci.contextStack.pop() parmStack.pop() } response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int) if (eci.message.hasError()) { // if error return that String errorsString = eci.message.errorsString logger.warn((String) "General error in Service REST API: " + errorsString) sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString, null) } else { // otherwise send response sendJsonResponse(responseList) } } else { eci.contextStack.push(parmStack) RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci) eci.contextStack.pop() response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int) restResult.setHeaders(response) if (eci.message.hasError()) { // if error return that String errorsString = eci.message.errorsString logger.warn((String) "Error message from Service REST API (400): " + errorsString) sendJsonError(HttpServletResponse.SC_BAD_REQUEST, errorsString, null) } else { // NOTE: This will always respond with 200 OK, consider using 201 Created (for successful POST, create PUT) // and 204 No Content (for DELETE and other when no content is returned) sendJsonResponse(restResult.responseObj) } } } catch (AuthenticationRequiredException e) { logger.warn("REST Unauthorized (401 no authc): " + e.message) sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, null, e) } catch (ArtifactAuthorizationException e) { // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures logger.warn("REST Access Forbidden (403 no authz): " + e.message) sendJsonError(HttpServletResponse.SC_FORBIDDEN, null, e) } catch (ArtifactTarpitException e) { logger.warn("REST Too Many Requests (429 tarpit): " + e.message) if (e.getRetryAfterSeconds()) response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details sendJsonError(429, null, e) } catch (RestApi.ResourceNotFoundException e) { logger.warn((String) "REST Resource Not Found (404): " + e.message) // send 404 Not Found for resources/paths that don't exist (along with records that don't exist) sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e) } catch (RestApi.MethodNotSupportedException e) { logger.warn((String) "REST Method Not Supported (405): " + e.message) sendJsonError(HttpServletResponse.SC_METHOD_NOT_ALLOWED, null, e) } catch (EntityValueNotFoundException e) { logger.warn("REST Entity Value Not Found (404): " + e.message) // record doesn't exist, send 404 Not Found sendJsonError(HttpServletResponse.SC_NOT_FOUND, null, e) } catch (Throwable t) { String errorMessage = t.toString() if (eci.message.hasError()) { String errorsString = eci.message.errorsString logger.error(errorsString, t) errorMessage = errorMessage + ' ' + errorsString } logger.warn((String) "Error thrown in Service REST API (500): " + t.toString(), t) sendJsonError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorMessage, null) } } void handleSystemMessage(List extraPathNameList) { int pathSize = extraPathNameList.size() if (pathSize < 2) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "No message type or remote system specified") return } String systemMessageTypeId = (String) extraPathNameList.get(0) String systemMessageRemoteId = (String) extraPathNameList.get(1) String remoteMessageId = pathSize > 2 ? (String) extraPathNameList.get(2) : (String) null String messageText = getRequestBodyText() if (messageText == null || messageText.isEmpty()) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Request body empty") return } try { // make sure systemMessageTypeId and systemMessageRemoteId are valid before the service call EntityValue systemMessageType = eci.entityFacade.find("moqui.service.message.SystemMessageType") .condition("systemMessageTypeId", systemMessageTypeId).disableAuthz().one() if (systemMessageType == null) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Message type ${systemMessageTypeId} not valid") return } EntityValue systemMessageRemote = eci.entityFacade.find("moqui.service.message.SystemMessageRemote") .condition("systemMessageRemoteId", systemMessageRemoteId).disableAuthz().one() if (systemMessageRemote == null) { response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Remote system ${systemMessageRemoteId} not valid") return } // authc mechanism, what can clients send? custom header or body or anything? may need various options String userId = eci.userFacade.getUserId() String messageAuthEnumId = systemMessageRemote.getNoCheckSimple("messageAuthEnumId") // TODO: consider moving this elsewhere if (!messageAuthEnumId || "SmatLogin".equals(messageAuthEnumId)) { // require that user is logged in by this point (handled by UserFacadeImpl init) if (!userId) { response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Receive message for remote system ${systemMessageRemoteId} requires login") return } // see if isPermitted for service org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl("org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage", ArtifactExecutionInfo.AT_SERVICE, ArtifactExecutionInfo.AUTHZA_ALL, null) try { eci.artifactExecutionFacade.isPermitted(aeii, null, true, false, true, null) } catch (ArtifactAuthorizationException e) { logger.warn("Authz failutre for system message receive from remote ${systemMessageRemoteId}", e.toString()) response.sendError(HttpServletResponse.SC_FORBIDDEN, "Receive message for remote system ${systemMessageRemoteId} not authorized for user with ID ${userId}") return } } else if ("SmatHmacSha256".equals(messageAuthEnumId)) { // validate HMAC value from authHeaderName HTTP header using sharedSecret and messageText String authHeaderName = (String) systemMessageRemote.authHeaderName String sharedSecret = (String) systemMessageRemote.sharedSecret String headerValue = request.getHeader(authHeaderName) if (!headerValue) { logger.warn("System message receive HMAC verify no header ${authHeaderName} value found, for remote ${systemMessageRemoteId}") response.sendError(HttpServletResponse.SC_FORBIDDEN, "No HMAC header ${authHeaderName} found for remote system ${systemMessageRemoteId}") return } Mac hmac = Mac.getInstance("HmacSHA256") hmac.init(new SecretKeySpec(sharedSecret.getBytes("UTF-8"), "HmacSHA256")) // NOTE: if this fails try with "ISO-8859-1" String signature = Base64.encoder.encodeToString(hmac.doFinal(messageText.getBytes("UTF-8"))) if (headerValue != signature) { logger.warn("System message receive HMAC verify header value ${headerValue} calculated ${signature} did not match for remote ${systemMessageRemoteId}") response.sendError(HttpServletResponse.SC_FORBIDDEN, "HMAC verify failed for remote system ${systemMessageRemoteId}") return } // login anonymous if not logged in eci.userFacade.loginAnonymousIfNoUser() } else if ("SmatHmacSha256Timestamp".equals(messageAuthEnumId)) { // validate HMAC value from authHeaderName HTTP header using sharedSecret and messageText String authHeaderName = (String) systemMessageRemote.authHeaderName String sharedSecret = (String) systemMessageRemote.sharedSecret String headerValue = request.getHeader(authHeaderName) if (!headerValue) { logger.warn("System message receive HMAC verify no header ${authHeaderName} value found, for remote ${systemMessageRemoteId}") response.sendError(HttpServletResponse.SC_FORBIDDEN, "No HMAC header ${authHeaderName} found for remote system ${systemMessageRemoteId}") return } // This assumes a header format like // Example-Signature-Header: //t=1492774577, //v1=5257a869e7ecebeda32affa62cdca3fa51cad7e77a0e56ff536d0ce8e108d8bd // We’ve added newlines for clarity, but a realExample-Signature-Header is on a single line. String timestamp = null; String incomingSignature = null; String[] headerValueList = headerValue.split(",") // split on comma for (String headerValueItem : headerValueList) { String key = headerValueItem.split("=")[0].trim() if ("t".equals(key)) timestamp = headerValueItem.split("=")[1].trim() else if ("v1".equals(key)) incomingSignature = headerValueItem.split("=")[1].trim() } // This also assumes that the signature is generated from the following concatenated strings: // Timestamp in the header // The character . // The text body of the request String signatureTextToVerify = timestamp + "." + messageText Mac hmac = Mac.getInstance("HmacSHA256") hmac.init(new SecretKeySpec(sharedSecret.getBytes(StandardCharsets.UTF_8), "HmacSHA256")) // NOTE: if this fails try with "ISO-8859-1" byte[] hash = hmac.doFinal(signatureTextToVerify.getBytes(StandardCharsets.UTF_8)); String signature = "" for (byte b : hash) { // Came from https://github.com/stripe/stripe-java/blob/3686feb8f2067878b7bb4619f931580a3d31bf4f/src/main/java/com/stripe/net/Webhook.java#L187 signature += Integer.toString((b & 0xff) + 0x100, 16).substring(1); } if (incomingSignature != signature) { logger.warn("System message receive HMAC verify header value ${incomingSignature} calculated ${signature} did not match for remote ${systemMessageRemoteId}") response.sendError(HttpServletResponse.SC_FORBIDDEN, "HMAC verify failed for remote system ${systemMessageRemoteId}") return } Timestamp incomingTimestamp = new Timestamp(Long.parseLong(timestamp) * 1000) // Add 10 seconds to now timestamp to allow for clock skew (10 seconds = 10000 milliseconds = 10*1000) Timestamp nowTimestamp = new Timestamp(eci.user.nowTimestamp.getTime() + 10000) // If timestamp was not sent in past 5 minutes, reject message (5 minutes = 300000 milliseconds = 5*60*1000) Timestamp beforeTimestamp = new Timestamp(nowTimestamp.getTime() - 300000) if (!incomingTimestamp.before(nowTimestamp) || !incomingTimestamp.after(beforeTimestamp) ){ logger.warn("System message receive HMAC invalid incoming timestamp where before timestamp ${beforeTimestamp} < incoming timestamp ${incomingTimestamp} < now timestamp ${nowTimestamp}" ) response.sendError(HttpServletResponse.SC_FORBIDDEN, "HMAC timestamp verification failed") return } // login anonymous if not logged in eci.userFacade.loginAnonymousIfNoUser() } else if (!"SmatNone".equals(messageAuthEnumId)) { logger.error("Got system message for remote ${systemMessageRemoteId} with unsupported messageAuthEnumId ${messageAuthEnumId}, returning error") response.sendError(HttpServletResponse.SC_BAD_REQUEST, "Remote system ${systemMessageRemoteId} auth configuration not valid") return } // NOTE: called with disableAuthz() since we do an authz check before when needed Map result = eci.serviceFacade.sync().name("org.moqui.impl.SystemMessageServices.receive#IncomingSystemMessage") .parameter("systemMessageTypeId", systemMessageTypeId).parameter("systemMessageRemoteId", systemMessageRemoteId) .parameter("remoteMessageId", remoteMessageId).parameter("messageText", messageText).disableAuthz().call() if (eci.messageFacade.hasError()) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, eci.messageFacade.getErrorsString()) return } // technically SC_ACCEPTED (202) is more accurate, OK (200) more common response.setStatus(HttpServletResponse.SC_OK) // TODO: consider returning response with systemMessageIdList in JSON or XML based on Accept header } catch (Throwable t) { logger.error("Error handling system message type ${systemMessageTypeId} remote ${systemMessageRemoteId} remote msg ${remoteMessageId}", t) response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "Error receiving message: ${t.toString()}") } } /* Session based pass through handling, etc */ void saveScreenLastInfo(String screenPath, Map parameters) { session.setAttribute("moqui.screen.last.path", screenPath ?: getPathInfo()) parameters = parameters ?: new HashMap(getRequestParameters()) // logger.warn("saveScreenLastInfo parameters: ${parameters}") // logger.warn("saveScreenLastInfo getRequestParameters(): ${getRequestParameters().toString()}") WebUtilities.testSerialization("moqui.screen.last.parameters", parameters) session.setAttribute("moqui.screen.last.parameters", parameters) } String getRemoveScreenLastPath() { String path = session.getAttribute("moqui.screen.last.path") session.removeAttribute("moqui.screen.last.path") return path } Map getSavedParameters() { return (Map) session.getAttribute("moqui.saved.parameters") } void removeScreenLastParameters(boolean moveToSaved) { if (moveToSaved) session.setAttribute("moqui.saved.parameters", session.getAttribute("moqui.screen.last.parameters")) session.removeAttribute("moqui.screen.last.parameters") } void saveMessagesToSession() { List messageInfos = eci.messageFacade.getMessageInfos() WebUtilities.testSerialization("moqui.message.messageInfos", messageInfos) if (messageInfos != null && messageInfos.size() > 0) session.setAttribute("moqui.message.messageInfos", messageInfos) List publicMessageInfos = eci.messageFacade.getPublicMessageInfos() WebUtilities.testSerialization("moqui.message.publicMessageInfos", publicMessageInfos) if (publicMessageInfos != null && publicMessageInfos.size() > 0) session.setAttribute("moqui.message.publicMessageInfos", publicMessageInfos) List errors = eci.messageFacade.getErrors() if (errors != null && errors.size() > 0) session.setAttribute("moqui.message.errors", errors) List validationErrors = eci.messageFacade.validationErrors WebUtilities.testSerialization("moqui.message.validationErrors", validationErrors) if (validationErrors != null && validationErrors.size() > 0) session.setAttribute("moqui.message.validationErrors", validationErrors) } /** Save passed parameters Map to a Map in the moqui.saved.parameters session attribute */ void saveParametersToSession(Map parameters) { if (parameters == null || parameters.size() == 0) return Map parms = new HashMap() // merge existing moqui.saved.parameters if there are any, valid for current request only as WebFacadeImpl() constructors removes this session attribute if has value Map currentSavedParameters = (Map) request.session.getAttribute("moqui.saved.parameters") if (currentSavedParameters) parms.putAll(currentSavedParameters) parms.putAll(parameters) if (!"production".equals(System.getProperty("instance_purpose"))) WebUtilities.testSerialization("moqui.saved.parameters", parms) session.setAttribute("moqui.saved.parameters", parms) } /** Save request parameters and attributes to a Map in the moqui.saved.parameters session attribute */ void saveRequestParametersToSession() { Map parms = new HashMap() // merge existing moqui.saved.parameters if there are any, valid for current request only as WebFacadeImpl() constructors removes this session attribute if has value Map currentSavedParameters = (Map) request.session.getAttribute("moqui.saved.parameters") if (currentSavedParameters) parms.putAll(currentSavedParameters) if (requestParameters) parms.putAll(requestParameters) // don't include attributes, end up with internal stuff in URL parameters: if (requestAttributes) parms.putAll(requestAttributes) if (!"production".equals(System.getProperty("instance_purpose"))) WebUtilities.testSerialization("moqui.saved.parameters", parms) session.setAttribute("moqui.saved.parameters", parms) } /** Save request parameters and attributes to a Map in the moqui.error.parameters session attribute */ void saveErrorParametersToSession() { Map parms = new HashMap() if (requestParameters) parms.putAll(requestParameters) // don't include attributes, end up with internal stuff in URL parameters: if (requestAttributes) parms.putAll(requestAttributes) if (!"production".equals(System.getProperty("instance_purpose"))) WebUtilities.testSerialization("moqui.error.parameters", parms) session.setAttribute("moqui.error.parameters", parms) } static final byte[] trackingPng = [(byte)0x89,0x50,0x4E,0x47,0x0D,0x0A,0x1A,0x0A,0x00,0x00,0x00,0x0D,0x49,0x48,0x44,0x52,0x00, 0x00,0x00,0x01,0x00,0x00,0x00,0x01,0x08,0x06,0x00,0x00,0x00,0x1F,0x15,(byte)0xC4,(byte)0x89,0x00,0x00,0x00,0x0B,0x49, 0x44,0x41,0x54,0x78,(byte)0xDA,0x63,0x60,0x00,0x02,0x00,0x00,0x05,0x00,0x01,(byte)0xE9,(byte)0xFA,(byte)0xDC,(byte)0xD8, 0x00,0x00,0x00,0x00,0x49,0x45,0x4E,0x44,(byte)0xAE,0x42,0x60,(byte)0x82] void viewEmailMessage() { // first send the empty image response.setContentType('image/png') response.setHeader("Content-Disposition", "inline") OutputStream os = response.outputStream try { os.write(trackingPng) } finally { os.close() } // mark the message viewed try { String emailMessageId = (String) eci.contextStack.get("emailMessageId") if (emailMessageId != null && !emailMessageId.isEmpty()) { int dotIndex = emailMessageId.indexOf(".") if (dotIndex > 0) emailMessageId = emailMessageId.substring(0, dotIndex) EntityValue emailMessage = eci.entity.find("moqui.basic.email.EmailMessage").condition("emailMessageId", emailMessageId) .disableAuthz().one() if (emailMessage == null) { logger.warn("Tried to mark EmailMessage ${emailMessageId} viewed but not found") } else if (!"ES_VIEWED".equals(emailMessage.statusId)) { eci.service.sync().name("update#moqui.basic.email.EmailMessage").parameter("emailMessageId", emailMessageId) .parameter("statusId", "ES_VIEWED").parameter("receivedDate", eci.user.nowTimestamp).disableAuthz().call() } } } catch (Throwable t) { logger.error("Error marking EmailMessage viewed", t) } } protected DiskFileItemFactory makeDiskFileItemFactory() { // NOTE: consider keeping this factory somewhere to be more efficient, if it even makes a difference... File repository = new File(eci.ecfi.runtimePath + "/tmp") if (!repository.exists()) repository.mkdir() DiskFileItemFactory factory = DiskFileItemFactory.builder() .setPath(repository.toPath()) .setBufferSize(DiskFileItemFactory.DEFAULT_THRESHOLD) .get() // TODO: this was causing files to get deleted before the upload was streamed... need to figure out something else //FileCleaningTracker fileCleaningTracker = FileCleanerCleanup.getFileCleaningTracker(request.getServletContext()) //factory.setFileCleaningTracker(fileCleaningTracker) return factory } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/reference/BaseResourceReference.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.reference; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.resource.ResourceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.InputStream; import java.io.OutputStream; import java.net.URL; import java.util.*; public abstract class BaseResourceReference extends ResourceReference { protected static final Logger logger = LoggerFactory.getLogger(BaseResourceReference.class); protected ExecutionContextFactoryImpl ecf = (ExecutionContextFactoryImpl) null; public BaseResourceReference() { } @Override public ResourceReference init(String location) { return init(location, null); } public abstract ResourceReference init(String location, ExecutionContextFactoryImpl ecf); @Override public abstract ResourceReference createNew(String location); @Override public abstract String getLocation(); @Override public abstract InputStream openStream(); @Override public abstract OutputStream getOutputStream(); @Override public abstract String getText(); @Override public abstract boolean supportsAll(); @Override public abstract boolean supportsUrl(); @Override public abstract URL getUrl(); @Override public abstract boolean supportsDirectory(); @Override public abstract boolean isFile(); @Override public abstract boolean isDirectory(); @Override public abstract List getDirectoryEntries(); @Override public abstract boolean supportsExists(); @Override public abstract boolean getExists(); @Override public abstract boolean supportsLastModified(); @Override public abstract long getLastModified(); @Override public abstract boolean supportsSize(); @Override public abstract long getSize(); @Override public abstract boolean supportsWrite(); @Override public abstract void putText(String text); @Override public abstract void putStream(InputStream stream); @Override public abstract void move(String newLocation); @Override public abstract ResourceReference makeDirectory(String name); @Override public abstract ResourceReference makeFile(String name); @Override public abstract boolean delete(); } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/reference/ComponentResourceReference.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.reference; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.resource.ResourceReference; import java.util.ArrayList; import java.util.List; public class ComponentResourceReference extends WrapperResourceReference { private String componentLocation; public ComponentResourceReference() { super(); } public ResourceReference init(String location, ExecutionContextFactoryImpl ecf) { this.ecf = ecf; // if there is a hash (used in resource locations for versions) strip the hash and everything after int hashIdx = location.indexOf("#"); if (hashIdx > 0) location = location.substring(0, hashIdx); // remove trailing slash if there is one if (location.endsWith("/")) location = location.substring(0, location.length() - 1); this.componentLocation = location; String strippedLocation = ResourceReference.stripLocationPrefix(location); // turn this into another URL using the component location StringBuilder baseLocation = new StringBuilder(strippedLocation); // componentName is everything before the first slash String componentName; int firstSlash = baseLocation.indexOf("/"); if (firstSlash > 0) { componentName = baseLocation.substring(0, firstSlash); // got the componentName, now remove it from the baseLocation baseLocation.delete(0, firstSlash + 1); } else { componentName = baseLocation.toString(); baseLocation.delete(0, baseLocation.length()); } baseLocation.insert(0, "/"); baseLocation.insert(0, ecf.getComponentBaseLocations().get(componentName)); setRr(ecf.getResource().getLocationReference(baseLocation.toString())); return this; } @Override public ResourceReference createNew(String location) { ComponentResourceReference resRef = new ComponentResourceReference(); resRef.init(location, ecf); return resRef; } @Override public String getLocation() { return componentLocation; } @Override public List getDirectoryEntries() { // a little extra work to keep the directory entries as component-based locations List nestedList = this.getRr().getDirectoryEntries(); List newList = new ArrayList<>(nestedList.size()); for (ResourceReference entryRr : nestedList) { String entryLoc = entryRr.getLocation(); if (entryLoc.endsWith("/")) entryLoc = entryLoc.substring(0, entryLoc.length() - 1); String newLocation = this.componentLocation + "/" + entryLoc.substring(entryLoc.lastIndexOf("/") + 1); newList.add(new ComponentResourceReference().init(newLocation, ecf)); } return newList; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/reference/ContentResourceReference.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.reference import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.util.ObjectUtilities import javax.jcr.NodeIterator import javax.jcr.PathNotFoundException import javax.jcr.Session import javax.jcr.Property import org.moqui.resource.ResourceReference import org.moqui.impl.context.ResourceFacadeImpl import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ContentResourceReference extends BaseResourceReference { protected final static Logger logger = LoggerFactory.getLogger(ContentResourceReference.class) public final static String locationPrefix = "content://" String location String repositoryName String nodePath protected javax.jcr.Node theNode = null ContentResourceReference() { } @Override ResourceReference init(String location, ExecutionContextFactoryImpl ecf) { this.ecf = ecf this.location = location // TODO: change to not rely on URI, or to encode properly URI locationUri = new URI(location) repositoryName = locationUri.host nodePath = locationUri.path return this } ResourceReference init(String repositoryName, javax.jcr.Node node, ExecutionContextFactoryImpl ecf) { this.ecf = ecf this.repositoryName = repositoryName this.nodePath = node.path this.location = "${locationPrefix}${repositoryName}${nodePath}" this.theNode = node return this } @Override ResourceReference createNew(String location) { ContentResourceReference resRef = new ContentResourceReference(); resRef.init(location, ecf); return resRef; } @Override String getLocation() { location } @Override InputStream openStream() { javax.jcr.Node node = getNode() if (node == null) return null javax.jcr.Node contentNode = node.getNode("jcr:content") if (contentNode == null) throw new IllegalArgumentException("Cannot get stream for content at [${repositoryName}][${nodePath}], has no jcr:content child node") Property dataProperty = contentNode.getProperty("jcr:data") if (dataProperty == null) throw new IllegalArgumentException("Cannot get stream for content at [${repositoryName}][${nodePath}], has no jcr:content.jcr:data property") return dataProperty.binary.stream } @Override OutputStream getOutputStream() { throw new UnsupportedOperationException("The getOutputStream method is not supported for JCR, use putStream() instead") } @Override String getText() { return ObjectUtilities.getStreamText(openStream()) } @Override boolean supportsAll() { true } @Override boolean supportsUrl() { false } @Override URL getUrl() { return null } @Override boolean supportsDirectory() { true } @Override boolean isFile() { javax.jcr.Node node = getNode() if (node == null) return false return node.isNodeType("nt:file") } @Override boolean isDirectory() { javax.jcr.Node node = getNode() if (node == null) return false return node.isNodeType("nt:folder") } @Override List getDirectoryEntries() { List dirEntries = new LinkedList() javax.jcr.Node node = getNode() if (node == null) return dirEntries NodeIterator childNodes = node.getNodes() while (childNodes.hasNext()) { javax.jcr.Node childNode = childNodes.nextNode() dirEntries.add(new ContentResourceReference().init(repositoryName, childNode, ecf)) } return dirEntries } // TODO: consider overriding findChildFile() to let the JCR impl do the query // ResourceReference findChildFile(String relativePath) @Override boolean supportsExists() { true } @Override boolean getExists() { if (theNode != null) return true Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) return session.nodeExists(nodePath) } @Override boolean supportsLastModified() { true } @Override long getLastModified() { try { return getNode()?.getProperty("jcr:lastModified")?.getDate()?.getTimeInMillis() } catch (PathNotFoundException e) { return System.currentTimeMillis() } } @Override boolean supportsSize() { true } @Override long getSize() { try { return getNode()?.getProperty("jcr:content/jcr:data")?.getLength() } catch (PathNotFoundException e) { return 0 } } @Override boolean supportsWrite() { true } @Override void putText(String text) { putObject(text) } @Override void putStream(InputStream stream) { putObject(stream) } protected void putObject(Object obj) { if (obj == null) { logger.warn("Data was null, not saving to resource [${getLocation()}]") return } Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) javax.jcr.Node fileNode = getNode() javax.jcr.Node fileContent if (fileNode != null) { fileContent = fileNode.getNode("jcr:content") } else { // first make sure the directory exists that this is in List nodePathList = new ArrayList<>(Arrays.asList(nodePath.split('/'))) // if nodePath started with a '/' the first element will be empty if (nodePathList && nodePathList[0] == "") nodePathList.remove(0) // remove the filename to just get the directory if (nodePathList) nodePathList.remove(nodePathList.size()-1) javax.jcr.Node folderNode = findDirectoryNode(session, nodePathList, true) // now create the node fileNode = folderNode.addNode(fileName, "nt:file") fileContent = fileNode.addNode("jcr:content", "nt:resource") } fileContent.setProperty("jcr:mimeType", contentType) // fileContent.setProperty("jcr:encoding", ?) Calendar lastModified = Calendar.getInstance(); lastModified.setTimeInMillis(System.currentTimeMillis()) fileContent.setProperty("jcr:lastModified", lastModified) if (obj instanceof CharSequence) { fileContent.setProperty("jcr:data", session.valueFactory.createValue(obj.toString())) } else if (obj instanceof InputStream) { fileContent.setProperty("jcr:data", session.valueFactory.createBinary((InputStream) obj)) } else if (obj == null) { fileContent.setProperty("jcr:data", session.valueFactory.createValue("")) } else { throw new IllegalArgumentException("Cannot save content for obj with type ${obj.class.name}") } session.save() } static javax.jcr.Node findDirectoryNode(Session session, List pathList, boolean create) { javax.jcr.Node rootNode = session.getRootNode() javax.jcr.Node folderNode = rootNode if (pathList) { for (String nodePathElement in pathList) { if (folderNode.hasNode(nodePathElement)) { folderNode = folderNode.getNode(nodePathElement) } else { if (create) { folderNode = folderNode.addNode(nodePathElement, "nt:folder") } else { folderNode = null break } } } } return folderNode } void move(String newLocation) { if (!newLocation.startsWith(locationPrefix)) throw new IllegalArgumentException("New location [${newLocation}] is not a content location, not moving resource at ${getLocation()}") Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) ResourceReference newRr = ecf.resource.getLocationReference(newLocation) if (!newRr instanceof ContentResourceReference) throw new IllegalArgumentException("New location [${newLocation}] is not a content location, not moving resource at ${getLocation()}") ContentResourceReference newCrr = (ContentResourceReference) newRr // make sure the target folder exists List nodePathList = new ArrayList<>(Arrays.asList(newCrr.getNodePath().split('/'))) if (nodePathList && nodePathList[0] == "") nodePathList.remove(0) if (nodePathList) nodePathList.remove(nodePathList.size()-1) findDirectoryNode(session, nodePathList, true) session.move(this.getNodePath(), newCrr.getNodePath()) session.save() this.theNode = null } @Override ResourceReference makeDirectory(String name) { Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) findDirectoryNode(session, [name], true) return new ContentResourceReference().init("${location}/${name}", ecf) } @Override ResourceReference makeFile(String name) { ContentResourceReference newRef = (ContentResourceReference) new ContentResourceReference().init("${location}/${name}", ecf) newRef.putObject(null) return newRef } @Override boolean delete() { javax.jcr.Node curNode = getNode() if (curNode == null) return false Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) session.removeItem(nodePath) session.save() this.theNode = null return true } javax.jcr.Node getNode() { if (theNode != null) return theNode Session session = ((ResourceFacadeImpl) ecf.resource).getContentRepositorySession(repositoryName) return session.nodeExists(nodePath) ? session.getNode(nodePath) : null } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/reference/DbResourceReference.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.reference import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.resource.ResourceReference // NOTE: IDE says this isn't needed but compiler requires it import org.moqui.resource.ResourceReference.Version import org.moqui.entity.EntityValue import org.moqui.entity.EntityList import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.sql.rowset.serial.SerialBlob import java.nio.charset.StandardCharsets import java.sql.Timestamp @CompileStatic class DbResourceReference extends BaseResourceReference { protected final static Logger logger = LoggerFactory.getLogger(DbResourceReference.class) public final static String locationPrefix = "dbresource://" String location String resourceId = (String) null DbResourceReference() { } @Override ResourceReference init(String location, ExecutionContextFactoryImpl ecf) { this.ecf = ecf this.location = location return this } ResourceReference init(String location, EntityValue dbResource, ExecutionContextFactoryImpl ecf) { this.ecf = ecf this.location = location resourceId = dbResource.resourceId return this } @Override ResourceReference createNew(String location) { DbResourceReference resRef = new DbResourceReference() resRef.init(location, ecf) return resRef } @Override String getLocation() { location } String getPath() { if (!location) return "" // should have a prefix of "dbresource://" return location.substring(locationPrefix.length()) } @Override InputStream openStream() { EntityValue dbrf = getDbResourceFile() if (dbrf == null) return null return dbrf.getSerialBlob("fileData")?.getBinaryStream() } @Override OutputStream getOutputStream() { throw new UnsupportedOperationException("The getOutputStream method is not supported for DB resources, use putStream() instead") } @Override String getText() { return ObjectUtilities.getStreamText(openStream()) } @Override boolean supportsAll() { true } @Override boolean supportsUrl() { false } @Override URL getUrl() { return null } @Override boolean supportsDirectory() { true } @Override boolean isFile() { return "Y".equals(getDbResource(true)?.isFile) } @Override boolean isDirectory() { if (!getPath()) return true // consider root a directory EntityValue dbr = getDbResource(true) return dbr != null && !"Y".equals(dbr.isFile) } @Override List getDirectoryEntries() { List dirEntries = new LinkedList() EntityValue dbr = getDbResource(true) if (getPath() && dbr == null) return dirEntries // allow parentResourceId to be null for the root EntityList childList = ecf.entity.find("moqui.resource.DbResource").condition([parentResourceId:dbr?.resourceId]) .orderBy("filename").useCache(true).disableAuthz().list() for (EntityValue child in childList) { String childLoc = getPath() ? "${location}/${child.filename}" : "${location}${child.filename}" dirEntries.add(new DbResourceReference().init(childLoc, child, ecf)) } return dirEntries } @Override boolean supportsExists() { true } @Override boolean getExists() { return getDbResource(true) != null } @Override boolean supportsLastModified() { true } @Override long getLastModified() { EntityValue dbr = getDbResource(true) if (dbr == null) return 0 if ("Y".equals(dbr.isFile)) { EntityValue dbrf = ecf.entity.find("moqui.resource.DbResourceFile").condition("resourceId", resourceId) .selectField("lastUpdatedStamp").useCache(false).disableAuthz().one() if (dbrf != null) return dbrf.getTimestamp("lastUpdatedStamp").getTime() } return dbr.getTimestamp("lastUpdatedStamp").getTime() } @Override boolean supportsSize() { true } @Override long getSize() { EntityValue dbrf = getDbResourceFile() if (dbrf == null) return 0 return dbrf.getSerialBlob("fileData")?.length() ?: 0 } @Override boolean supportsWrite() { true } @Override void putText(String text) { // TODO: use diff from last version for text SerialBlob sblob = text ? new SerialBlob(text.getBytes(StandardCharsets.UTF_8)) : null this.putObject(sblob) } @Override void putStream(InputStream stream) { if (stream == null) return ByteArrayOutputStream baos = new ByteArrayOutputStream() ObjectUtilities.copyStream(stream, baos) SerialBlob sblob = new SerialBlob(baos.toByteArray()) this.putObject(sblob) } @Override void putBytes(byte[] bytes) { this.putObject(new SerialBlob(bytes)) } protected void putObject(Object fileObj) { EntityValue dbrf = getDbResourceFile() if (dbrf != null) { makeNextVersion(dbrf, fileObj) } else { // first make sure the directory exists that this is in List filenameList = new ArrayList<>(Arrays.asList(getPath().split("/"))) int filenameListSize = filenameList.size() if (filenameListSize == 0) throw new BaseArtifactException("Cannot put file at empty location ${getPath()}") String filename = filenameList.get(filenameList.size()-1) // remove the current filename from the list, and find ID of parent directory for path filenameList.remove(filenameList.size()-1) String parentResourceId = findDirectoryId(filenameList, true) if (parentResourceId == null) throw new BaseArtifactException("Could not find directory to put new file in at ${filenameList}") // lock the parentResourceId ecf.entity.find("moqui.resource.DbResource").condition("resourceId", parentResourceId) .selectField("lastUpdatedStamp").forUpdate(true).disableAuthz().one() // do a query by name to see if it exists EntityValue existingValue = ecf.entity.find("moqui.resource.DbResource") .condition("parentResourceId", parentResourceId).condition("filename", filename) .useCache(false).disableAuthz().list().getFirst() if (existingValue != null) { resourceId = existingValue.resourceId dbrf = getDbResourceFile() makeNextVersion(dbrf, fileObj) } else { // now write the DbResource and DbResourceFile records Map createDbrResult = ecf.service.sync().name("create", "moqui.resource.DbResource") .parameters([parentResourceId:parentResourceId, filename:filename, isFile:"Y"]) .disableAuthz().call() resourceId = createDbrResult.resourceId String versionName = "01" ecf.service.sync().name("create", "moqui.resource.DbResourceFile") .parameters([resourceId:resourceId, mimeType:getContentType(), versionName:versionName, rootVersionName:versionName, fileData:fileObj]) .disableAuthz().call() ExecutionContextImpl eci = ecf.getEci() // NOTE: no fileData, for non-diff only past versions ecf.service.sync().name("create", "moqui.resource.DbResourceFileHistory") .parameters([resourceId:resourceId, versionDate:eci.userFacade.nowTimestamp, userId:eci.userFacade.userId, isDiff:"N"]) .disableAuthz().call() } } } protected void makeNextVersion(EntityValue dbrf, Object newFileObj) { String currentVersionName = dbrf.versionName if (currentVersionName != null && !currentVersionName.isEmpty()) { EntityValue currentDbrfHistory = ecf.entityFacade.find("moqui.resource.DbResourceFileHistory") .condition("resourceId", resourceId).condition("versionName", currentVersionName) .useCache(false).disableAuthz().one() if (currentDbrfHistory != null) { currentDbrfHistory.set("fileData", dbrf.fileData) currentDbrfHistory.update() } } ExecutionContextImpl eci = ecf.getEci() // NOTE: no fileData, for non-diff only past versions Map createOut = ecf.service.sync().name("create", "moqui.resource.DbResourceFileHistory") .parameters([resourceId:resourceId, previousVersionName:currentVersionName, versionDate:eci.userFacade.nowTimestamp, userId:eci.userFacade.userId, isDiff:"N"]) .disableAuthz().call() String newVersionName = createOut.versionName if (!dbrf.rootVersionName) dbrf.rootVersionName = currentVersionName ?: newVersionName dbrf.versionName = newVersionName dbrf.fileData = newFileObj dbrf.update() } String findDirectoryId(List pathList, boolean create) { String finalParentResourceId = null if (pathList) { String parentResourceId = null boolean found = true for (String filename in pathList) { if (filename == null || filename.length() == 0) continue EntityValue directoryValue = ecf.entity.find("moqui.resource.DbResource") .condition("parentResourceId", parentResourceId).condition("filename", filename) .useCache(true).disableAuthz().list().getFirst() if (directoryValue == null) { if (create) { // trying a create so lock the parent, then query again to make sure it doesn't exist ecf.entity.find("moqui.resource.DbResource").condition("resourceId", parentResourceId) .selectField("lastUpdatedStamp").forUpdate(true).disableAuthz().one() directoryValue = ecf.entity.find("moqui.resource.DbResource") .condition("parentResourceId", parentResourceId).condition("filename", filename) .useCache(false).disableAuthz().list().getFirst() if (directoryValue == null) { Map createResult = ecf.service.sync().name("create", "moqui.resource.DbResource") .parameters([parentResourceId:parentResourceId, filename:filename, isFile:"N"]) .disableAuthz().call() parentResourceId = createResult.resourceId // logger.warn("=============== put text to ${location}, created dir ${filename}") } // else fall through, handle below } else { found = false break } } if (directoryValue != null) { if (directoryValue.isFile == "Y") { throw new BaseArtifactException("Tried to find a directory in a path but found file instead at ${filename} under DbResource ${parentResourceId}") } else { parentResourceId = directoryValue.resourceId // logger.warn("=============== put text to ${location}, found existing dir ${filename}") } } } if (found) finalParentResourceId = parentResourceId } return finalParentResourceId } @Override void move(String newLocation) { EntityValue dbr = getDbResource(false) // if the current resource doesn't exist, nothing to move if (!dbr) { logger.warn("Could not find dbresource at [${getPath()}]") return } if (!newLocation) throw new BaseArtifactException("No location specified, not moving resource at ${getLocation()}") // ResourceReference newRr = ecf.resource.getLocationReference(newLocation) if (!newLocation.startsWith(locationPrefix)) throw new BaseArtifactException("Location [${newLocation}] is not a dbresource location, not moving resource at ${getLocation()}") List filenameList = new ArrayList<>(Arrays.asList(newLocation.substring(locationPrefix.length()).split("/"))) if (filenameList) { String newFilename = filenameList.get(filenameList.size()-1) filenameList.remove(filenameList.size()-1) String parentResourceId = findDirectoryId(filenameList, true) dbr.parentResourceId = parentResourceId dbr.filename = newFilename dbr.update() } } @Override ResourceReference makeDirectory(String name) { findDirectoryId([name], true) return new DbResourceReference().init("${location}/${name}", ecf) } @Override ResourceReference makeFile(String name) { DbResourceReference newRef = (DbResourceReference) new DbResourceReference().init("${location}/${name}", ecf) newRef.putObject(null) return newRef } @Override boolean delete() { EntityValue dbr = getDbResource(false) if (dbr == null) return false if (dbr.isFile == "Y") { EntityValue dbrf = getDbResourceFile() if (dbrf != null) { // first delete history records dbrf.deleteRelated("histories") // then delete the file dbrf.delete() } } dbr.delete() resourceId = null return true } @Override boolean supportsVersion() { return true } @Override Version getVersion(String versionName) { String resourceId = getDbResourceId() if (resourceId == null) return null return makeVersion(ecf.entityFacade.find("moqui.resource.DbResourceFileHistory") .condition("resourceId", resourceId).condition("versionName", versionName) .useCache(false).disableAuthz().one()) } @Override Version getCurrentVersion() { EntityValue dbrf = getDbResourceFile() if (dbrf == null) return null return getVersion((String) dbrf.versionName) } @Override Version getRootVersion() { EntityValue dbrf = getDbResourceFile() if (dbrf == null) return null return getVersion((String) dbrf.rootVersionName) } @Override ArrayList getVersionHistory() { String resourceId = getDbResourceId() if (resourceId == null) return new ArrayList<>() EntityList dbrfHistoryList = ecf.entityFacade.find("moqui.resource.DbResourceFileHistory") .condition("resourceId", resourceId).orderBy("-versionDate") .useCache(false).disableAuthz().list() int dbrfHistorySize = dbrfHistoryList.size() ArrayList verList = new ArrayList<>(dbrfHistorySize) for (int i = 0; i < dbrfHistorySize; i++) { EntityValue dbrfHistory = dbrfHistoryList.get(i) verList.add(makeVersion(dbrfHistory)) } return verList } @Override ArrayList getNextVersions(String versionName) { String resourceId = getDbResourceId() if (resourceId == null) return new ArrayList<>() EntityList dbrfHistoryList = ecf.entityFacade.find("moqui.resource.DbResourceFileHistory") .condition("resourceId", resourceId).condition("previousVersionName", versionName) .useCache(false).disableAuthz().list() int dbrfHistorySize = dbrfHistoryList.size() ArrayList verList = new ArrayList<>(dbrfHistorySize) for (int i = 0; i < dbrfHistorySize; i++) { EntityValue dbrfHistory = dbrfHistoryList.get(i) verList.add(makeVersion(dbrfHistory)) } return verList } @Override InputStream openStream(String versionName) { if (versionName == null || versionName.isEmpty()) return openStream() EntityValue dbrfHistory = getDbResourceFileHistory(versionName) if (dbrfHistory == null) return null if ("Y".equals(dbrfHistory.isDiff)) { // TODO if current version get full text from dbrf otherwise reconstruct from root merging in diffs as needed up to versionName return null } else { SerialBlob fileData = dbrfHistory.getSerialBlob("fileData") if (fileData != null) { return fileData.getBinaryStream() } else { // may be the current version with no fileData value in dbrfHistory EntityValue dbrf = getDbResourceFile() if (dbrf == null || !versionName.equals(dbrf.versionName)) return null fileData = dbrf.getSerialBlob("fileData") if (fileData == null) return null return fileData.getBinaryStream() } } } @Override String getText(String versionName) { return ObjectUtilities.getStreamText(openStream(versionName)) } Version makeVersion(EntityValue dbrfHistory) { if (dbrfHistory == null) return null return new Version(this, (String) dbrfHistory.versionName, (String) dbrfHistory.previousVersionName, (String) dbrfHistory.userId, (Timestamp) dbrfHistory.versionDate) } String getDbResourceId() { if (resourceId != null) return resourceId List filenameList = new ArrayList<>(Arrays.asList(getPath().split("/"))) String lastResourceId = null for (String filename in filenameList) { EntityValue curDbr = ecf.entityFacade.find("moqui.resource.DbResource") .condition("parentResourceId", lastResourceId) .condition("filename", filename).useCache(true) .disableAuthz().one() if (curDbr == null) return null lastResourceId = curDbr.resourceId } resourceId = lastResourceId return resourceId } EntityValue getDbResource(boolean useCache) { String resourceId = getDbResourceId() if (resourceId == null) return null return ecf.entityFacade.fastFindOne("moqui.resource.DbResource", useCache, true, resourceId) } EntityValue getDbResourceFile() { String resourceId = getDbResourceId() if (resourceId == null) return null // don't cache this, can be big and will be cached below this as text if needed return ecf.entityFacade.fastFindOne("moqui.resource.DbResourceFile", false, true, resourceId) } EntityValue getDbResourceFileHistory(String versionName) { if (versionName == null) return null String resourceId = getDbResourceId() if (resourceId == null) return null // don't cache this, can be big and will be cached below this as text if needed return ecf.entityFacade.fastFindOne("moqui.resource.DbResourceFileHistory", false, true, resourceId, versionName) } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/reference/WrapperResourceReference.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.reference import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.resource.ResourceReference @CompileStatic abstract class WrapperResourceReference extends BaseResourceReference { ResourceReference rr = null WrapperResourceReference() { } @Override ResourceReference init(String location, ExecutionContextFactoryImpl ecf) { this.ecf = ecf return this } ResourceReference init(ResourceReference rr, ExecutionContextFactoryImpl ecf) { this.rr = rr this.ecf = ecf return this } @Override abstract ResourceReference createNew(String location); String getLocation() { return rr.getLocation() } InputStream openStream() { return rr.openStream() } OutputStream getOutputStream() { return rr.getOutputStream() } String getText() { return rr.getText() } boolean supportsAll() { return rr.supportsAll() } boolean supportsUrl() { return rr.supportsUrl() } URL getUrl() { return rr.getUrl() } boolean supportsDirectory() { return rr.supportsDirectory() } boolean isFile() { return rr.isFile() } boolean isDirectory() { return rr.isDirectory() } List getDirectoryEntries() { return rr.getDirectoryEntries() } boolean supportsExists() { return rr.supportsExists() } boolean getExists() { return rr.getExists()} boolean supportsLastModified() { return rr.supportsLastModified() } long getLastModified() { return rr.getLastModified() } boolean supportsSize() { return rr.supportsSize() } long getSize() { return rr.getSize() } boolean supportsWrite() { return rr.supportsWrite() } void putText(String text) { rr.putText(text) } void putStream(InputStream stream) { rr.putStream(stream) } void move(String newLocation) { rr.move(newLocation) } ResourceReference makeDirectory(String name) { return rr.makeDirectory(name) } ResourceReference makeFile(String name) { return rr.makeFile(name) } boolean delete() { return rr.delete() } void destroy() { rr.destroy() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/renderer/FtlMarkdownTemplateRenderer.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.renderer import freemarker.template.Template import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.context.ExecutionContextFactory import org.moqui.resource.ResourceReference import org.moqui.context.TemplateRenderer import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.jcache.MCache import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache @CompileStatic class FtlMarkdownTemplateRenderer implements TemplateRenderer { protected final static Logger logger = LoggerFactory.getLogger(FtlMarkdownTemplateRenderer.class) protected ExecutionContextFactoryImpl ecfi protected Cache templateFtlLocationCache FtlMarkdownTemplateRenderer() { } TemplateRenderer init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.templateFtlLocationCache = ecfi.cacheFacade.getCache("resource.ftl.location", String.class, Template.class) return this } void render(String location, Writer writer) { boolean hasVersion = location.indexOf("#") > 0 Template theTemplate = null if (!hasVersion) { if (templateFtlLocationCache instanceof MCache) { MCache mCache = (MCache) templateFtlLocationCache ResourceReference rr = ecfi.resourceFacade.getLocationReference(location) long lastModified = rr != null ? rr.getLastModified() : 0L theTemplate = mCache.get(location, lastModified) } else { // TODO: doesn't support on the fly reloading without cache expire/clear! theTemplate = templateFtlLocationCache.get(location) } } if (theTemplate == null) theTemplate = makeTemplate(location, hasVersion) if (theTemplate == null) throw new BaseArtifactException("Could not find template at ${location}") theTemplate.createProcessingEnvironment(ecfi.getEci().contextStack, writer).process() } protected Template makeTemplate(String location, boolean hasVersion) { if (!hasVersion) { Template theTemplate = (Template) templateFtlLocationCache.get(location) if (theTemplate != null) return theTemplate } Template newTemplate try { //ScreenRenderImpl sri = (ScreenRenderImpl) ecfi.getExecutionContext().getContext().get("sri") // how to set base URL? if (sri != null) builder.setBase(sri.getBaseLinkUri()) /* Markdown4jProcessor markdown4jProcessor = new Markdown4jProcessor() String mdText = markdown4jProcessor.process(ecfi.resourceFacade.getLocationText(location, false)) PegDownProcessor pdp = new PegDownProcessor(MarkdownTemplateRenderer.pegDownOptions) String mdText = pdp.markdownToHtml(ecfi.resourceFacade.getLocationText(location, false)) */ com.vladsch.flexmark.util.ast.Node document = MarkdownTemplateRenderer.PARSER.parse(ecfi.resourceFacade.getLocationText(location, false)) String mdText = MarkdownTemplateRenderer.RENDERER.render(document) // logger.warn("======== .md.ftl post-markdown text: ${mdText}") Reader templateReader = new StringReader(mdText) newTemplate = new Template(location, templateReader, ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration()) } catch (Exception e) { throw new BaseArtifactException("Error while initializing template at [${location}]", e) } if (!hasVersion && newTemplate != null) templateFtlLocationCache.put(location, newTemplate) return newTemplate } String stripTemplateExtension(String fileName) { String stripped = fileName.contains(".md") ? fileName.replace(".md", "") : fileName stripped = stripped.contains(".markdown") ? stripped.replace(".markdown", "") : stripped return stripped.contains(".ftl") ? stripped.replace(".ftl", "") : stripped } void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/renderer/FtlTemplateRenderer.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.renderer; import freemarker.core.Environment; import freemarker.core.InvalidReferenceException; import freemarker.ext.beans.BeansWrapper; import freemarker.ext.beans.BeansWrapperBuilder; import freemarker.template.*; import groovy.transform.CompileStatic; import org.moqui.BaseArtifactException; import org.moqui.BaseException; import org.moqui.context.ExecutionContextFactory; import org.moqui.resource.ResourceReference; import org.moqui.context.TemplateRenderer; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.jcache.MCache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.cache.Cache; import java.io.*; import java.nio.charset.StandardCharsets; import java.util.Locale; @CompileStatic public class FtlTemplateRenderer implements TemplateRenderer { public static final Version FTL_VERSION = Configuration.VERSION_2_3_34; private static final Logger logger = LoggerFactory.getLogger(FtlTemplateRenderer.class); protected ExecutionContextFactoryImpl ecfi; private Configuration defaultFtlConfiguration; private Cache templateFtlLocationCache; public FtlTemplateRenderer() { } @SuppressWarnings("unchecked") public TemplateRenderer init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf; defaultFtlConfiguration = makeFtlConfiguration(ecfi); templateFtlLocationCache = ecfi.cacheFacade.getCache("resource.ftl.location", String.class, Template.class); return this; } public void render(String location, Writer writer) { Template theTemplate = getFtlTemplateByLocation(location); try { theTemplate.createProcessingEnvironment(ecfi.getEci().contextStack, writer).process(); } catch (Exception e) { throw new BaseArtifactException("Error rendering template at " + location, e); } } public String stripTemplateExtension(String fileName) { return fileName.contains(".ftl") ? fileName.replace(".ftl", "") : fileName; } public void destroy() { } @SuppressWarnings("unchecked") private Template getFtlTemplateByLocation(final String location) { boolean hasVersion = location.indexOf("#") > 0; Template theTemplate = null; if (!hasVersion) { if (templateFtlLocationCache instanceof MCache) { MCache mCache = (MCache) templateFtlLocationCache; ResourceReference rr = ecfi.resourceFacade.getLocationReference(location); // if we have a rr and last modified is newer than the cache entry then throw it out (expire when cached entry // updated time is older/less than rr.lastModified) long lastModified = rr != null ? rr.getLastModified() : 0L; theTemplate = mCache.get(location, lastModified); } else { // TODO: doesn't support on the fly reloading without cache expire/clear! theTemplate = templateFtlLocationCache.get(location); } } if (theTemplate == null) theTemplate = makeTemplate(location, hasVersion); if (theTemplate == null) throw new BaseArtifactException("Could not find template at " + location); return theTemplate; } private Template makeTemplate(final String location, boolean hasVersion) { if (!hasVersion) { Template theTemplate = templateFtlLocationCache.get(location); if (theTemplate != null) return theTemplate; } Template newTemplate; Reader templateReader = null; InputStream is = ecfi.resourceFacade.getLocationStream(location); if (is == null) throw new BaseArtifactException("Template not found at " + location); try { templateReader = new InputStreamReader(is, StandardCharsets.UTF_8); newTemplate = new Template(location, templateReader, getFtlConfiguration()); } catch (Exception e) { throw new BaseArtifactException("Error while initializing template at " + location, e); } finally { if (templateReader != null) { try { templateReader.close(); } catch (Exception e) { logger.error("Error closing template reader", e); } } } if (!hasVersion) templateFtlLocationCache.put(location, newTemplate); return newTemplate; } public Configuration getFtlConfiguration() { return defaultFtlConfiguration; } private static Configuration makeFtlConfiguration(ExecutionContextFactoryImpl ecfi) { Configuration newConfig = new MoquiConfiguration(FTL_VERSION, ecfi); BeansWrapper defaultWrapper = new BeansWrapperBuilder(FTL_VERSION).build(); newConfig.setObjectWrapper(defaultWrapper); newConfig.setSharedVariable("Static", defaultWrapper.getStaticModels()); /* not needed, using getTemplate override instead: newConfig.setCacheStorage(new NullCacheStorage()) newConfig.setTemplateUpdateDelay(1) newConfig.setTemplateLoader(new MoquiTemplateLoader(ecfi)) newConfig.setLocalizedLookup(false) */ /* String moquiRuntime = System.getProperty("moqui.runtime"); if (moquiRuntime != null && !moquiRuntime.isEmpty()) { File runtimeFile = new File(moquiRuntime); try { newConfig.setDirectoryForTemplateLoading(runtimeFile); } catch (Exception e) { logger.error("Error setting FTL template loading directory to " + moquiRuntime, e); } } */ newConfig.setTemplateExceptionHandler(new MoquiTemplateExceptionHandler()); newConfig.setLogTemplateExceptions(false); newConfig.setWhitespaceStripping(true); newConfig.setDefaultEncoding("UTF-8"); return newConfig; } private static class MoquiConfiguration extends Configuration { private ExecutionContextFactoryImpl ecfi; MoquiConfiguration(Version version, ExecutionContextFactoryImpl ecfi) { super(version); this.ecfi = ecfi; } @Override public Template getTemplate(String name, Locale locale, Object customLookupCondition, String encoding, boolean parseAsFTL, boolean ignoreMissing) throws IOException { //return super.getTemplate(name, locale, encoding, parse) // NOTE: doing this because template loading behavior with cache/etc not desired and was having issues Template theTemplate; if (parseAsFTL) { theTemplate = ecfi.resourceFacade.getFtlTemplateRenderer().getFtlTemplateByLocation(name); } else { String text = ecfi.resourceFacade.getLocationText(name, true); theTemplate = Template.getPlainTextTemplate(name, text, this); } // NOTE: this is the same exception the standard FreeMarker code returns if (theTemplate == null && !ignoreMissing) throw new FileNotFoundException("Template " + name + " not found."); return theTemplate; } public ExecutionContextFactoryImpl getEcfi() { return ecfi; } public void setEcfi(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; } } /* private static class MoquiTemplateLoader implements TemplateLoader { private ExecutionContextFactoryImpl ecfi; MoquiTemplateLoader(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi; } @Override public Object findTemplateSource(String name) throws IOException { return ecfi.resourceFacade.getLocationReference(name); } @Override public long getLastModified(Object templateSource) { if (templateSource instanceof ResourceReference) { return ((ResourceReference) templateSource).getLastModified(); } else { return 0; } } @Override public Reader getReader(Object templateSource, String encoding) throws IOException { if (!(templateSource instanceof ResourceReference)) throw new IllegalArgumentException("Cannot get Reader, templateSource is not a ResourceReference"); ResourceReference rr = (ResourceReference) templateSource; InputStream is = rr.openStream(); if (is == null) throw new IOException("Template not found at " + rr.getLocation()); return new InputStreamReader(is); } @Override public void closeTemplateSource(Object templateSource) throws IOException { } } */ private static class MoquiTemplateExceptionHandler implements TemplateExceptionHandler { public void handleTemplateException(final TemplateException te, Environment env, Writer out) throws TemplateException { try { // TODO: encode error, something like: StringUtil.SimpleEncoder simpleEncoder = FreeMarkerWorker.getWrappedObject("simpleEncoder", env); // stackTrace = simpleEncoder.encode(stackTrace); if (te.getCause() != null) { BaseException.filterStackTrace(te.getCause()); logger.error("Error from code called in FTL render", te.getCause()); // NOTE: ScreenTestImpl looks for this string, ie "[Template Error" String causeMsg = te.getCause().getMessage(); if (causeMsg == null || causeMsg.isEmpty()) causeMsg = te.getMessage(); if (causeMsg == null || causeMsg.isEmpty()) causeMsg = "no message available"; out.write("[Template Error: "); out.write(causeMsg); out.write("]"); } else { // NOTE: if there is not cause it is an exception generated by FreeMarker and not some code called in the template if (te instanceof InvalidReferenceException) { // NOTE: ScreenTestImpl looks for this string, ie "[Template Error" logger.error("[Template Error: expression '" + te.getBlamedExpressionString() + "' was null or not found (" + te.getTemplateSourceName() + ":" + te.getLineNumber() + "," + te.getColumnNumber() + ")]"); out.write("[Template Error]"); } else { BaseException.filterStackTrace(te); logger.error("Error from FTL in render", te); // NOTE: ScreenTestImpl looks for this string, ie "[Template Error" out.write("[Template Error: "); out.write(te.getMessage()); out.write("]"); } } } catch (IOException e) { throw new TemplateException("Failed to print error message. Cause: " + e, env); } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/renderer/GStringTemplateRenderer.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.renderer import groovy.text.GStringTemplateEngine import groovy.text.Template import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.context.ExecutionContextFactory import org.moqui.resource.ResourceReference import org.moqui.context.TemplateRenderer import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.jcache.MCache import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache @CompileStatic class GStringTemplateRenderer implements TemplateRenderer { protected final static Logger logger = LoggerFactory.getLogger(GStringTemplateRenderer.class) protected ExecutionContextFactoryImpl ecfi protected Cache templateGStringLocationCache GStringTemplateRenderer() { } TemplateRenderer init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.templateGStringLocationCache = ecfi.cacheFacade.getCache("resource.gstring.location", String.class, Template.class) return this } void render(String location, Writer writer) { Template theTemplate = getGStringTemplateByLocation(location) Writable writable = theTemplate.make(ecfi.executionContext.context) writable.writeTo(writer) } String stripTemplateExtension(String fileName) { return fileName.contains(".gstring") ? fileName.replace(".gstring", "") : fileName } void destroy() { } Template getGStringTemplateByLocation(String location) { Template theTemplate; if (templateGStringLocationCache instanceof MCache) { MCache mCache = (MCache) templateGStringLocationCache; ResourceReference rr = ecfi.resourceFacade.getLocationReference(location); long lastModified = rr != null ? rr.getLastModified() : 0L; theTemplate = mCache.get(location, lastModified); } else { // TODO: doesn't support on the fly reloading without cache expire/clear! theTemplate = templateGStringLocationCache.get(location); } if (!theTemplate) theTemplate = makeGStringTemplate(location) if (!theTemplate) throw new BaseArtifactException("Could not find template at [${location}]") return theTemplate } protected Template makeGStringTemplate(String location) { Template theTemplate = (Template) templateGStringLocationCache.get(location) if (theTemplate) return theTemplate Template newTemplate = null Reader templateReader = null try { templateReader = new InputStreamReader(ecfi.resourceFacade.getLocationStream(location)) GStringTemplateEngine gste = new GStringTemplateEngine() newTemplate = gste.createTemplate(templateReader) } catch (Exception e) { throw new BaseArtifactException("Error while initializing template at [${location}]", e) } finally { if (templateReader != null) templateReader.close() } if (newTemplate) templateGStringLocationCache.put(location, newTemplate) return newTemplate } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/renderer/MarkdownTemplateRenderer.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.renderer import com.vladsch.flexmark.ext.tables.TablesExtension import com.vladsch.flexmark.ext.toc.TocExtension import com.vladsch.flexmark.html.HtmlRenderer import com.vladsch.flexmark.parser.Parser import com.vladsch.flexmark.util.ast.KeepType import com.vladsch.flexmark.util.data.MutableDataHolder import com.vladsch.flexmark.util.data.MutableDataSet import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.resource.ResourceReference import org.moqui.context.TemplateRenderer import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.jcache.MCache import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache @CompileStatic class MarkdownTemplateRenderer implements TemplateRenderer { protected final static Logger logger = LoggerFactory.getLogger(MarkdownTemplateRenderer.class) // ALL_WITH_OPTIONALS includes SMARTS and QUOTES so XOR them to remove them // final static int pegDownOptions = Extensions.ALL_WITH_OPTIONALS ^ Extensions.SMARTS ^ Extensions.QUOTES static final MutableDataHolder OPTIONS = new MutableDataSet() .set(Parser.REFERENCES_KEEP, KeepType.LAST) .set(Parser.SPACE_IN_LINK_URLS, true) .set(HtmlRenderer.INDENT_SIZE, 2) .set(HtmlRenderer.PERCENT_ENCODE_URLS, true) // for full GitHub Flavored Markdown table compatibility add the following table extension options: .set(TablesExtension.COLUMN_SPANS, false) .set(TablesExtension.APPEND_MISSING_COLUMNS, true) .set(TablesExtension.DISCARD_EXTRA_COLUMNS, true) .set(TablesExtension.HEADER_SEPARATOR_COLUMN_MATCH, true) .set(Parser.EXTENSIONS, (Iterable) Arrays.asList(TablesExtension.create(), TocExtension.create())) static final Parser PARSER = Parser.builder(OPTIONS).build() static final HtmlRenderer RENDERER = HtmlRenderer.builder(OPTIONS).build() protected ExecutionContextFactoryImpl ecfi protected Cache templateMarkdownLocationCache MarkdownTemplateRenderer() { } TemplateRenderer init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.templateMarkdownLocationCache = ecfi.cacheFacade.getCache("resource.markdown.location") return this } void render(String location, Writer writer) { boolean hasVersion = location.indexOf("#") > 0 String mdText if (!hasVersion) { if (templateMarkdownLocationCache instanceof MCache) { MCache mCache = (MCache) templateMarkdownLocationCache ResourceReference rr = ecfi.resourceFacade.getLocationReference(location) long lastModified = rr != null ? rr.getLastModified() : 0L mdText = (String) mCache.get(location, lastModified) } else { // TODO: doesn't support on the fly reloading without cache expire/clear! mdText = (String) templateMarkdownLocationCache.get(location) } if (mdText != null && !mdText.isEmpty()) { writer.write(mdText) return } } String sourceText = ecfi.resourceFacade.getLocationText(location, false) if (sourceText == null || sourceText.isEmpty()) { logger.warn("In Markdown template render got no text from location ${location}") return } //ScreenRenderImpl sri = (ScreenRenderImpl) ecfi.getExecutionContext().getContext().get("sri") // how to set base URL? if (sri != null) builder.setBase(sri.getBaseLinkUri()) /* Markdown4jProcessor markdown4jProcessor = new Markdown4jProcessor() mdText = markdown4jProcessor.process(sourceText) PegDownProcessor pdp = new PegDownProcessor(pegDownOptions) mdText = pdp.markdownToHtml(sourceText) */ com.vladsch.flexmark.util.ast.Node document = PARSER.parse(sourceText) mdText = RENDERER.render(document) // logger.warn("==== render md at ${location} version ${hasVersion} sourceText ${sourceText.length() > 100 ? sourceText.substring(0, 100) : sourceText}\nmdText ${mdText.length() > 100 ? mdText.substring(0, 100) : mdText}") if (mdText != null && !mdText.isEmpty()) { if (!hasVersion) templateMarkdownLocationCache.put(location, mdText) writer.write(mdText) } } String stripTemplateExtension(String fileName) { if (fileName.contains(".md")) return fileName.replace(".md", "") else if (fileName.contains(".markdown")) return fileName.replace(".markdown", "") else return fileName } void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/renderer/NoTemplateRenderer.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.renderer import groovy.transform.CompileStatic import org.moqui.context.TemplateRenderer import org.moqui.context.ExecutionContextFactory import org.moqui.impl.context.ExecutionContextFactoryImpl @CompileStatic class NoTemplateRenderer implements TemplateRenderer { protected ExecutionContextFactoryImpl ecfi NoTemplateRenderer() { } TemplateRenderer init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf return this } void render(String location, Writer writer) { String text = ecfi.resourceFacade.getLocationText(location, true) if (text) writer.write(text) } String stripTemplateExtension(String fileName) { return fileName } void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/runner/GroovyScriptRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.runner import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper import org.moqui.context.ExecutionContext import org.moqui.context.ExecutionContextFactory import org.moqui.context.ScriptRunner import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.util.StringUtilities import javax.cache.Cache @CompileStatic class GroovyScriptRunner implements ScriptRunner { private ExecutionContextFactoryImpl ecfi private Cache scriptGroovyLocationCache GroovyScriptRunner() { } @Override ScriptRunner init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.scriptGroovyLocationCache = ecfi.cacheFacade.getCache("resource.groovy.location", String.class, Class.class) return this } @Override Object run(String location, String method, ExecutionContext ec) { Script script = InvokerHelper.createScript(getGroovyByLocation(location), ec.contextBinding) Object result if (method != null && !method.isEmpty()) { result = script.invokeMethod(method, null) } else { result = script.run() } return result } @Override void destroy() { } Class getGroovyByLocation(String location) { Class gc = (Class) scriptGroovyLocationCache.get(location) if (gc == null) gc = loadGroovy(location) return gc } private synchronized Class loadGroovy(String location) { Class gc = (Class) scriptGroovyLocationCache.get(location) if (gc == null) { String groovyText = ecfi.resourceFacade.getLocationText(location, false) gc = ecfi.compileGroovy(groovyText, StringUtilities.cleanStringForJavaName(location)) scriptGroovyLocationCache.put(location, gc) } return gc } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/runner/JavaxScriptRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.runner import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.ExecutionContext import org.moqui.context.ExecutionContextFactory import org.moqui.context.ScriptRunner import org.moqui.impl.context.ExecutionContextFactoryImpl import javax.cache.Cache import javax.script.Bindings import javax.script.Compilable import javax.script.CompiledScript import javax.script.SimpleBindings import javax.script.ScriptEngine import javax.script.ScriptEngineManager import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class JavaxScriptRunner implements ScriptRunner { protected final static Logger logger = LoggerFactory.getLogger(JavaxScriptRunner.class) protected ScriptEngineManager mgr = new ScriptEngineManager(); protected ExecutionContextFactoryImpl ecfi protected Cache scriptLocationCache protected String engineName JavaxScriptRunner() { this.engineName = "groovy" } JavaxScriptRunner(String engineName) { this.engineName = engineName } ScriptRunner init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.scriptLocationCache = ecfi.cacheFacade.getCache("resource.${engineName}.location") return this } Object run(String location, String method, ExecutionContext ec) { // this doesn't support methods, so if passed warn about that if (method) logger.warn("Tried to invoke script at [${location}] with method [${method}] through javax.script (JSR-223) runner which does NOT support methods, so it is being ignored.", new BaseException("Script Run Location")) ScriptEngine engine = mgr.getEngineByName(engineName) return bindAndRun(location, ec, engine, scriptLocationCache) } void destroy() { } static Object bindAndRun(String location, ExecutionContext ec, ScriptEngine engine, Cache scriptLocationCache) { Bindings bindings = new SimpleBindings() for (Map.Entry ce in ec.getContext().entrySet()) bindings.put((String) ce.getKey(), ce.getValue()) Object result if (engine instanceof Compilable) { // cache the CompiledScript CompiledScript script = (CompiledScript) scriptLocationCache.get(location) if (script == null) { script = engine.compile(ec.getResource().getLocationText(location, false)) scriptLocationCache.put(location, script) } result = script.eval(bindings) } else { // cache the script text String scriptText = (String) scriptLocationCache.get(location) if (scriptText == null) { scriptText = ec.getResource().getLocationText(location, false) scriptLocationCache.put(location, scriptText) } result = engine.eval(scriptText, bindings) } return result } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/context/runner/XmlActionsScriptRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.context.runner import freemarker.template.Template import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.ScriptRunner import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.actions.XmlAction import org.moqui.context.ExecutionContext import org.moqui.impl.context.ExecutionContextImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache @CompileStatic class XmlActionsScriptRunner implements ScriptRunner { protected final static Logger logger = LoggerFactory.getLogger(XmlActionsScriptRunner.class) protected ExecutionContextFactoryImpl ecfi protected Cache scriptXmlActionLocationCache protected Template xmlActionsTemplate = null XmlActionsScriptRunner() { } ScriptRunner init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf this.scriptXmlActionLocationCache = ecfi.cacheFacade.getCache("resource.xml-actions.location", String.class, XmlAction.class) return this } Object run(String location, String method, ExecutionContext ec) { XmlAction xa = getXmlActionByLocation(location) return xa.run((ExecutionContextImpl) ec) } void destroy() { } XmlAction getXmlActionByLocation(String location) { XmlAction xa = (XmlAction) scriptXmlActionLocationCache.get(location) if (xa == null) xa = loadXmlAction(location) return xa } protected synchronized XmlAction loadXmlAction(String location) { XmlAction xa = (XmlAction) scriptXmlActionLocationCache.get(location) if (xa == null) { xa = new XmlAction(ecfi, ecfi.resourceFacade.getLocationText(location, false), location) scriptXmlActionLocationCache.put(location, xa) } return xa } Template getXmlActionsTemplate() { if (xmlActionsTemplate == null) makeXmlActionsTemplate() return xmlActionsTemplate } protected synchronized void makeXmlActionsTemplate() { if (xmlActionsTemplate != null) return String templateLocation = ecfi.confXmlRoot.first("resource-facade").attribute("xml-actions-template-location") Template newTemplate = null Reader templateReader = null try { templateReader = new InputStreamReader(ecfi.resourceFacade.getLocationStream(templateLocation)) newTemplate = new Template(templateLocation, templateReader, ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration()) } catch (Exception e) { logger.error("Error while initializing XMLActions template at [${templateLocation}]", e) } finally { if (templateReader != null) templateReader.close() } xmlActionsTemplate = newTemplate } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/AggregationUtil.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import groovy.lang.MissingPropertyException; import groovy.lang.Script; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.codehaus.groovy.runtime.InvokerHelper; import org.moqui.BaseArtifactException; import org.moqui.entity.EntityValue; import org.moqui.impl.actions.XmlAction; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.util.ContextStack; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.math.RoundingMode; import java.util.*; public class AggregationUtil { protected final static Logger logger = LoggerFactory.getLogger(AggregationUtil.class); protected final static boolean isTraceEnabled = logger.isTraceEnabled(); public enum AggregateFunction { MIN, MAX, SUM, AVG, COUNT, FIRST, LAST } private static final BigDecimal BIG_DECIMAL_TWO = new BigDecimal(2); public static class AggregateField { public final String fieldName; public final AggregateFunction function; public final AggregateFunction showTotal; public final boolean groupBy, subList; public final Class fromExpr; public AggregateField(String fn, AggregateFunction func, boolean gb, boolean sl, String st, Class from) { if ("false".equals(st)) st = null; fieldName = fn; function = func; groupBy = gb; subList = sl; fromExpr = from; showTotal = st != null ? AggregateFunction.valueOf(st.toUpperCase()) : null; } } private String listName, listEntryName; private AggregateField[] aggregateFields; private boolean hasFromExpr = false; private boolean hasSubListTotals = false; private String[] groupFields; private XmlAction rowActions; public AggregationUtil(String listName, String listEntryName, AggregateField[] aggregateFields, String[] groupFields, XmlAction rowActions) { this.listName = listName; this.listEntryName = listEntryName; if (this.listEntryName != null && this.listEntryName.isEmpty()) this.listEntryName = null; this.aggregateFields = aggregateFields; this.groupFields = groupFields; this.rowActions = rowActions; for (int i = 0; i < aggregateFields.length; i++) { AggregateField aggField = aggregateFields[i]; if (aggField.fromExpr != null) hasFromExpr = true; if (aggField.subList && aggField.showTotal != null) hasSubListTotals = true; } } @SuppressWarnings("unchecked") public ArrayList> aggregateList(Object listObj, Set includeFields, boolean makeSubList, ExecutionContextImpl eci) { if (groupFields == null || groupFields.length == 0) makeSubList = false; ArrayList> resultList = new ArrayList<>(); if (listObj == null) return resultList; // for (Object result : (Iterable) listObj) logger.warn("Aggregate Input: " + result.toString()); long startTime = System.currentTimeMillis(); Map, Map> groupRows = new HashMap<>(); Map totalsMap = new HashMap<>(); int originalCount = 0; if (listObj instanceof List) { List listList = (List) listObj; int listSize = listList.size(); if (listObj instanceof RandomAccess) { for (int i = 0; i < listSize; i++) { Object curObject = listList.get(i); processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci); originalCount++; } } else { int i = 0; for (Object curObject : listList) { processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci); i++; originalCount++; } } } else if (listObj instanceof Map) { Iterator listIter = (Iterator) ((Map) listObj).entrySet().iterator(); int i = 0; while (listIter.hasNext()) { Object curObject = listIter.next(); processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, listIter.hasNext(), makeSubList, eci); i++; originalCount++; } } else if (listObj instanceof Iterator) { Iterator listIter = (Iterator) listObj; int i = 0; while (listIter.hasNext()) { Object curObject = listIter.next(); processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, listIter.hasNext(), makeSubList, eci); i++; originalCount++; } } else if (listObj.getClass().isArray()) { Object[] listArray = (Object[]) listObj; int listSize = listArray.length; for (int i = 0; i < listSize; i++) { Object curObject = listArray[i]; processAggregateOriginal(curObject, resultList, includeFields, groupRows, totalsMap, i, (i < (listSize - 1)), makeSubList, eci); originalCount++; } } else { throw new BaseArtifactException("form-list list " + listName + " is a type we don't know how to iterate: " + listObj.getClass().getName()); } if (hasSubListTotals) { int resultSize = resultList.size(); for (int i = 0; i < resultSize; i++) { Map resultMap = resultList.get(i); ArrayList aggregateSubList = (ArrayList) resultMap.get("aggregateSubList"); if (aggregateSubList != null) { Map aggregateSubListTotals = (Map) resultMap.get("aggregateSubListTotals"); if (aggregateSubListTotals != null) aggregateSubList.add(aggregateSubListTotals); } } } if (totalsMap.size() > 0) resultList.add(new HashMap<>(totalsMap)); if (logger.isTraceEnabled()) logger.trace("Processed list " + listName + ", from " + originalCount + " items to " + resultList.size() + " items, in " + (System.currentTimeMillis() - startTime) + "ms"); // for (Map result : resultList) logger.warn("Aggregate Result: " + result.toString()); return resultList; } @SuppressWarnings("unchecked") private void processAggregateOriginal(Object curObject, ArrayList> resultList, Set includeFields, Map, Map> groupRows, Map totalsMap, int index, boolean hasNext, boolean makeSubList, ExecutionContextImpl eci) { Map curMap = null; if (curObject instanceof EntityValue) { curMap = ((EntityValue) curObject).getMap(); } else if (curObject instanceof Map) { curMap = (Map) curObject; } boolean curIsMap = curMap != null; ContextStack context = eci.contextStack; Map contextTopMap; if (curMap != null) { contextTopMap = new HashMap<>(curMap); } else { contextTopMap = new HashMap<>(); } context.push(contextTopMap); if (listEntryName != null) { context.put(listEntryName, curObject); context.put(listEntryName + "_index", index); context.put(listEntryName + "_has_next", hasNext); } else { context.put(listName + "_index", index); context.put(listName + "_has_next", hasNext); context.put(listName + "_entry", curObject); } // if there are row actions run them if (rowActions != null || hasFromExpr) { if (rowActions != null) rowActions.run(eci); // if any fields have a fromExpr get the value from that for (int i = 0; i < aggregateFields.length; i++) { AggregateField aggField = aggregateFields[i]; if (aggField.fromExpr != null) { Script script = InvokerHelper.createScript(aggField.fromExpr, eci.contextBindingInternal); Object newValue = script.run(); context.put(aggField.fieldName, newValue); } } } Map resultMap = null; Map groupByMap = null; if (makeSubList) { groupByMap = new HashMap<>(); for (int i = 0; i < groupFields.length; i++) { String groupBy = groupFields[i]; if (!includeFields.contains(groupBy)) continue; groupByMap.put(groupBy, getField(groupBy, context, curObject, curIsMap)); } resultMap = groupRows.get(groupByMap); } if (resultMap == null) { resultMap = contextTopMap; Map subListMap = null; Map subListTotalsMap = null; for (int i = 0; i < aggregateFields.length; i++) { AggregateField aggField = aggregateFields[i]; String fieldName = aggField.fieldName; Object fieldValue = getField(fieldName, context, curObject, curIsMap); // don't want to put null values, a waste of time/space; if count aggregate continue so it isn't counted if (fieldValue == null) continue; // handle subList if (makeSubList && aggField.subList) { // NOTE: may have an issue here not using contextTopMap as starting point for sub-list entry, ie row-actions values lost if not referenced in a field name/from // NOTE2: if we start with contextTopMap should clone and perhaps remove aggregateFields that are not sub-list if (subListMap == null) subListMap = new HashMap<>(); subListMap.put(fieldName, fieldValue); resultMap.remove(fieldName); } else if (aggField.function == AggregateFunction.COUNT) { resultMap.put(fieldName, 1); } else { resultMap.put(fieldName, fieldValue); } // handle showTotal if (aggField.showTotal != null) { if (aggField.subList) { if (subListTotalsMap == null) subListTotalsMap = new HashMap<>(); doFunction(aggField.showTotal, subListTotalsMap, fieldName, fieldValue); } else { doFunction(aggField.showTotal, totalsMap, fieldName, fieldValue); } } } if (subListMap != null) { ArrayList> subList = new ArrayList<>(); subList.add(subListMap); resultMap.put("aggregateSubList", subList); } if (subListTotalsMap != null) resultMap.put("aggregateSubListTotals", subListTotalsMap); resultList.add(resultMap); if (makeSubList) groupRows.put(groupByMap, resultMap); } else { // NOTE: if makeSubList == false this will never run Map subListMap = null; Map subListTotalsMap = (Map) resultMap.get("aggregateSubListTotals"); for (int i = 0; i < aggregateFields.length; i++) { AggregateField aggField = aggregateFields[i]; String fieldName = aggField.fieldName; Object fieldValue = getField(fieldName, context, curObject, curIsMap); // don't want to put null values, a waste of time/space; if count aggregate continue so it isn't counted if (fieldValue == null) continue; if (aggField.subList) { // NOTE: may have an issue here not using contextTopMap as starting point for sub-list entry, ie row-actions values lost if not referenced in a field name/from if (subListMap == null) subListMap = new HashMap<>(); subListMap.put(fieldName, fieldValue); } else if (aggField.function != null) { doFunction(aggField.function, resultMap, fieldName, fieldValue); } // handle showTotal if (aggField.showTotal != null) { if (aggField.subList) { if (subListTotalsMap == null) { subListTotalsMap = new HashMap<>(); resultMap.put("aggregateSubListTotals", subListTotalsMap); } doFunction(aggField.showTotal, subListTotalsMap, fieldName, fieldValue); } else { doFunction(aggField.showTotal, totalsMap, fieldName, fieldValue); } } } if (subListMap != null) { ArrayList> subList = (ArrayList>) resultMap.get("aggregateSubList"); if (subList != null) subList.add(subListMap); } } // all done, pop the row context to clean up context.pop(); } private Object getField(String fieldName, ContextStack context, Object curObject, boolean curIsMap) { Object value = context.getByString(fieldName); if (curObject != null && !curIsMap && ObjectUtilities.isEmpty(value)) { // try Groovy getAt for property access try { value = DefaultGroovyMethods.getAt(curObject, fieldName); } catch (MissingPropertyException e) { // ignore exception, we know this may not be a real property of the object if (isTraceEnabled) logger.trace("Field " + fieldName + " is not a property of list-entry " + listEntryName + " in list " + listName + ": " + e.toString()); } } return value; } @SuppressWarnings("unchecked") private void doFunction(AggregateFunction function, Map resultMap, String fieldName, Object fieldValue) { switch (function) { case MIN: case MAX: Comparable existingComp = (Comparable) resultMap.get(fieldName); Comparable newComp = (Comparable) fieldValue; if (existingComp == null) { if (newComp != null) resultMap.put(fieldName, newComp); } else { int compResult = existingComp.compareTo(newComp); if ((function == AggregateFunction.MIN && compResult > 0) || (function == AggregateFunction.MAX && compResult < 0)) resultMap.put(fieldName, newComp); } break; case SUM: if (fieldValue != null) { Number curNumber; if (fieldValue instanceof Number) { curNumber = (Number) fieldValue; } else if (fieldValue instanceof CharSequence) { curNumber = new BigDecimal(fieldValue.toString()); } else { throw new IllegalArgumentException("Tried to sum non-number value " + fieldValue); } Number sumNum = ObjectUtilities.addNumbers((Number) resultMap.get(fieldName), curNumber); if (sumNum != null) resultMap.put(fieldName, sumNum); } break; case AVG: Number newNum = (Number) fieldValue; if (newNum != null) { BigDecimal newNumBd = (newNum instanceof BigDecimal) ? (BigDecimal) newNum : new BigDecimal(newNum.toString()); String fieldCountName = fieldName.concat("Count"); String fieldTotalName = fieldName.concat("Total"); Number existingNum = (Number) resultMap.get(fieldName); if (existingNum == null) { resultMap.put(fieldName, newNumBd); resultMap.put(fieldCountName, BigDecimal.ONE); resultMap.put(fieldTotalName, newNumBd); } else { BigDecimal count = (BigDecimal) resultMap.get(fieldCountName); BigDecimal total = (BigDecimal) resultMap.get(fieldTotalName); BigDecimal avgTotal = total.add(newNumBd); BigDecimal countPlusOne = count.add(BigDecimal.ONE); resultMap.put(fieldName, avgTotal.divide(countPlusOne, RoundingMode.HALF_EVEN)); resultMap.put(fieldCountName, countPlusOne); resultMap.put(fieldTotalName, avgTotal); } } break; case COUNT: Integer existingCount = (Integer) resultMap.get(fieldName); if (existingCount == null) existingCount = 0; resultMap.put(fieldName, existingCount + 1); break; case FIRST: if (!resultMap.containsKey(fieldName)) resultMap.put(fieldName, fieldValue); break; case LAST: resultMap.put(fieldName, fieldValue); break; } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityCache.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import javax.cache.Cache import org.moqui.entity.EntityCondition import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.CacheFacadeImpl import org.moqui.util.MNode import org.moqui.util.SimpleTopic import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ConcurrentMap @CompileStatic class EntityCache { protected final static Logger logger = LoggerFactory.getLogger(EntityCache.class) protected final EntityFacadeImpl efi final CacheFacadeImpl cfi static final String oneKeyBase = "entity.record.one." static final String oneRaKeyBase = "entity.record.one_ra." static final String oneViewRaKeyBase = "entity.record.one_view_ra." static final String listKeyBase = "entity.record.list." static final String listRaKeyBase = "entity.record.list_ra." static final String listViewRaKeyBase = "entity.record.list_view_ra." static final String countKeyBase = "entity.record.count." Cache> oneBfCache protected final Map> cachedListViewEntitiesByMember = new HashMap<>() protected final boolean distributedCacheInvalidate /** Entity Cache Invalidate Topic */ private SimpleTopic entityCacheInvalidateTopic = null EntityCache(EntityFacadeImpl efi) { this.efi = efi this.cfi = efi.ecfi.cacheFacade oneBfCache = cfi.getCache("entity.record.one_bf") MNode entityFacadeNode = efi.getEntityFacadeNode() distributedCacheInvalidate = entityFacadeNode.attribute("distributed-cache-invalidate") == "true" && entityFacadeNode.attribute("dci-topic-factory") logger.info("Entity Cache initialized, distributed cache invalidate enabled: ${distributedCacheInvalidate}") if (distributedCacheInvalidate) { try { String dciTopicFactory = entityFacadeNode.attribute("dci-topic-factory") entityCacheInvalidateTopic = (SimpleTopic) efi.ecfi.getTool(dciTopicFactory, SimpleTopic.class) } catch (Exception e) { logger.error("Entity distributed cache invalidate is enabled but could not initialize", e) } } } static class EntityCacheInvalidate implements Externalizable { boolean isCreate EntityValueBase evb EntityCacheInvalidate() { } EntityCacheInvalidate(EntityValueBase evb, boolean isCreate) { this.isCreate = isCreate this.evb = evb } @Override void writeExternal(ObjectOutput out) throws IOException { out.writeBoolean(isCreate) // NOTE: this would be faster but can't because don't know which impl of the abstract class was used: evb.writeExternal(out) out.writeObject(evb) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { isCreate = objectInput.readBoolean() try { evb = (EntityValueBase) objectInput.readObject() } catch (Throwable t) { logger.error("Error deserializing EntityValueBase for EntityCacheInvalidate, isCreate " + isCreate, t) throw t } } } static class EmptyRecord extends EntityValueImpl { EmptyRecord() { } EmptyRecord(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip) } } void putInOneCache(EntityDefinition ed, EntityCondition whereCondition, EntityValueBase newEntityValue, Cache entityOneCache) { if (entityOneCache == null) entityOneCache = ed.getCacheOne(this) if (newEntityValue != null) newEntityValue.setFromCache() entityOneCache.put(whereCondition, newEntityValue != null ? newEntityValue : new EmptyRecord(ed, efi)) // need to register an RA just in case the condition was not actually a primary key registerCacheOneRa(ed.getFullEntityName(), whereCondition, newEntityValue) } EntityListImpl getFromListCache(EntityDefinition ed, EntityCondition whereCondition, List orderByList, Cache entityListCache) { if (whereCondition == null) return null if (entityListCache == null) entityListCache = ed.getCacheList(this) EntityListImpl cacheHit = (EntityListImpl) entityListCache.get(whereCondition) if (cacheHit != null && orderByList != null && orderByList.size() > 0) cacheHit.orderByFields(orderByList) return cacheHit } void putInListCache(EntityDefinition ed, EntityListImpl el, EntityCondition whereCondition, Cache entityListCache) { if (whereCondition == null) return if (entityListCache == null) entityListCache = ed.getCacheList(this) // EntityList elToCache = el != null ? el : EntityListImpl.EMPTY EntityListImpl elToCache = el != null ? el : efi.getEmptyList() elToCache.setFromCache() entityListCache.put(whereCondition, elToCache) registerCacheListRa(ed.getFullEntityName(), whereCondition, elToCache) } /* Long getFromCountCache(EntityDefinition ed, EntityCondition whereCondition, Cache entityCountCache) { if (entityCountCache == null) entityCountCache = getCacheCount(ed.getFullEntityName()) return (Long) entityCountCache.get(whereCondition) } */ /** Called from EntityValueBase */ void clearCacheForValue(EntityValueBase evb, boolean isCreate) { if (evb == null) return EntityDefinition ed = evb.getEntityDefinition() if (ed.entityInfo.neverCache) return // String entityName = evb.resolveEntityName() // if (!entityName.startsWith("moqui.")) logger.info("========== ========== ========== clearCacheForValue ${entityName}") if (distributedCacheInvalidate && entityCacheInvalidateTopic != null) { // NOTE: this takes some time to run and is done a LOT, for nearly all entity CrUD ops // NOTE: have set many entities as never cache // NOTE: can't avoid message when caches don't exist and not used in view-entity as it might be on another server EntityCacheInvalidate eci = new EntityCacheInvalidate(evb, isCreate) entityCacheInvalidateTopic.publish(eci) } else { clearCacheForValueActual(evb, isCreate) } } /** Does actual cache clear, called directly or distributed through topic */ void clearCacheForValueActual(EntityValueBase evb, boolean isCreate) { // logger.info("====== clearCacheForValueActual isCreate=${isCreate}, evb: ${evb}") try { EntityDefinition ed = evb.getEntityDefinition() // use getValueMap instead of getMap, faster and we don't want to cache localized values/etc Map evbMap = evb.getValueMap() // checked in clearCacheForValue(): if ('never'.equals(ed.getUseCache())) return String fullEntityName = ed.entityInfo.fullEntityName // init this as null, set below if needed (common case it isn't, will perform better) EntityCondition pkCondition = null // NOTE: use to check if caches exist ONLY, don't use to actually get cache ConcurrentMap localCacheMap = cfi.localCacheMap // clear one cache String oneKey = oneKeyBase.concat(fullEntityName) if (localCacheMap.containsKey(oneKey)) { pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys()) Cache entityOneCache = ed.getCacheOne(this) // clear by PK, most common scenario entityOneCache.remove(pkCondition) // NOTE: these two have to be done whether or not it is a create because of non-pk updates, etc // see if there are any one RA entries Cache> oneRaCache = ed.getCacheOneRa(this) Set raKeyList = (Set) oneRaCache.get(pkCondition) if (raKeyList != null) { for (EntityCondition ec in raKeyList) { entityOneCache.remove(ec) } // we've cleared all entries that this was referring to, so clean it out too oneRaCache.remove(pkCondition) } // see if there are any cached entries with no result using the bf (brute-force) matching Set bfKeySet = (Set) oneBfCache.get(fullEntityName) if (bfKeySet != null && bfKeySet.size() > 0) { ArrayList keysToRemove = new ArrayList() Iterator bfKeySetIter = bfKeySet.iterator() while (bfKeySetIter.hasNext()) { EntityCondition bfKey = (EntityCondition) bfKeySetIter.next() if (bfKey.mapMatches(evbMap)) keysToRemove.add(bfKey) } int keysToRemoveSize = keysToRemove.size() for (int i = 0; i < keysToRemoveSize; i++) { EntityCondition key = (EntityCondition) keysToRemove.get(i) entityOneCache.remove(key) bfKeySet.remove(key) } } } // check the One View RA entries for this entity String oneViewRaKey = oneViewRaKeyBase.concat(fullEntityName) if (localCacheMap.containsKey(oneViewRaKey)) { if (pkCondition == null) pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys()) Cache> oneViewRaCache = ed.getCacheOneViewRa(this) Set oneViewRaKeyList = (Set) oneViewRaCache.get(pkCondition) // if (fullEntityName.contains("FOO")) logger.warn("======= clearCacheForValue ${fullEntityName}, PK ${pkCondition}, oneViewRaKeyList: ${oneViewRaKeyList}") if (oneViewRaKeyList != null) { for (ViewRaKey raKey in oneViewRaKeyList) { EntityDefinition raEd = efi.getEntityDefinition(raKey.entityName) Cache viewEntityOneCache = raEd.getCacheOne(this) // this may have already been cleared, but it is a waste of time to check for that explicitly viewEntityOneCache.remove(raKey.ec) } // we've cleared all entries that this was referring to, so clean it out too oneViewRaCache.remove(pkCondition) } } // clear list cache, use reverse-associative Map (also a Cache) String listKey = listKeyBase.concat(fullEntityName) if (localCacheMap.containsKey(listKey)) { if (pkCondition == null) pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys()) Cache entityListCache = ed.getCacheList(this) // if this was a create the RA cache won't help, so go through EACH entry and see if it matches the created value // The RA cache doesn't work for updates in the scenario where a record exists but its fields don't // match a find condition when the cached list find is initially done, but is then updated so the // fields do match Iterator> elcIterator = entityListCache.iterator() while (elcIterator.hasNext()) { Cache.Entry entry = (Cache.Entry) elcIterator.next() EntityCondition ec = (EntityCondition) entry.getKey() // any way to efficiently clear out the RA cache for these? for now just leave and they are handled eventually if (ec.mapMatches(evbMap)) entityListCache.remove(ec) } // if this is an update also check reverse associations (RA) as the condition check above may not match // against the new values, or partially updated records if (!isCreate) { // First just the list RA cache Cache> listRaCache = ed.getCacheListRa(this) // logger.warn("============= clearing list for entity ${fullEntityName}, for pkCondition [${pkCondition}] listRaCache=${listRaCache}") Set raKeyList = (Set) listRaCache.get(pkCondition) if (raKeyList != null) { // logger.warn("============= for entity ${fullEntityName}, for pkCondition [${pkCondition}], raKeyList for clear=${raKeyList}") for (EntityCondition raKey in raKeyList) { // logger.warn("============= for entity ${fullEntityName}, removing raKey=${raKey} from ${entityListCache.getName()}") EntityCondition ec = (EntityCondition) raKey // this may have already been cleared, but it is a waste of time to check for that explicitly entityListCache.remove(ec) } // we've cleared all entries that this was referring to, so clean it out too listRaCache.remove(pkCondition) } // Now to the same for the list view RA cache Cache> listViewRaCache = ed.getCacheListViewRa(this) // logger.warn("============= clearing view list for entity ${fullEntityName}, for pkCondition [${pkCondition}] listViewRaCache=${listViewRaCache}") Set listViewRaKeyList = (Set) listViewRaCache.get(pkCondition) if (listViewRaKeyList != null) { // logger.warn("============= for entity ${fullEntityName}, for pkCondition [${pkCondition}], listViewRaKeyList for clear=${listViewRaKeyList}") for (ViewRaKey raKey in listViewRaKeyList) { // logger.warn("============= for entity ${fullEntityName}, removing raKey=${raKey} from ${entityListCache.getName()}") EntityDefinition raEd = efi.getEntityDefinition(raKey.entityName) Cache viewEntityListCache = raEd.getCacheList(this) // this may have already been cleared, but it is a waste of time to check for that explicitly viewEntityListCache.remove(raKey.ec) } // we've cleared all entries that this was referring to, so clean it out too listViewRaCache.remove(pkCondition) } } } // see if this entity is a member of a cached view-entity List cachedViewEntityNames = (List) cachedListViewEntitiesByMember.get(fullEntityName) if (cachedViewEntityNames != null) synchronized (cachedViewEntityNames) { int cachedViewEntityNamesSize = cachedViewEntityNames.size() for (int i = 0; i < cachedViewEntityNamesSize; i++) { String cachedViewEntityName = (String) cachedViewEntityNames.get(i) // logger.warn("Found ${cachedViewEntityName} as a cached view-entity for member ${fullEntityName}") EntityDefinition viewEd = efi.getEntityDefinition(cachedViewEntityName) // generally match against view-entity aliases for fields on member entity // handle cases where current record (evbMap) has some keys from view-entity but not all (like UserPermissionCheck) Map viewMatchMap = new HashMap<>() Map> memberFieldAliases = viewEd.getMemberFieldAliases(fullEntityName) for (Map.Entry> mfAliasEntry in memberFieldAliases.entrySet()) { String fieldName = mfAliasEntry.getKey() if (!evbMap.containsKey(fieldName)) continue Object fieldValue = evbMap.get(fieldName) ArrayList aliasNodeList = (ArrayList) mfAliasEntry.getValue() int aliasNodeListSize = aliasNodeList.size() for (int j = 0 ; j < aliasNodeListSize; j++) { MNode aliasNode = (MNode) aliasNodeList.get(j) viewMatchMap.put(aliasNode.attribute("name"), fieldValue) } } // logger.warn("========= viewMatchMap: ${viewMatchMap}") Cache entityListCache = viewEd.getCacheList(this) Iterator> elcIterator = entityListCache.iterator() while (elcIterator.hasNext()) { Cache.Entry entry = (Cache.Entry) elcIterator.next() // in javax.cache.Cache next() may return null for expired, etc entries if (entry == null) continue; EntityCondition econd = (EntityCondition) entry.getKey() // logger.warn("======= entity ${fullEntityName} view-entity ${cachedViewEntityName} matches any? ${econd.mapMatchesAny(viewMatchMap)} keys not contained? ${econd.mapKeysNotContained(viewMatchMap)} econd: ${econd}") // FUTURE: any way to efficiently clear out the RA cache for these? for now just leave and they are handled eventually // don't require a full match, if matches any part of condition clear it // NOTE: the mapKeysNotContained() call will handle cases where there is no negative match, but is overly // inclusive and will clear cache entries that may not need to be cleared; a better approach might be // possible; especially needed for cases where the list is queried by a field on the primary member-entity // but another member-entity is updated if (econd.mapMatchesAny(viewMatchMap) || econd.mapKeysNotContained(viewMatchMap)) elcIterator.remove() } } } // clear count cache (no RA because we only have a count to work with, just match by condition) String countKey = countKeyBase.concat(fullEntityName) if (localCacheMap.containsKey(countKey)) { Cache entityCountCache = ed.getCacheCount(this) // with so little information about count cache results we can't do RA and checking conditions fails to clear in // cases where a value no longer matches, would handle newly matched clearing where count increases but not no // longer matches cases where count decreases // no choice but to clear the whole cache entityCountCache.clear() /* Iterator> eccIterator = entityCountCache.iterator() while (eccIterator.hasNext()) { Cache.Entry entry = (Cache.Entry) eccIterator.next() EntityCondition ec = (EntityCondition) entry.getKey() logger.warn("checking count condition: ${ec.toString()} matches? ${ec.mapMatchesAny(evbMap) || ec.mapKeysNotContained(evbMap)}") if (ec.mapMatchesAny(evbMap) || ec.mapKeysNotContained(evbMap)) eccIterator.remove() } */ } } catch (Throwable t) { logger.error("Suppressed error in entity cache clearing [${evb.resolveEntityName()}; ${isCreate ? 'create' : 'non-create'}]", t) } } void registerCacheOneRa(String entityName, EntityCondition ec, EntityValueBase evb) { // don't skip it for null values because we're caching those too: if (evb == null) return if (evb == null) { // can't use RA cache because we don't know the PK, so use a brute-force cache but keep it separate to perform better Set bfKeySet = (Set) oneBfCache.get(entityName) if (bfKeySet == null) { bfKeySet = ConcurrentHashMap.newKeySet() oneBfCache.put(entityName, bfKeySet) } bfKeySet.add(ec) } else { EntityDefinition ed = evb.getEntityDefinition() Cache> oneRaCache = ed.getCacheOneRa(this) EntityCondition pkCondition = efi.getConditionFactory().makeCondition(evb.getPrimaryKeys()) // if the condition matches the primary key, no need for an RA entry if (pkCondition != ec) { Set raKeyList = (Set) oneRaCache.get(pkCondition) if (raKeyList == null) { raKeyList = ConcurrentHashMap.newKeySet() oneRaCache.put(pkCondition, raKeyList) } raKeyList.add(ec) } // if this is a view entity we need View RA entries for each member entity (that we have a PK for) if (ed.isViewEntity) { // go through each member-entity ArrayList memberEntityList = ed.getEntityNode().children('member-entity') int memberEntityListSize = memberEntityList.size() for (int i = 0; i < memberEntityListSize; i++) { MNode memberEntityNode = (MNode) memberEntityList.get(i) Map mePkFieldToAliasNameMap = ed.getMePkFieldToAliasNameMap(memberEntityNode.attribute('entity-alias')) if (mePkFieldToAliasNameMap.isEmpty()) { logger.warn("for view-entity ${entityName}, member-entity ${memberEntityNode.attribute('@entity-name')}, got empty PK field to alias map") continue } // create EntityCondition with pk fields // store with main ec with view-entity name in a RA cache for view entities for the member-entity name // with cache key of member-entity PK EntityCondition obj EntityDefinition memberEd = efi.getEntityDefinition(memberEntityNode.attribute('entity-name')) // String memberEntityName = memberEd.getFullEntityName() Map pkCondMap = new HashMap<>() for (Map.Entry mePkEntry in mePkFieldToAliasNameMap.entrySet()) pkCondMap.put(mePkEntry.getKey(), evb.getNoCheckSimple(mePkEntry.getValue())) // no PK fields? view-entity must not have them, skip it if (pkCondMap.size() == 0) continue // logger.warn("====== for view-entity ${entityName}, member-entity ${memberEd.fullEntityName}, got PK field to alias map: ${mePkFieldToAliasNameMap}\npkCondMap: ${pkCondMap}") Cache> oneViewRaCache = memberEd.getCacheOneViewRa(this) EntityCondition memberPkCondition = efi.getConditionFactory().makeCondition(pkCondMap) Set raKeyList = (Set) oneViewRaCache.get(memberPkCondition) ViewRaKey newRaKey = new ViewRaKey(entityName, ec) if (raKeyList == null) { raKeyList = ConcurrentHashMap.newKeySet() oneViewRaCache.put(memberPkCondition, raKeyList) raKeyList.add(newRaKey) // logger.warn("===== added ViewRaKey for ${memberEntityName}, PK ${memberPkCondition}, raKeyList: ${raKeyList}") } else if (!raKeyList.contains(newRaKey)) { raKeyList.add(newRaKey) // logger.warn("===== added ViewRaKey for ${memberEntityName}, PK ${memberPkCondition}, raKeyList: ${raKeyList}") } } } } } void registerCacheListRa(String entityName, EntityCondition ec, EntityList eli) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed.isViewEntity) { // go through each member-entity ArrayList memberEntityList = ed.getEntityNode().children('member-entity') int memberEntityListSize = memberEntityList.size() for (int j = 0; j < memberEntityListSize; j++) { MNode memberEntityNode = (MNode) memberEntityList.get(j) Map mePkFieldToAliasNameMap = ed.getMePkFieldToAliasNameMap(memberEntityNode.attribute('entity-alias')) if (mePkFieldToAliasNameMap.isEmpty()) { logger.warn("for view-entity ${entityName}, member-entity ${memberEntityNode.attribute('@entity-name')}, got empty PK field to alias map") continue } // logger.warn("TOREMOVE for view-entity ${entityName}, member-entity ${memberEntityNode.'@entity-name'}, got PK field to alias map: ${mePkFieldToAliasNameMap}") // create EntityCondition with pk fields // store with main ec with view-entity name in a RA cache for view entities for the member-entity name // with cache key of member-entity PK EntityCondition obj EntityDefinition memberEd = efi.getEntityDefinition(memberEntityNode.attribute('entity-name')) String memberEntityName = memberEd.getFullEntityName() // remember that this member entity has been used in a cached view entity List cachedViewEntityNames = cachedListViewEntitiesByMember.get(memberEntityName) if (cachedViewEntityNames == null) { cachedViewEntityNames = Collections.synchronizedList(new ArrayList<>()) as List cachedListViewEntitiesByMember.put(memberEntityName, cachedViewEntityNames) cachedViewEntityNames.add(entityName) // logger.info("Added ${entityName} as a cached view-entity for member ${memberEntityName}") } else if (!cachedViewEntityNames.contains(entityName)) { cachedViewEntityNames.add(entityName) // logger.info("Added ${entityName} as a cached view-entity for member ${memberEntityName}") } Cache> listViewRaCache = memberEd.getCacheListViewRa(this) int eliSize = eli.size() for (int i = 0; i < eliSize; i++) { EntityValue ev = (EntityValue) eli.get(i) Map pkCondMap = new HashMap() for (Map.Entry mePkEntry in mePkFieldToAliasNameMap.entrySet()) pkCondMap.put(mePkEntry.getKey(), ev.getNoCheckSimple(mePkEntry.getValue())) EntityCondition pkCondition = efi.getConditionFactory().makeCondition(pkCondMap) Set raKeyList = (Set) listViewRaCache.get(pkCondition) ViewRaKey newRaKey = new ViewRaKey(entityName, ec) if (raKeyList == null) { raKeyList = ConcurrentHashMap.newKeySet() listViewRaCache.put(pkCondition, raKeyList) raKeyList.add(newRaKey) } else if (!raKeyList.contains(newRaKey)) { raKeyList.add(newRaKey) } // logger.warn("TOREMOVE for view-entity ${entityName}, member-entity ${memberEntityNode.'@entity-name'}, for pkCondition [${pkCondition}], raKeyList after add=${raKeyList}") } } } else { Cache> listRaCache = ed.getCacheListRa(this) int eliSize = eli.size() for (int i = 0; i < eliSize; i++) { EntityValue ev = (EntityValue) eli.get(i) EntityCondition pkCondition = efi.getConditionFactory().makeCondition(ev.getPrimaryKeys()) // NOTE: was memory leak here, using List it gets really large over time with duplicate find list conditions, use Set instead Set raKeyList = (Set) listRaCache.get(pkCondition) if (raKeyList == null) { raKeyList = ConcurrentHashMap.newKeySet() listRaCache.put(pkCondition, raKeyList) } raKeyList.add(ec) } } } static class ViewRaKey implements Serializable { final String entityName final EntityCondition ec final int hashCodeVal ViewRaKey(String entityName, EntityCondition ec) { this.entityName = entityName; this.ec = ec; hashCodeVal = entityName.hashCode() + ec.hashCode() } @Override int hashCode() { return hashCodeVal } @Override boolean equals(Object obj) { if (obj.getClass() != ViewRaKey.class) return false ViewRaKey that = (ViewRaKey) obj if (!entityName.equals(that.entityName)) return false if (!ec.equals(that.ec)) return false return true } @Override String toString() { return entityName + '(' + ec.toString() + ')' } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityConditionFactoryImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.entity.EntityCondition import org.moqui.entity.EntityCondition.ComparisonOperator import org.moqui.entity.EntityCondition.JoinOperator import org.moqui.entity.EntityConditionFactory import org.moqui.entity.EntityException import org.moqui.util.CollectionUtilities.KeyValue import org.moqui.impl.entity.condition.* import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp @CompileStatic class EntityConditionFactoryImpl implements EntityConditionFactory { protected final static Logger logger = LoggerFactory.getLogger(EntityConditionFactoryImpl.class) protected final EntityFacadeImpl efi protected final TrueCondition trueCondition EntityConditionFactoryImpl(EntityFacadeImpl efi) { this.efi = efi trueCondition = new TrueCondition() } EntityFacadeImpl getEfi() { return efi } @Override EntityCondition getTrueCondition() { return trueCondition } @Override EntityCondition makeCondition(EntityCondition lhs, JoinOperator operator, EntityCondition rhs) { return makeConditionImpl((EntityConditionImplBase) lhs, operator, (EntityConditionImplBase) rhs) } static EntityConditionImplBase makeConditionImpl(EntityConditionImplBase lhs, JoinOperator operator, EntityConditionImplBase rhs) { if (lhs != null) { if (rhs != null) { // we have both lhs and rhs if (lhs instanceof ListCondition) { ListCondition lhsLc = (ListCondition) lhs if (lhsLc.getOperator() == operator) { if (rhs instanceof ListCondition) { ListCondition rhsLc = (ListCondition) rhs if (rhsLc.getOperator() == operator) { lhsLc.addConditions(rhsLc) return lhsLc } else { lhsLc.addCondition(rhsLc) return lhsLc } } else { lhsLc.addCondition((EntityConditionImplBase) rhs) return lhsLc } } } // no special handling, create a BasicJoinCondition return new BasicJoinCondition((EntityConditionImplBase) lhs, operator, (EntityConditionImplBase) rhs) } else { return lhs } } else { if (rhs != null) { return rhs } else { return null } } } @Override EntityCondition makeCondition(String fieldName, ComparisonOperator operator, Object value) { return new FieldValueCondition(new ConditionField(fieldName), operator, value) } @Override EntityCondition makeCondition(String fieldName, ComparisonOperator operator, Object value, boolean orNull) { EntityConditionImplBase cond = new FieldValueCondition(new ConditionField(fieldName), operator, value) return orNull ? makeCondition(cond, JoinOperator.OR, makeCondition(fieldName, ComparisonOperator.EQUALS, null)) : cond } @Override EntityCondition makeConditionToField(String fieldName, ComparisonOperator operator, String toFieldName) { return new FieldToFieldCondition(new ConditionField(fieldName), operator, new ConditionField(toFieldName)) } @Override EntityCondition makeCondition(List conditionList) { return this.makeCondition(conditionList, JoinOperator.AND) } @Override EntityCondition makeCondition(List conditionList, JoinOperator operator) { if (conditionList == null || conditionList.size() == 0) return null ArrayList newList = new ArrayList() if (conditionList instanceof RandomAccess) { // avoid creating an iterator if possible int listSize = conditionList.size() for (int i = 0; i < listSize; i++) { EntityCondition curCond = conditionList.get(i) if (curCond == null) continue // this is all they could be, all that is supported right now if (curCond instanceof EntityConditionImplBase) newList.add((EntityConditionImplBase) curCond) else throw new BaseArtifactException("EntityCondition of type [${curCond.getClass().getName()}] not supported") } } else { Iterator conditionIter = conditionList.iterator() while (conditionIter.hasNext()) { EntityCondition curCond = conditionIter.next() if (curCond == null) continue // this is all they could be, all that is supported right now if (curCond instanceof EntityConditionImplBase) newList.add((EntityConditionImplBase) curCond) else throw new BaseArtifactException("EntityCondition of type [${curCond.getClass().getName()}] not supported") } } if (newList == null || newList.size() == 0) return null if (newList.size() == 1) { return (EntityCondition) newList.get(0) } else { return new ListCondition(newList, operator) } } @Override EntityCondition makeCondition(List conditionList, String listOperator, String mapComparisonOperator, String mapJoinOperator) { if (conditionList == null || conditionList.size() == 0) return null JoinOperator listJoin = listOperator ? getJoinOperator(listOperator) : JoinOperator.AND ComparisonOperator mapComparison = mapComparisonOperator ? getComparisonOperator(mapComparisonOperator) : ComparisonOperator.EQUALS JoinOperator mapJoin = mapJoinOperator ? getJoinOperator(mapJoinOperator) : JoinOperator.AND List newList = new ArrayList() Iterator conditionIter = conditionList.iterator() while (conditionIter.hasNext()) { Object curObj = conditionIter.next() if (curObj == null) continue if (curObj instanceof Map) { Map curMap = (Map) curObj if (curMap.size() == 0) continue EntityCondition curCond = makeCondition(curMap, mapComparison, mapJoin) newList.add((EntityConditionImplBase) curCond) continue } if (curObj instanceof EntityConditionImplBase) { EntityConditionImplBase curCond = (EntityConditionImplBase) curObj newList.add(curCond) continue } throw new BaseArtifactException("The conditionList parameter must contain only Map and EntityCondition objects, found entry of type [${curObj.getClass().getName()}]") } if (newList.size() == 0) return null if (newList.size() == 1) { return newList.get(0) } else { return new ListCondition(newList, listJoin) } } @Override EntityCondition makeCondition(Map fieldMap, ComparisonOperator comparisonOperator, JoinOperator joinOperator) { return makeCondition(fieldMap, comparisonOperator, joinOperator, null, null, false) } EntityConditionImplBase makeCondition(Map fieldMap, ComparisonOperator comparisonOperator, JoinOperator joinOperator, EntityDefinition findEd, Map> memberFieldAliases, boolean excludeNulls) { if (fieldMap == null || fieldMap.size() == 0) return (EntityConditionImplBase) null JoinOperator joinOp = joinOperator != null ? joinOperator : JoinOperator.AND ComparisonOperator compOp = comparisonOperator != null ? comparisonOperator : ComparisonOperator.EQUALS ArrayList condList = new ArrayList() ArrayList fieldList = new ArrayList() for (Map.Entry entry in fieldMap.entrySet()) { String key = entry.getKey() Object value = entry.getValue() if (key.startsWith("_")) { if (key == "_comp") { compOp = getComparisonOperator((String) value) continue } else if (key == "_join") { joinOp = getJoinOperator((String) value) continue } else if (key == "_list") { // if there is an _list treat each as a condition Map, ie call back into this method if (value instanceof List) { List valueList = (List) value for (Object listEntry in valueList) { if (listEntry instanceof Map) { EntityConditionImplBase entryCond = makeCondition((Map) listEntry, ComparisonOperator.EQUALS, JoinOperator.AND, findEd, memberFieldAliases, excludeNulls) if (entryCond != null) condList.add(entryCond) } else { throw new EntityException("Entry in _list is not a Map: ${listEntry}") } } } else { throw new EntityException("Value for _list entry is not a List: ${value}") } continue } } if (excludeNulls && value == null) { if (logger.isTraceEnabled()) logger.trace("Tried to filter find on entity ${findEd.fullEntityName} on field ${key} but value was null, not adding condition") continue } // add field key/value to a list to iterate over later for conditions once we have _comp for sure fieldList.add(new KeyValue(key, value)) } // has fields? make conditions for them if (fieldList.size() > 0) { int fieldListSize = fieldList.size() for (int i = 0; i < fieldListSize; i++) { KeyValue fieldValue = (KeyValue) fieldList.get(i) String fieldName = fieldValue.key Object value = fieldValue.value if (memberFieldAliases != null && memberFieldAliases.size() > 0) { // we have a view entity, more complex ArrayList aliases = (ArrayList) memberFieldAliases.get(fieldName) if (aliases == null || aliases.size() == 0) throw new EntityException("Tried to filter on field ${fieldName} which is not included in view-entity ${findEd.fullEntityName}") for (int k = 0; k < aliases.size(); k++) { MNode aliasNode = (MNode) aliases.get(k) // could be same as field name, but not if aliased with different name String aliasName = aliasNode.attribute("name") ConditionField cf = findEd != null ? findEd.getFieldInfo(aliasName).conditionField : new ConditionField(aliasName) if (ComparisonOperator.NOT_EQUAL.is(compOp) || ComparisonOperator.NOT_IN.is(compOp) || ComparisonOperator.NOT_LIKE.is(compOp)) { condList.add(makeConditionImpl(new FieldValueCondition(cf, compOp, value), JoinOperator.OR, new FieldValueCondition(cf, ComparisonOperator.EQUALS, null))) } else { // in view-entities do or null for member entities that are join-optional String memberAlias = aliasNode.attribute("entity-alias") MNode memberEntity = findEd.getMemberEntityNode(memberAlias) if ("true".equals(memberEntity.attribute("join-optional"))) { condList.add(new BasicJoinCondition(new FieldValueCondition(cf, compOp, value), JoinOperator.OR, new FieldValueCondition(cf, ComparisonOperator.EQUALS, null))) } else { condList.add(new FieldValueCondition(cf, compOp, value)) } } } } else { ConditionField cf = findEd != null ? findEd.getFieldInfo(fieldName).conditionField : new ConditionField(fieldName) if (ComparisonOperator.NOT_EQUAL.is(compOp) || ComparisonOperator.NOT_IN.is(compOp) || ComparisonOperator.NOT_LIKE.is(compOp)) { condList.add(makeConditionImpl(new FieldValueCondition(cf, compOp, value), JoinOperator.OR, new FieldValueCondition(cf, ComparisonOperator.EQUALS, null))) } else { condList.add(new FieldValueCondition(cf, compOp, value)) } } } } if (condList.size() == 0) return (EntityConditionImplBase) null if (condList.size() == 1) { return (EntityConditionImplBase) condList.get(0) } else { return new ListCondition(condList, joinOp) } } @Override EntityCondition makeCondition(Map fieldMap) { return makeCondition(fieldMap, ComparisonOperator.EQUALS, JoinOperator.AND, null, null, false) } @Override EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp) { return new DateCondition(fromFieldName, thruFieldName, (compareStamp != (Object) null) ? compareStamp : efi.ecfi.getEci().userFacade.getNowTimestamp()) } EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp, boolean ignoreIfEmpty, String ignore) { if (ignoreIfEmpty && (Object) compareStamp == null) return null if (efi.ecfi.resourceFacade.condition(ignore, null)) return null return new DateCondition(fromFieldName, thruFieldName, (compareStamp != (Object) null) ? compareStamp : efi.ecfi.getEci().userFacade.getNowTimestamp()) } @Override EntityCondition makeConditionWhere(String sqlWhereClause) { if (!sqlWhereClause) return null return new WhereCondition(sqlWhereClause) } ComparisonOperator comparisonOperatorFromEnumId(String enumId) { switch (enumId) { case "ENTCO_LESS": return EntityCondition.LESS_THAN case "ENTCO_GREATER": return EntityCondition.GREATER_THAN case "ENTCO_LESS_EQ": return EntityCondition.LESS_THAN_EQUAL_TO case "ENTCO_GREATER_EQ": return EntityCondition.GREATER_THAN_EQUAL_TO case "ENTCO_EQUALS": return EntityCondition.EQUALS case "ENTCO_NOT_EQUALS": return EntityCondition.NOT_EQUAL case "ENTCO_IN": return EntityCondition.IN case "ENTCO_NOT_IN": return EntityCondition.NOT_IN case "ENTCO_BETWEEN": return EntityCondition.BETWEEN case "ENTCO_NOT_BETWEEN": return EntityCondition.NOT_BETWEEN case "ENTCO_LIKE": return EntityCondition.LIKE case "ENTCO_NOT_LIKE": return EntityCondition.NOT_LIKE case "ENTCO_IS_NULL": return EntityCondition.IS_NULL case "ENTCO_IS_NOT_NULL": return EntityCondition.IS_NOT_NULL default: return null } } static EntityConditionImplBase addAndListToCondition(EntityConditionImplBase baseCond, ArrayList condList) { EntityConditionImplBase outCondition = baseCond int condListSize = condList != null ? condList.size() : 0 if (condListSize > 0) { if (baseCond == null) { if (condListSize == 1) { outCondition = (EntityConditionImplBase) condList.get(0) } else { outCondition = new ListCondition(condList, EntityCondition.AND) } } else { ListCondition newListCond = (ListCondition) null if (baseCond instanceof ListCondition) { ListCondition baseListCond = (ListCondition) baseCond if (EntityCondition.AND.is(baseListCond.operator)) { // modify in place newListCond = baseListCond } } if (newListCond == null) newListCond = new ListCondition([baseCond], EntityCondition.AND) newListCond.addConditions(condList) outCondition = newListCond } } return outCondition } EntityCondition makeActionCondition(String fieldName, String operator, String fromExpr, String value, String toFieldName, boolean ignoreCase, boolean ignoreIfEmpty, boolean orNull, String ignore) { Object from = fromExpr ? this.efi.ecfi.resourceFacade.expression(fromExpr, "") : null return makeActionConditionDirect(fieldName, operator, from, value, toFieldName, ignoreCase, ignoreIfEmpty, orNull, ignore) } EntityCondition makeActionConditionDirect(String fieldName, String operator, Object fromObj, String value, String toFieldName, boolean ignoreCase, boolean ignoreIfEmpty, boolean orNull, String ignore) { // logger.info("TOREMOVE makeActionCondition(fieldName ${fieldName}, operator ${operator}, fromExpr ${fromExpr}, value ${value}, toFieldName ${toFieldName}, ignoreCase ${ignoreCase}, ignoreIfEmpty ${ignoreIfEmpty}, orNull ${orNull}, ignore ${ignore})") if (efi.ecfi.resourceFacade.condition(ignore, null)) return null if (toFieldName != null && toFieldName.length() > 0) { EntityCondition ec = makeConditionToField(fieldName, getComparisonOperator(operator), toFieldName) if (ignoreCase) ec.ignoreCase() return ec } else { Object condValue if (value != null && value.length() > 0) { // NOTE: have to convert value (if needed) later on because we don't know which entity/field this is for, or change to pass in entity? condValue = value } else { condValue = fromObj } if (ignoreIfEmpty && ObjectUtilities.isEmpty(condValue)) return null EntityCondition mainEc = makeCondition(fieldName, getComparisonOperator(operator), condValue) if (ignoreCase) mainEc.ignoreCase() EntityCondition ec = mainEc if (orNull) ec = makeCondition(mainEc, JoinOperator.OR, makeCondition(fieldName, ComparisonOperator.EQUALS, null)) return ec } } EntityCondition makeActionCondition(MNode node) { Map attrs = node.attributes return makeActionCondition(attrs.get("field-name"), attrs.get("operator") ?: "equals", (attrs.get("from") ?: attrs.get("field-name")), attrs.get("value"), attrs.get("to-field-name"), (attrs.get("ignore-case") ?: "false") == "true", (attrs.get("ignore-if-empty") ?: "false") == "true", (attrs.get("or-null") ?: "false") == "true", (attrs.get("ignore") ?: "false")) } EntityCondition makeActionConditions(MNode node, boolean isCached) { ArrayList condList = new ArrayList() ArrayList subCondList = node.getChildren() int subCondListSize = subCondList.size() for (int i = 0; i < subCondListSize; i++) { MNode subCond = (MNode) subCondList.get(i) if ("econdition".equals(subCond.nodeName)) { EntityCondition econd = makeActionCondition(subCond) if (econd != null) condList.add(econd) } else if ("econditions".equals(subCond.nodeName)) { EntityCondition econd = makeActionConditions(subCond, isCached) if (econd != null) condList.add(econd) } else if ("date-filter".equals(subCond.nodeName)) { if (!isCached) { Timestamp validDate = subCond.attribute("valid-date") ? efi.ecfi.resourceFacade.expression(subCond.attribute("valid-date"), null) as Timestamp : null condList.add(makeConditionDate(subCond.attribute("from-field-name") ?: "fromDate", subCond.attribute("thru-field-name") ?: "thruDate", validDate, 'true'.equals(subCond.attribute("ignore-if-empty")), subCond.attribute("ignore") ?: 'false')) } } else if ("econdition-object".equals(subCond.nodeName)) { Object curObj = efi.ecfi.resourceFacade.expression(subCond.attribute("field"), null) if (curObj == null) continue if (curObj instanceof Map) { Map curMap = (Map) curObj if (curMap.size() == 0) continue EntityCondition curCond = makeCondition(curMap, ComparisonOperator.EQUALS, JoinOperator.AND) condList.add((EntityConditionImplBase) curCond) continue } if (curObj instanceof EntityConditionImplBase) { EntityConditionImplBase curCond = (EntityConditionImplBase) curObj condList.add(curCond) continue } throw new BaseArtifactException("The econdition-object field attribute must contain only Map and EntityCondition objects, found entry of type [${curObj.getClass().getName()}]") } } return makeCondition(condList, getJoinOperator(node.attribute("combine"))) } protected static final Map comparisonOperatorStringMap = new EnumMap(ComparisonOperator.class) static { comparisonOperatorStringMap.put(ComparisonOperator.EQUALS, "=") comparisonOperatorStringMap.put(ComparisonOperator.NOT_EQUAL, "<>") comparisonOperatorStringMap.put(ComparisonOperator.LESS_THAN, "<") comparisonOperatorStringMap.put(ComparisonOperator.GREATER_THAN, ">") comparisonOperatorStringMap.put(ComparisonOperator.LESS_THAN_EQUAL_TO, "<=") comparisonOperatorStringMap.put(ComparisonOperator.GREATER_THAN_EQUAL_TO, ">=") comparisonOperatorStringMap.put(ComparisonOperator.IN, "IN") comparisonOperatorStringMap.put(ComparisonOperator.NOT_IN, "NOT IN") comparisonOperatorStringMap.put(ComparisonOperator.BETWEEN, "BETWEEN") comparisonOperatorStringMap.put(ComparisonOperator.NOT_BETWEEN, "NOT BETWEEN") comparisonOperatorStringMap.put(ComparisonOperator.LIKE, "LIKE") comparisonOperatorStringMap.put(ComparisonOperator.NOT_LIKE, "NOT LIKE") comparisonOperatorStringMap.put(ComparisonOperator.IS_NULL, "IS NULL") comparisonOperatorStringMap.put(ComparisonOperator.IS_NOT_NULL, "IS NOT NULL") } protected static final Map stringComparisonOperatorMap = [ "=":ComparisonOperator.EQUALS, "equals":ComparisonOperator.EQUALS, "not-equals":ComparisonOperator.NOT_EQUAL, "not-equal":ComparisonOperator.NOT_EQUAL, "!=":ComparisonOperator.NOT_EQUAL, "<>":ComparisonOperator.NOT_EQUAL, "less-than":ComparisonOperator.LESS_THAN, "less":ComparisonOperator.LESS_THAN, "<":ComparisonOperator.LESS_THAN, "greater-than":ComparisonOperator.GREATER_THAN, "greater":ComparisonOperator.GREATER_THAN, ">":ComparisonOperator.GREATER_THAN, "less-than-equal-to":ComparisonOperator.LESS_THAN_EQUAL_TO, "less-equals":ComparisonOperator.LESS_THAN_EQUAL_TO, "<=":ComparisonOperator.LESS_THAN_EQUAL_TO, "greater-than-equal-to":ComparisonOperator.GREATER_THAN_EQUAL_TO, "greater-equals":ComparisonOperator.GREATER_THAN_EQUAL_TO, ">=":ComparisonOperator.GREATER_THAN_EQUAL_TO, "in":ComparisonOperator.IN, "IN":ComparisonOperator.IN, "not-in":ComparisonOperator.NOT_IN, "NOT IN":ComparisonOperator.NOT_IN, "between":ComparisonOperator.BETWEEN, "BETWEEN":ComparisonOperator.BETWEEN, "not-between":ComparisonOperator.NOT_BETWEEN, "NOT BETWEEN":ComparisonOperator.NOT_BETWEEN, "like":ComparisonOperator.LIKE, "LIKE":ComparisonOperator.LIKE, "not-like":ComparisonOperator.NOT_LIKE, "NOT LIKE":ComparisonOperator.NOT_LIKE, "is-null":ComparisonOperator.IS_NULL, "IS NULL":ComparisonOperator.IS_NULL, "is-not-null":ComparisonOperator.IS_NOT_NULL, "IS NOT NULL":ComparisonOperator.IS_NOT_NULL ] static String getJoinOperatorString(JoinOperator op) { return JoinOperator.OR.is(op) ? "OR" : "AND" } static JoinOperator getJoinOperator(String opName) { return "or".equalsIgnoreCase(opName) ? JoinOperator.OR :JoinOperator.AND } static String getComparisonOperatorString(ComparisonOperator op) { return comparisonOperatorStringMap.get(op) } static ComparisonOperator getComparisonOperator(String opName) { if (opName == null) return ComparisonOperator.EQUALS ComparisonOperator co = stringComparisonOperatorMap.get(opName) return co != null ? co : ComparisonOperator.EQUALS } static boolean compareByOperator(Object value1, ComparisonOperator op, Object value2) { switch (op) { case ComparisonOperator.EQUALS: return value1 == value2 case ComparisonOperator.NOT_EQUAL: return value1 != value2 case ComparisonOperator.LESS_THAN: Comparable comp1 = ObjectUtilities.makeComparable(value1) Comparable comp2 = ObjectUtilities.makeComparable(value2) return comp1 < comp2 case ComparisonOperator.GREATER_THAN: Comparable comp1 = ObjectUtilities.makeComparable(value1) Comparable comp2 = ObjectUtilities.makeComparable(value2) return comp1 > comp2 case ComparisonOperator.LESS_THAN_EQUAL_TO: Comparable comp1 = ObjectUtilities.makeComparable(value1) Comparable comp2 = ObjectUtilities.makeComparable(value2) return comp1 <= comp2 case ComparisonOperator.GREATER_THAN_EQUAL_TO: Comparable comp1 = ObjectUtilities.makeComparable(value1) Comparable comp2 = ObjectUtilities.makeComparable(value2) return comp1 >= comp2 case ComparisonOperator.IN: if (value2 instanceof Collection) { return ((Collection) value2).contains(value1) } else { // not a Collection, try equals return value1 == value2 } case ComparisonOperator.NOT_IN: if (value2 instanceof Collection) { return !((Collection) value2).contains(value1) } else { // not a Collection, try not-equals return value1 != value2 } case ComparisonOperator.BETWEEN: if (value2 instanceof Collection && ((Collection) value2).size() == 2) { Comparable comp1 = ObjectUtilities.makeComparable(value1) Iterator iterator = ((Collection) value2).iterator() Comparable lowObj = ObjectUtilities.makeComparable(iterator.next()) Comparable highObj = ObjectUtilities.makeComparable(iterator.next()) return lowObj <= comp1 && comp1 < highObj } else { return false } case ComparisonOperator.NOT_BETWEEN: if (value2 instanceof Collection && ((Collection) value2).size() == 2) { Comparable comp1 = ObjectUtilities.makeComparable(value1) Iterator iterator = ((Collection) value2).iterator() Comparable lowObj = ObjectUtilities.makeComparable(iterator.next()) Comparable highObj = ObjectUtilities.makeComparable(iterator.next()) return lowObj > comp1 && comp1 >= highObj } else { return false } case ComparisonOperator.LIKE: return ObjectUtilities.compareLike(value1, value2) case ComparisonOperator.NOT_LIKE: return !ObjectUtilities.compareLike(value1, value2) case ComparisonOperator.IS_NULL: return value1 == null case ComparisonOperator.IS_NOT_NULL: return value1 != null } // default return false return false } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDataDocument.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.json.JsonOutput import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityException import org.moqui.entity.EntityFind import org.moqui.entity.EntityList import org.moqui.entity.EntityListIterator import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.condition.ConditionAlias import org.moqui.impl.entity.condition.ConditionField import org.moqui.impl.entity.condition.FieldValueCondition import org.moqui.util.CollectionUtilities import org.moqui.util.LiteStringMap import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicInteger @CompileStatic class EntityDataDocument { protected final static Logger logger = LoggerFactory.getLogger(EntityDataDocument.class) protected final EntityFacadeImpl efi EntityDataDocument(EntityFacadeImpl efi) { this.efi = efi } int writeDocumentsToFile(String filename, List dataDocumentIds, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) { File outFile = new File(filename) if (!outFile.createNewFile()) { efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists.','',[filename:filename])) return 0 } PrintWriter pw = new PrintWriter(outFile) pw.write("[\n") int valuesWritten = writeDocumentsToWriter(pw, dataDocumentIds, condition, fromUpdateStamp, thruUpdatedStamp, prettyPrint) pw.write("{}\n]\n") pw.close() efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} documents to file ${filename}','',[valuesWritten:valuesWritten,filename:filename])) return valuesWritten } int writeDocumentsToDirectory(String dirname, List dataDocumentIds, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) { File outDir = new File(dirname) if (!outDir.exists()) outDir.mkdir() if (!outDir.isDirectory()) { efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('Path ${dirname} is not a directory.','',[dirname:dirname])) return 0 } int valuesWritten = 0 for (String dataDocumentId in dataDocumentIds) { String filename = "${dirname}/${dataDocumentId}.json" File outFile = new File(filename) if (outFile.exists()) { efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists, skipping document ${dataDocumentId}.','',[filename:filename,dataDocumentId:dataDocumentId])) continue } outFile.createNewFile() PrintWriter pw = new PrintWriter(outFile) pw.write("[\n") valuesWritten += writeDocumentsToWriter(pw, [dataDocumentId], condition, fromUpdateStamp, thruUpdatedStamp, prettyPrint) pw.write("{}\n]\n") pw.close() efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}','',[valuesWritten:valuesWritten, filename:filename])) } return valuesWritten } int writeDocumentsToWriter(Writer pw, List dataDocumentIds, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, boolean prettyPrint) { if (dataDocumentIds == null || dataDocumentIds.size() == 0) return 0 int valuesWritten = 0 for (String dataDocumentId in dataDocumentIds) { ArrayList documentList = getDataDocuments(dataDocumentId, condition, fromUpdateStamp, thruUpdatedStamp) int docListSize = documentList.size() for (int i = 0; i < docListSize; i++) { if (valuesWritten > 0) pw.write(",\n") Map document = (Map) documentList.get(i) String json = JsonOutput.toJson(document) if (prettyPrint) { pw.write(JsonOutput.prettyPrint(json)) } else { pw.write(json) } valuesWritten++ } } if (valuesWritten > 0) pw.write("\n") return valuesWritten } static class DataDocumentInfo { String dataDocumentId EntityValue dataDocument EntityList dataDocumentFieldList EntityList dataDocumentRelAliasList EntityList dataDocumentConditionList String primaryEntityName EntityDefinition primaryEd ArrayList primaryPkFieldNames int primaryPkFieldNamesSize Map fieldTree = [:] Map fieldAliasPathMap = [:] Map relationshipAliasMap = [:] boolean hasExpressionField = false boolean hasAllPrimaryPks = true EntityDefinition entityDef DataDocumentInfo(String dataDocumentId, EntityFacadeImpl efi) { this.dataDocumentId = dataDocumentId dataDocument = efi.fastFindOne("moqui.entity.document.DataDocument", true, false, dataDocumentId) if (dataDocument == null) throw new EntityException("No DataDocument found with ID ${dataDocumentId}") dataDocumentFieldList = dataDocument.findRelated("moqui.entity.document.DataDocumentField", null, ['sequenceNum', 'fieldPath'], true, false) dataDocumentRelAliasList = dataDocument.findRelated("moqui.entity.document.DataDocumentRelAlias", null, null, true, false) dataDocumentConditionList = dataDocument.findRelated("moqui.entity.document.DataDocumentCondition", null, null, true, false) for (int rai = 0; rai < dataDocumentRelAliasList.size(); rai++) { EntityValue dataDocumentRelAlias = (EntityValue) dataDocumentRelAliasList.get(rai) relationshipAliasMap.put((String) dataDocumentRelAlias.getNoCheckSimple("relationshipName"), (String) dataDocumentRelAlias.getNoCheckSimple("documentAlias")) } primaryEntityName = (String) dataDocument.getNoCheckSimple("primaryEntityName") primaryEd = efi.getEntityDefinition(primaryEntityName) primaryPkFieldNames = primaryEd.getPkFieldNames() primaryPkFieldNamesSize = primaryPkFieldNames.size() AtomicBoolean hasExprMut = new AtomicBoolean(false) populateFieldTreeAndAliasPathMap(dataDocumentFieldList, primaryPkFieldNames, fieldTree, fieldAliasPathMap, hasExprMut, false) hasExpressionField = hasExprMut.get() for (int pki = 0; pki < primaryPkFieldNames.size(); pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) if (!fieldAliasPathMap.containsKey(pkFieldName)) { hasAllPrimaryPks = false break } } EntityDynamicViewImpl dynamicView = new EntityDynamicViewImpl(efi) dynamicView.entityNode.attributes.put("package", "DataDocument") dynamicView.entityNode.attributes.put("entity-name", dataDocumentId) // add member entities and field aliases to dynamic view dynamicView.addMemberEntity("PRIM", primaryEntityName, null, null, null) AtomicInteger incrementer = new AtomicInteger() fieldTree.put("_ALIAS", "PRIM") addDataDocRelatedEntity(dynamicView, "PRIM", fieldTree, incrementer, makeDdfByAlias(dataDocumentFieldList)) // logger.warn("=========== ${dataDocumentId} fieldTree=${fieldTree}") // logger.warn("=========== ${dataDocumentId} fieldAliasPathMap=${fieldAliasPathMap}") entityDef = dynamicView.makeEntityDefinition() } String makeDocId(EntityValue ev) { if (primaryPkFieldNamesSize == 1) { // optimization for common simple case String pkFieldName = (String) primaryPkFieldNames.get(0) Object pkFieldValue = ev.getNoCheckSimple(pkFieldName) return ObjectUtilities.toPlainString(pkFieldValue) } else { StringBuilder pkCombinedSb = new StringBuilder() for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) // don't do this, always use full PK even if not all aliased in doc, probably a bad DataDocument definition: if (!fieldAliasPathMap.containsKey(pkFieldName)) continue if (pkCombinedSb.length() > 0) pkCombinedSb.append("::") Object pkFieldValue = ev.getNoCheckSimple(pkFieldName) pkCombinedSb.append(ObjectUtilities.toPlainString(pkFieldValue)) } return pkCombinedSb.toString() } } } EntityDefinition makeEntityDefinition(String dataDocumentId) { DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi) return ddi.entityDef } EntityFind makeDataDocumentFind(String dataDocumentId) { DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi) return makeDataDocumentFind(ddi, null, null) } EntityFind makeDataDocumentFind(DataDocumentInfo ddi, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) { // build the query condition for the primary entity and all related entities EntityDefinition ed = ddi.entityDef EntityFind mainFind = ed.makeEntityFind() // add conditions if (ddi.dataDocumentConditionList != null && ddi.dataDocumentConditionList.size() > 0) { ExecutionContextImpl eci = efi.ecfi.getEci() int dataDocumentConditionListSize = ddi.dataDocumentConditionList.size() for (int ddci = 0; ddci < dataDocumentConditionListSize; ddci++) { EntityValue dataDocumentCondition = (EntityValue) ddi.dataDocumentConditionList.get(ddci) String fieldAlias = (String) dataDocumentCondition.getNoCheckSimple("fieldNameAlias") FieldInfo fi = ed.getFieldInfo(fieldAlias) if (fi == null) throw new EntityException("Found DataDocument Condition with alias [${fieldAlias}] that is not aliased in DataDocument ${ddi.dataDocumentId}") if (dataDocumentCondition.getNoCheckSimple("postQuery") != "Y") { String operator = ((String) dataDocumentCondition.getNoCheckSimple("operator")) ?: 'equals' String toFieldAlias = (String) dataDocumentCondition.getNoCheckSimple("toFieldNameAlias") if (toFieldAlias != null && !toFieldAlias.isEmpty()) { mainFind.conditionToField(fieldAlias, EntityConditionFactoryImpl.stringComparisonOperatorMap.get(operator), toFieldAlias) } else { String stringVal = (String) dataDocumentCondition.getNoCheckSimple("fieldValue") Object objVal = fi.convertFromString(stringVal, eci.l10nFacade) mainFind.condition(fieldAlias, operator, objVal) } } } } // create a condition with an OR list of date range comparisons to check that at least one member-entity has lastUpdatedStamp in range if ((Object) fromUpdateStamp != null || (Object) thruUpdatedStamp != null) { List dateRangeOrCondList = [] for (MNode memberEntityNode in ed.entityNode.children("member-entity")) { ConditionField ludCf = new ConditionAlias(memberEntityNode.attribute("entity-alias"), "lastUpdatedStamp", efi.getEntityDefinition(memberEntityNode.attribute("entity-name"))) List dateRangeFieldCondList = [] if ((Object) fromUpdateStamp != null) { dateRangeFieldCondList.add(efi.getConditionFactory().makeCondition( new FieldValueCondition(ludCf, EntityCondition.EQUALS, null), EntityCondition.OR, new FieldValueCondition(ludCf, EntityCondition.GREATER_THAN_EQUAL_TO, fromUpdateStamp))) } if ((Object) thruUpdatedStamp != null) { dateRangeFieldCondList.add(efi.getConditionFactory().makeCondition( new FieldValueCondition(ludCf, EntityCondition.EQUALS, null), EntityCondition.OR, new FieldValueCondition(ludCf, EntityCondition.LESS_THAN, thruUpdatedStamp))) } dateRangeOrCondList.add(efi.getConditionFactory().makeCondition(dateRangeFieldCondList, EntityCondition.AND)) } mainFind.condition(efi.getConditionFactory().makeCondition(dateRangeOrCondList, EntityCondition.OR)) } // use a read only clone if available, this always runs async or for reporting anyway mainFind.useClone(true) // logger.warn("=========== DataDocument query condition for ${dataDocumentId} mainFind.condition=${((EntityFindImpl) mainFind).getWhereEntityCondition()}\n${mainFind.toString()}") return mainFind } /** Build data document Maps from DB data, feed in batches to specified service. This is called from the SearchServices.index#DataFeedDocuments service */ int feedDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp, String feedReceiveServiceName, Integer batchSizeOvd) { if (feedReceiveServiceName == null || feedReceiveServiceName.isEmpty()) { logger.warn("In feedDataDocuments no feed receive service name specified, not searching and feeding ${dataDocumentId} documents") return 0 } int batchSize = batchSizeOvd != null ? batchSizeOvd.intValue() : 1000 logger.info("Feeding data documents for dataDocumentId ${dataDocumentId} in batches of ${batchSize} to service ${feedReceiveServiceName}") DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi) long startTimeMillis = System.currentTimeMillis() Timestamp docTimestamp = thruUpdatedStamp != (Timestamp) null ? thruUpdatedStamp : new Timestamp(startTimeMillis) String docTsString = docTimestamp.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT) boolean hasAllPrimaryPks = ddi.hasAllPrimaryPks if (!hasAllPrimaryPks) logger.warn("DataDocument ${dataDocumentId} does not have all primary keys for feed to service ${feedReceiveServiceName}") Map documentMapMap = hasAllPrimaryPks ? new LinkedHashMap(batchSize + 10) : null ArrayList documentMapList = hasAllPrimaryPks ? null : new ArrayList(batchSize + 10) EntityFind mainFind = makeDataDocumentFind(ddi, fromUpdateStamp, thruUpdatedStamp) if (condition != null) mainFind.condition(condition) // for this to work sort by primary key fields (of primary entity) so all records for a given document are together mainFind.orderBy(ddi.primaryPkFieldNames) // do the one big query String lastDocId = null int docCount = 0 try (EntityListIterator mainEli = mainFind.iterator()) { logger.info("Feed dataDocumentId ${dataDocumentId} query complete (cursor opened) in ${System.currentTimeMillis() - startTimeMillis}ms") EntityValue ev while ((ev = (EntityValue) mainEli.next()) != null) { String curDocId = ddi.makeDocId(ev) if (!curDocId.equals(lastDocId)) { docCount++ // index the batch if time to, with sort by PK fields when we get a new combined doc ID // we are in results between documents (single document often has multiple rows) int docsSoFar = hasAllPrimaryPks ? documentMapMap.size() : documentMapList.size() if (docsSoFar >= batchSize) { // logger.warn("curDocId ${curDocId} lastDocId ${lastDocId}") if (hasAllPrimaryPks) { documentMapList = new ArrayList<>(documentMapMap.values()) } postProcessDocMapList(documentMapList, ddi) // call the feed receive service efi.ecfi.serviceFacade.sync().name(feedReceiveServiceName).parameter("documentList", documentMapList) .noRememberParameters().call() // stop if there was an error if (efi.ecfi.getEci().messageFacade.hasError()) break documentMapMap = hasAllPrimaryPks ? new LinkedHashMap(batchSize + 10) : null documentMapList = hasAllPrimaryPks ? null : new ArrayList(batchSize + 10) } } // continue current doc or ready to move on to next doc, merge the current result lastDocId = mergeValueToDocMap(ev, ddi, documentMapMap, documentMapList, docTsString) } // feed remaining documents if (documentMapMap != null && documentMapMap.size() > 0) { documentMapList = new ArrayList<>(documentMapMap.values()) } if (documentMapList != null && documentMapList.size() > 0) { postProcessDocMapList(documentMapList, ddi) // call the feed receive service efi.ecfi.serviceFacade.sync().name(feedReceiveServiceName).parameter("documentList", documentMapList).call() } } finally { logger.info("Feed dataDocumentId ${dataDocumentId} feed complete and cursor closed in ${System.currentTimeMillis() - startTimeMillis}ms") } logger.info("Fed ${docCount} data documents for dataDocumentId ${dataDocumentId} to service ${feedReceiveServiceName}") return docCount } ArrayList getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) { DataDocumentInfo ddi = new DataDocumentInfo(dataDocumentId, efi) EntityFind mainFind = makeDataDocumentFind(ddi, fromUpdateStamp, thruUpdatedStamp) if (condition != null) mainFind.condition(condition) Timestamp docTimestamp = thruUpdatedStamp != (Timestamp) null ? thruUpdatedStamp : new Timestamp(System.currentTimeMillis()) String docTsString = docTimestamp.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT) Map documentMapMap = ddi.hasAllPrimaryPks ? new LinkedHashMap() : null ArrayList documentMapList = ddi.hasAllPrimaryPks ? null : new ArrayList() // do the one big query mainFind.iterator().withCloseable ({mainEli-> EntityValue ev while ((ev = (EntityValue) mainEli.next()) != null) { // logger.warn("=========== DataDocument query result for ${dataDocumentId}: ${ev}") mergeValueToDocMap(ev, ddi, documentMapMap, documentMapList, docTsString) } }) // make the actual list and return it if (ddi.hasAllPrimaryPks) { documentMapList = new ArrayList<>(documentMapMap.size()) documentMapList.addAll(documentMapMap.values()) } postProcessDocMapList(documentMapList, ddi) return documentMapList } String mergeValueToDocMap(EntityValue ev, DataDocumentInfo ddi, Map documentMapMap, ArrayList documentMapList, String docTsString) { /* - _index = DataDocument.indexName - _type = dataDocumentId - _id = pk field values from primary entity, double colon separated - _timestamp = document created time - Map for primary entity with primaryEntityName as key - nested List of Maps for each related entity with aliased fields with relationship name as key */ String docId = ddi.makeDocId(ev) // logger.warn("DataDoc record PKs string: " + docId) Map docMap = ddi.hasAllPrimaryPks ? ((Map) documentMapMap.get(docId)) : (Map) null if (docMap == null) { // add special entries docMap = new LiteStringMap() docMap.put("_type", ddi.dataDocumentId) if (docId != null && !docId.isEmpty()) docMap.put("_id", docId) docMap.put('_timestamp', docTsString) String _index = ddi.dataDocument.indexName if (_index != null && !_index.isEmpty()) docMap.put('_index', _index.toLowerCase()) docMap.put('_entity', ddi.primaryEd.getShortOrFullEntityName()) // add Map for primary entity for (Map.Entry fieldTreeEntry in ddi.fieldTree.entrySet()) { Object entryValue = fieldTreeEntry.getValue() // if ("_ALIAS".equals(fieldTreeEntry.getKey())) continue if (entryValue instanceof ArrayList) { String fieldEntryKey = fieldTreeEntry.getKey() if (fieldEntryKey.startsWith("(")) continue ArrayList fieldAliasList = (ArrayList) entryValue for (int i = 0; i < fieldAliasList.size(); i++) { String fieldAlias = (String) fieldAliasList.get(i) Object curVal = ev.get(fieldAlias) if (curVal != null) docMap.put(fieldAlias, curVal) } } } if (ddi.hasAllPrimaryPks) documentMapMap.put(docId, docMap) else documentMapList.add(docMap) } // recursively add Map or List of Maps for each related entity populateDataDocRelatedMap(ev, docMap, ddi.primaryEd, ddi.fieldTree, ddi.relationshipAliasMap, false) return docId } void postProcessDocMapList(ArrayList documentMapList, DataDocumentInfo ddi) { String manualDataServiceName = (String) ddi.dataDocument.getNoCheckSimple("manualDataServiceName") // NOTE: have to get size() each time in case records are removed for (int i = 0; i < documentMapList.size(); ) { Map docMap = (Map) documentMapList.get(i) // call the manualDataServiceName service for each document if (manualDataServiceName != null && !manualDataServiceName.isEmpty()) { // logger.warn("Calling ${manualDataServiceName} with doc: ${docMap}") Map result = efi.ecfi.serviceFacade.sync().name(manualDataServiceName) .parameter("dataDocumentId", ddi.dataDocumentId).parameter("document", docMap).call() if (result == null || efi.ecfi.getEci().messageFacade.hasError()) { logger.error("Error calling manual data service for ${ddi.dataDocumentId}, document may be missing data: ${efi.ecfi.getEci().messageFacade.getErrorsString()}") efi.ecfi.getEci().messageFacade.clearErrors() } else { Map outDoc = (Map) result.get("document") if (outDoc != null && outDoc.size() > 0) { docMap = outDoc documentMapList.set(i, docMap) } } } // evaluate expression fields if (ddi.hasExpressionField) { runDocExpressions(docMap, null, ddi.primaryEd, ddi.fieldTree, ddi.relationshipAliasMap) } // check postQuery conditions boolean allPassed = true int dataDocumentConditionListSize = ddi.dataDocumentConditionList.size() for (int ddci = 0; ddci < dataDocumentConditionListSize; ddci++) { EntityValue dataDocumentCondition = (EntityValue) ddi.dataDocumentConditionList.get(ddci) if ("Y".equals(dataDocumentCondition.postQuery)) { Set valueSet = new HashSet() CollectionUtilities.findAllFieldsNestedMap((String) dataDocumentCondition.getNoCheckSimple("fieldNameAlias"), docMap, valueSet) if (valueSet.size() == 0) { if (!dataDocumentCondition.getNoCheckSimple("fieldValue")) { continue } else { allPassed = false; break } } if (!dataDocumentCondition.getNoCheckSimple("fieldValue")) { allPassed = false; break } Object fieldValueObj = dataDocumentCondition.getNoCheckSimple("fieldValue").asType(valueSet.first().class) if (!(fieldValueObj in valueSet)) { allPassed = false; break } } } if (allPassed) { i++ } else { documentMapList.remove(i) } } } static ArrayList fieldPathToList(String fieldPath) { int openParenIdx = fieldPath.indexOf("(") ArrayList fieldPathElementList = new ArrayList<>() if (openParenIdx == -1) { Collections.addAll(fieldPathElementList, fieldPath.split(":")) } else { if (openParenIdx > 0) { // should end with a colon so subtract 1 String preParen = fieldPath.substring(0, openParenIdx - 1) Collections.addAll(fieldPathElementList, preParen.split(":")) fieldPathElementList.add(fieldPath.substring(openParenIdx)) } else { fieldPathElementList.add(fieldPath) } } return fieldPathElementList } static void populateFieldTreeAndAliasPathMap(EntityList dataDocumentFieldList, List primaryPkFieldNames, Map fieldTree, Map fieldAliasPathMap, AtomicBoolean hasExprMut, boolean allPks) { for (EntityValue dataDocumentField in dataDocumentFieldList) { String fieldPath = (String) dataDocumentField.getNoCheckSimple("fieldPath") ArrayList fieldPathElementList = fieldPathToList(fieldPath) Map currentTree = fieldTree int fieldPathElementListSize = fieldPathElementList.size() for (int i = 0; i < fieldPathElementListSize; i++) { String fieldPathElement = (String) fieldPathElementList.get(i) if (i < (fieldPathElementListSize - 1)) { Map subTree = (Map) currentTree.get(fieldPathElement) if (subTree == null) { subTree = [:]; currentTree.put(fieldPathElement, subTree) } currentTree = subTree } else { String fieldAlias = ((String) dataDocumentField.getNoCheckSimple("fieldNameAlias")) ?: fieldPathElement CollectionUtilities.addToListInMap(fieldPathElement, fieldAlias, currentTree) fieldAliasPathMap.put(fieldAlias, fieldPath) if (fieldPathElement.startsWith("(")) hasExprMut.set(true) } } } // make sure all PK fields of the primary entity are aliased if (allPks) { for (String pkFieldName in primaryPkFieldNames) if (!fieldAliasPathMap.containsKey(pkFieldName)) { fieldTree.put(pkFieldName, pkFieldName) fieldAliasPathMap.put(pkFieldName, pkFieldName) } } } protected void runDocExpressions(Map curDocMap, Map parentsMap, EntityDefinition parentEd, Map fieldTreeCurrent, Map relationshipAliasMap) { for (Map.Entry fieldTreeEntry in fieldTreeCurrent.entrySet()) { String fieldEntryKey = fieldTreeEntry.getKey() Object fieldEntryValue = fieldTreeEntry.getValue() if (fieldEntryValue instanceof Map) { String relationshipName = fieldEntryKey Map fieldTreeChild = (Map) fieldEntryValue EntityJavaUtil.RelationshipInfo relationshipInfo = parentEd.getRelationshipInfo(relationshipName) String relDocumentAlias = relationshipAliasMap.get(relationshipName) ?: relationshipInfo.shortAlias ?: relationshipName EntityDefinition relatedEd = relationshipInfo.relatedEd boolean isOneRelationship = relationshipInfo.isTypeOne if (isOneRelationship) { runDocExpressions(curDocMap, parentsMap, relatedEd, fieldTreeChild, relationshipAliasMap) } else { List relatedEntityDocList = (List) curDocMap.get(relDocumentAlias) if (relatedEntityDocList != null) for (Map childMap in relatedEntityDocList) { Map newParentsMap if (parentsMap != null) { newParentsMap = new HashMap(parentsMap) newParentsMap.putAll(curDocMap) } else { newParentsMap = curDocMap } runDocExpressions(childMap, newParentsMap, relatedEd, fieldTreeChild, relationshipAliasMap) } } } else if (fieldEntryValue instanceof ArrayList) { if (fieldEntryKey.startsWith("(")) { // run expression to get value, set for all aliases (though will always be one) Map evalMap if (parentsMap != null) { evalMap = new HashMap(parentsMap) evalMap.putAll(curDocMap) } else { evalMap = curDocMap } try { Object curVal = efi.ecfi.resourceFacade.expression(fieldEntryKey, null, evalMap) if (curVal != null) { ArrayList fieldAliasList = (ArrayList) fieldEntryValue for (int i = 0; i < fieldAliasList.size(); i++) { String fieldAlias = (String) fieldAliasList.get(i) if (curVal != null) curDocMap.put(fieldAlias, curVal) } } } catch (Throwable t) { logger.error("Error evaluating DataDocumentField expression: ${fieldEntryKey}", t) } } } } } protected void populateDataDocRelatedMap(EntityValue ev, Map parentDocMap, EntityDefinition parentEd, Map fieldTreeCurrent, Map relationshipAliasMap, boolean setFields) { for (Map.Entry fieldTreeEntry in fieldTreeCurrent.entrySet()) { String fieldEntryKey = fieldTreeEntry.getKey() Object fieldEntryValue = fieldTreeEntry.getValue() // if ("_ALIAS".equals(fieldEntryKey)) continue if (fieldEntryValue instanceof Map) { String relationshipName = fieldEntryKey Map fieldTreeChild = (Map) fieldEntryValue EntityJavaUtil.RelationshipInfo relationshipInfo = parentEd.getRelationshipInfo(relationshipName) String relDocumentAlias = relationshipAliasMap.get(relationshipName) ?: relationshipInfo.shortAlias ?: relationshipName EntityDefinition relatedEd = relationshipInfo.relatedEd boolean isOneRelationship = relationshipInfo.isTypeOne if (isOneRelationship) { // we only need a single Map populateDataDocRelatedMap(ev, parentDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, true) } else { // we need a List of Maps Map relatedEntityDocMap = (Map) null // see if there is a Map in the List in the matching entry List relatedEntityDocList = (List) parentDocMap.get(relDocumentAlias) if (relatedEntityDocList != null) { for (Map candidateMap in relatedEntityDocList) { boolean allMatch = true for (Map.Entry fieldTreeChildEntry in fieldTreeChild.entrySet()) { Object entryValue = fieldTreeChildEntry.getValue() if (entryValue instanceof ArrayList && !fieldTreeChildEntry.getKey().startsWith("(")) { ArrayList fieldAliasList = (ArrayList) entryValue for (int i = 0; i < fieldAliasList.size(); i++) { String fieldAlias = (String) fieldAliasList.get(i) if (candidateMap.get(fieldAlias) != ev.get(fieldAlias)) { allMatch = false break } } } } if (allMatch) { relatedEntityDocMap = candidateMap break } } } if (relatedEntityDocMap == null) { // no matching Map? create a new one... and it will get populated in the recursive call relatedEntityDocMap = new LiteStringMap() // now time to recurse populateDataDocRelatedMap(ev, relatedEntityDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, true) if (relatedEntityDocMap.size() > 0) { if (relatedEntityDocList == null) { // use ArrayList internally, avoid new object per entry with LinkedList relatedEntityDocList = new ArrayList<>() parentDocMap.put(relDocumentAlias, relatedEntityDocList) } relatedEntityDocList.add(relatedEntityDocMap) } } else { // now time to recurse populateDataDocRelatedMap(ev, relatedEntityDocMap, relatedEd, fieldTreeChild, relationshipAliasMap, false) } } } else if (fieldEntryValue instanceof ArrayList) { if (setFields && !fieldEntryKey.startsWith("(")) { // set the field(s) ArrayList fieldAliasList = (ArrayList) fieldEntryValue for (int i = 0; i < fieldAliasList.size(); i++) { String fieldAlias = (String) fieldAliasList.get(i) Object curVal = ev.get(fieldAlias) if (curVal != null) parentDocMap.put(fieldAlias, curVal) } } } } } private static Map makeDdfByAlias(EntityList dataDocumentFieldList) { Map ddfByAlias = new HashMap<>() int ddfSize = dataDocumentFieldList.size() for (int i = 0; i < ddfSize; i++) { EntityValue ddf = (EntityValue) dataDocumentFieldList.get(i) String alias = (String) ddf.getNoCheckSimple("fieldNameAlias") if (alias == null || alias.isEmpty()) { String fieldPath = (String) ddf.getNoCheckSimple("fieldPath") ArrayList fieldPathElementList = fieldPathToList(fieldPath) alias = (String) fieldPathElementList.get(fieldPathElementList.size() - 1) } ddfByAlias.put(alias, ddf) } return ddfByAlias } private static void addDataDocRelatedEntity(EntityDynamicViewImpl dynamicView, String parentEntityAlias, Map fieldTreeCurrent, AtomicInteger incrementer, Map ddfByAlias) { for (Map.Entry fieldTreeEntry in fieldTreeCurrent.entrySet()) { String fieldEntryKey = (String) fieldTreeEntry.getKey() if ("_ALIAS".equals(fieldEntryKey)) continue Object entryValue = fieldTreeEntry.getValue() if (entryValue instanceof Map) { Map fieldTreeChild = (Map) entryValue // add member entity, and entity alias in "_ALIAS" entry String entityAlias = "MBR" + incrementer.getAndIncrement() dynamicView.addRelationshipMember(entityAlias, parentEntityAlias, fieldEntryKey, true) fieldTreeChild.put("_ALIAS", entityAlias) // now time to recurse addDataDocRelatedEntity(dynamicView, entityAlias, fieldTreeChild, incrementer, ddfByAlias) } else if (entryValue instanceof ArrayList) { // add alias for field String entityAlias = fieldTreeCurrent.get("_ALIAS") ArrayList fieldAliasList = (ArrayList) entryValue for (int i = 0; i < fieldAliasList.size(); i++) { String fieldAlias = (String) fieldAliasList.get(i) EntityValue ddf = ddfByAlias.get(fieldAlias) if (ddf == null) throw new EntityException("Could not find DataDocumentField for field alias ${fieldEntryKey}") String defaultDisplay = ddf.getNoCheckSimple("defaultDisplay") if (fieldEntryKey.startsWith("(")) { // handle expressions differently, expressions have to be meant for this but nice for various cases // TODO: somehow specify type, yet another new field on DataDocumentField entity? for now defaulting to 'text-long' dynamicView.addPqExprAlias(fieldAlias, fieldEntryKey, "text-long", "N".equals(defaultDisplay) ? "false" : ("Y".equals(defaultDisplay) ? "true" : null)) } else { dynamicView.addAlias(entityAlias, fieldAlias, fieldEntryKey, (String) ddf.getNoCheckSimple("functionName"), "N".equals(defaultDisplay) ? "false" : ("Y".equals(defaultDisplay) ? "true" : null)) } } } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDataFeed.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityException import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.jcache.MCache import javax.cache.Cache import jakarta.transaction.Status import jakarta.transaction.Synchronization import jakarta.transaction.Transaction import jakarta.transaction.TransactionManager import javax.transaction.xa.XAException import java.sql.Timestamp import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.RejectedExecutionException @CompileStatic class EntityDataFeed { protected final static Logger logger = LoggerFactory.getLogger(EntityDataFeed.class) protected final EntityFacadeImpl efi protected final MCache> dataFeedEntityInfo Set entitiesWithDataFeed = null EntityDataFeed(EntityFacadeImpl efi) { this.efi = efi dataFeedEntityInfo = efi.ecfi.cacheFacade.getLocalCache("entity.data.feed.info") } EntityFacadeImpl getEfi() { return efi } /** This method gets the latest documents for a DataFeed based on DataFeed.lastFeedStamp, and updates lastFeedStamp * to the current time. This method should be called in a service or something to manage the transaction. * See the org.moqui.impl.EntityServices.get#DataFeedLatestDocuments service.*/ List getFeedLatestDocuments(String dataFeedId) { EntityValue dataFeed = efi.find("moqui.entity.feed.DataFeed").condition("dataFeedId", dataFeedId) .useCache(false).forUpdate(true).one() Timestamp fromUpdateStamp = dataFeed.getTimestamp("lastFeedStamp") Timestamp thruUpdateStamp = new Timestamp(System.currentTimeMillis()) // get the List first, if no errors update lastFeedStamp List documentList = getFeedDocuments(dataFeedId, fromUpdateStamp, thruUpdateStamp) dataFeed.lastFeedStamp = thruUpdateStamp dataFeed.update() return documentList } ArrayList getFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) { EntityList dataFeedDocumentList = efi.find("moqui.entity.feed.DataFeedDocument") .condition("dataFeedId", dataFeedId).useCache(true).list() ArrayList fullDocumentList = new ArrayList<>() for (EntityValue dataFeedDocument in dataFeedDocumentList) { String dataDocumentId = dataFeedDocument.dataDocumentId ArrayList curDocList = efi.getDataDocuments(dataDocumentId, null, fromUpdateStamp, thruUpdatedStamp) fullDocumentList.addAll(curDocList) } return fullDocumentList } /* Notes for real-time push DataFeed: - doing update on entity have entityNames updated, for each fieldNames updated, field values (query as needed based on actual conditions if any conditions on fields not present in EntityValue - do this based on a committed transaction of changes, not just on a single record... - keep data for documents to include until transaction committed to quickly lookup DataDocuments updated with a corresponding real time (DTFDTP_RT_PUSH) DataFeed need: - don't have to constrain by real time DataFeed, will be done in advance for index - Map with entityName as key - value is List of Map with: - dataFeedId - List of DocumentEntityInfo objects with - dataDocumentId - Set of fields for DataDocument and the current entity - primaryEntityName - relationship path from primary to current entity - Map of field conditions for current entity - and for entire document? no, false positives filtered out when doc data queried - find with query on DataFeed and DataFeedDocument where DataFeed.dataFeedTypeEnumId=DTFDTP_RT_PUSH - iterate through dataDocumentId and call getDataDocumentEntityInfo() for each to produce the document with zero or minimal query - during transaction save all created or updated records in EntityList updatedList (in EntityFacadeImpl?) - EntityValues added to the list only if they are in the - once we have dataDocumentIdSet use to lookup all DTFDTP_RT_PUSH DataFeed with a matching DataFeedDocument record - look up primary entity value for the current updated value and use its PK fields as a condition to call getDataDocuments() so that we get a document for just the updated record(s) */ void dataFeedCheckAndRegister(EntityValue ev, boolean isUpdate, Map valueMap, Map oldValues) { boolean shouldLogDetail = false // if (ev.resolveEntityName().startsWith("WikiPage")) logger.warn("============== DataFeed checking entity isModified=${ev.isModified()} [${ev.resolveEntityName()}] value: ${ev}") if (shouldLogDetail) logger.warn("======= dataFeedCheckAndRegister update? ${isUpdate} mod? ${ev.isModified()}\nev: ${ev}\noldValues=${oldValues}") // if the value isn't modified don't register for DataFeed at all if (!ev.isModified()) { if (shouldLogDetail) logger.warn("Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, is not modified") return } if (isUpdate && oldValues == null) { if (shouldLogDetail) logger.warn("Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, isUpdate and oldValues is null") return } // see if this should be added to the feed ArrayList entityInfoList try { entityInfoList = getDataFeedEntityInfoList(ev.resolveEntityName()) } catch (Throwable t) { logger.error("Error getting DataFeed entity info, not registering value for entity ${ev.resolveEntityName()}", t) return } if (shouldLogDetail) logger.warn("======= dataFeedCheckAndRegister ${ev.resolveEntityName()} entityInfoList size ${entityInfoList.size()}") if (entityInfoList.size() > 0) { // logger.warn("============== found registered entity [${ev.resolveEntityName()}] value: ${ev}") // populate and pass the dataDocumentIdSet, and/or other things needed? Set dataDocumentIdSet = new HashSet() for (DocumentEntityInfo entityInfo in entityInfoList) { // only add value if a field in the document was changed boolean fieldModified = false for (String fieldName in entityInfo.fields) { // logger.warn("DataFeed ${entityInfo.dataDocumentId} check field ${fieldName} isUpdate ${isUpdate} isFieldModified ${ev.isFieldModified(fieldName)} value ${valueMap.get(fieldName)} oldValue ${oldValues?.get(fieldName)}") if (ev.isFieldModified(fieldName)) { fieldModified = true; break } if (!valueMap.containsKey(fieldName)) continue Object value = valueMap.get(fieldName) Object oldValue = oldValues?.get(fieldName) // logger.warn("DataFeed ${entityInfo.dataDocumentId} check field ${fieldName} isUpdate ${isUpdate} value ${value} oldValue ${oldValue} continue ${(isUpdate && value == oldValue) || (!isUpdate && value == null)}") // if isUpdate but old value == new value, then it hasn't been updated, so skip it if (isUpdate && value == oldValue) continue // if it's a create and there is no value don't log a change if (!isUpdate && value == null) continue fieldModified = true } if (!fieldModified) continue // only add value and dataDocumentId if there are no conditions or if this record matches all conditions // (not necessary, but this is an optimization to avoid false positives) boolean matchedConditions = true if (entityInfo.conditions) for (Map.Entry conditionEntry in entityInfo.conditions.entrySet()) { Object evValue = ev.get(conditionEntry.getKey()) // if ev doesn't have field populated, ignore the condition; we'll pick it up later in the big document query if (evValue == null) continue if (evValue != conditionEntry.getValue()) { matchedConditions = false; break } } if (!matchedConditions) continue // if we get here field(s) were modified and condition(s) passed dataDocumentIdSet.add(entityInfo.dataDocumentId) } if (!dataDocumentIdSet.isEmpty()) { // logger.warn("============== DataFeed registering entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}") // NOTE: comment out this line to disable real-time push DataFeed in one simple place: getDataFeedSynchronization().addValueToFeed(ev, dataDocumentIdSet) } else if (shouldLogDetail) { logger.warn("Not registering ${ev.resolveEntityName()} PK ${ev.getPrimaryKeys()}, dataDocumentIdSet is empty") } } } void dataFeedCheckDelete(EntityValue ev) { String entityName = ev.resolveEntityName() if (entityName == null || entityName.isEmpty()) { logger.error("Tried to do data feed delete with no entity name for ev: ${ev.toString()}") return } if (!ev.containsPrimaryKey()) { logger.error("Tried to do data feed delete with missing PK field values, ev: ${ev.toString()}") return } // is this entity in any feeds? ArrayList entityInfoList try { entityInfoList = getDataFeedEntityInfoList(ev.resolveEntityName()) } catch (Throwable t) { logger.error("Error getting DataFeed entity info, not registering delete for entity ${ev.resolveEntityName()}", t) return } if (entityInfoList.size() > 0) { // for each DataDocument if is the primary entity then delete, otherwise update (regenerate) Set updateDocumentIdSet = new HashSet() Set deleteDocumentIdSet = new HashSet() for (DocumentEntityInfo entityInfo in entityInfoList) { if (entityName.equals(entityInfo.primaryEntityName)) { // need to delete the DataDocument deleteDocumentIdSet.add(entityInfo.dataDocumentId) } else { // need to update the DataDocument updateDocumentIdSet.add(entityInfo.dataDocumentId) } } DataFeedSynchronization dfs = getDataFeedSynchronization() if (!updateDocumentIdSet.isEmpty()) { // logger.warn("============== DataFeed registering UPDATE entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}") dfs.addValueToFeed(ev, updateDocumentIdSet) } if (!deleteDocumentIdSet.isEmpty()) { // logger.warn("============== DataFeed registering DELETE entity value [${ev.resolveEntityName()}] value: ${ev.getPrimaryKeys()}") dfs.addDeleteToFeed(ev) } } } protected DataFeedSynchronization getDataFeedSynchronization() { DataFeedSynchronization dfxr = (DataFeedSynchronization) efi.ecfi.transactionFacade.getActiveSynchronization("DataFeedSynchronization") if (dfxr == null) { dfxr = new DataFeedSynchronization(this) dfxr.enlist() } return dfxr } final Set dataFeedSkipEntities = new HashSet(['moqui.entity.SequenceValueItem']) protected final static ArrayList emptyList = new ArrayList() // NOTE: this is called frequently (every create/update/delete) ArrayList getDataFeedEntityInfoList(String fullEntityName) { // see if this is a known entity in a feed // NOTE: this avoids issues with false negatives from the cache or excessive rebuilds (for every entity the // first time) but means if an entity is added to a DataDocument at runtime it won't pick it up!!!! // NOTE2: this could be cleared explicitly when a DataDocument is added or changed, but that is done through // direct DB stuff now (data load, etc), there is no UI or services for it if (entitiesWithDataFeed == null) rebuildDataFeedEntityInfo() if (!entitiesWithDataFeed.contains(fullEntityName)) return emptyList ArrayList cachedList = (ArrayList) dataFeedEntityInfo.get(fullEntityName) if (cachedList != null) return cachedList // if this is an entity to skip, return now (do after primary lookup to avoid additional performance overhead in common case) if (dataFeedSkipEntities.contains(fullEntityName)) { dataFeedEntityInfo.put(fullEntityName, emptyList) return emptyList } // logger.warn("=============== getting DocumentEntityInfo for [${fullEntityName}], from cache: ${entityInfoList}") // MAYBE (often causes issues): only rebuild if the cache is empty, most entities won't have any entry in it and don't want a rebuild for each one rebuildDataFeedEntityInfo() // now we should have all document entityInfos for all entities cachedList = (ArrayList) dataFeedEntityInfo.get(fullEntityName) if (cachedList != null) return cachedList // remember that we don't have any info dataFeedEntityInfo.put(fullEntityName, emptyList) return emptyList } // this should never be called except through getDataFeedEntityInfoList() private long lastRebuildTime = 0 protected synchronized void rebuildDataFeedEntityInfo() { // under load make sure waiting threads don't redo it, give it some time // NOTE: no other good way to limit this, cache entries may expire individually so we can't check to see if any are missing without a full reload if (dataFeedEntityInfo.size() > 0 && System.currentTimeMillis() < (lastRebuildTime + 5000)) return // logger.info("Building entity.data.feed.info cache") long startTime = System.currentTimeMillis() // rebuild from the DB for this and other entities, ie have to do it for all DataFeeds and // DataDocuments because we can't query it by entityName Map> localInfo = new HashMap<>() EntityList dataFeedAndDocumentList = efi.find("moqui.entity.feed.DataFeedAndDocument") .condition("dataFeedTypeEnumId", "DTFDTP_RT_PUSH").useCache(true).disableAuthz().list() //logger.warn("============= got dataFeedAndDocumentList: ${dataFeedAndDocumentList}") Set fullDataDocumentIdSet = new HashSet() int dataFeedAndDocumentListSize = dataFeedAndDocumentList.size() for (int i = 0; i < dataFeedAndDocumentListSize; i++) { EntityValue dataFeedAndDocument = (EntityValue) dataFeedAndDocumentList.get(i) fullDataDocumentIdSet.add(dataFeedAndDocument.getString("dataDocumentId")) } for (String dataDocumentId in fullDataDocumentIdSet) { try { Map entityInfoMap = getDataDocumentEntityInfo(dataDocumentId) if (entityInfoMap == null) { logger.error("Invalid or missing DataDocument ${dataDocumentId}, ignoring for real time feed") continue } // got a Map for all entities in the document, now split them by entity and add to master list for the entity for (Map.Entry entityInfoMapEntry in entityInfoMap.entrySet()) { String entityName = entityInfoMapEntry.getKey() ArrayList newEntityInfoList = (ArrayList) localInfo.get(entityName) if (newEntityInfoList == null) { newEntityInfoList = new ArrayList() localInfo.put(entityName, newEntityInfoList) // logger.warn("============= added dataFeedEntityInfo entry for entity [${entityInfoMapEntry.getKey()}]") } newEntityInfoList.add(entityInfoMapEntry.getValue()) } } catch (Throwable t) { logger.error("Error loading DataFeed info for DataDocument ${dataDocumentId}", t) } } Set entityNameSet = localInfo.keySet() if (entitiesWithDataFeed == null) { logger.info("Built entity.data.feed.info cache in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities") if (logger.isTraceEnabled()) logger.trace("Built entity.data.feed.info cache for in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities: ${entityNameSet}") } else { logger.info("Rebuilt entity.data.feed.info cache in ${System.currentTimeMillis() - startTime}ms, entries for ${entityNameSet.size()} entities") } dataFeedEntityInfo.putAll(localInfo) entitiesWithDataFeed = entityNameSet lastRebuildTime = System.currentTimeMillis() } Map getDataDocumentEntityInfo(String dataDocumentId) { EntityValue dataDocument = null EntityList dataDocumentFieldList = null EntityList dataDocumentConditionList = null boolean alreadyDisabled = efi.ecfi.getExecutionContext().getArtifactExecution().disableAuthz() try { dataDocument = efi.fastFindOne("moqui.entity.document.DataDocument", true, false, dataDocumentId) if (dataDocument == null) throw new EntityException("No DataDocument found with ID [${dataDocumentId}]") dataDocumentFieldList = dataDocument.findRelated("moqui.entity.document.DataDocumentField", null, null, true, false) dataDocumentConditionList = dataDocument.findRelated("moqui.entity.document.DataDocumentCondition", null, null, true, false) } finally { if (!alreadyDisabled) efi.ecfi.getExecutionContext().getArtifactExecution().enableAuthz() } String primaryEntityName = dataDocument.primaryEntityName if (!efi.isEntityDefined(primaryEntityName)) { logger.error("Could not find primary entity ${primaryEntityName} for DataDocument ${dataDocumentId}") return null } EntityDefinition primaryEd = efi.getEntityDefinition(primaryEntityName) Map entityInfoMap = [:] // start with the primary entity entityInfoMap.put(primaryEntityName, new DocumentEntityInfo(primaryEntityName, dataDocumentId, primaryEntityName, "")) // have to go through entire fieldTree instead of entity names directly from fieldPath because may not have hash (#) separator Map fieldTree = new LinkedHashMap() for (EntityValue dataDocumentField in dataDocumentFieldList) { String fieldPath = (String) dataDocumentField.fieldPath if (fieldPath.contains("(")) continue Map currentTree = fieldTree DocumentEntityInfo currentEntityInfo = entityInfoMap.get(primaryEntityName) StringBuilder currentRelationshipPath = new StringBuilder() EntityDefinition currentEd = primaryEd ArrayList fieldPathElementList = EntityDataDocument.fieldPathToList(fieldPath) int fieldPathElementListSize = fieldPathElementList.size() for (int i = 0; i < fieldPathElementListSize; i++) { String fieldPathElement = (String) fieldPathElementList.get(i) if (i < (fieldPathElementListSize - 1)) { if (currentRelationshipPath.length() > 0) currentRelationshipPath.append(":") currentRelationshipPath.append(fieldPathElement) Map subTree = (Map) currentTree.get(fieldPathElement) if (subTree == null) { subTree = [:]; currentTree.put(fieldPathElement, subTree) } currentTree = subTree // make sure we have an entityInfo Map RelationshipInfo relInfo = currentEd.getRelationshipInfo(fieldPathElement) if (relInfo == null) throw new EntityException("Could not find relationship [${fieldPathElement}] from entity [${currentEd.getFullEntityName()}] as part of DataDocumentField.fieldPath [${fieldPath}]") String relEntityName = relInfo.relatedEntityName EntityDefinition relEd = relInfo.relatedEd // TODO: handle entity used multiple times on different paths, perhaps with List in Map // add entry for the related entity if (!entityInfoMap.containsKey(relEntityName)) entityInfoMap.put(relEntityName, new DocumentEntityInfo(relEntityName, dataDocumentId, primaryEntityName, currentRelationshipPath.toString())) // add PK fields of the related entity as fields for the current entity so changes on them will also trigger a data feed Map relKeyMap = relInfo.keyMap for (String fkFieldName in relKeyMap.keySet()) { currentTree.put(fkFieldName, fkFieldName) // save the current field name (not the alias) currentEntityInfo.fields.add(fkFieldName) } currentEntityInfo = entityInfoMap.get(relEntityName) currentEd = relEd } else { String ddfFieldNameAlias = (String) dataDocumentField.fieldNameAlias currentTree.put(fieldPathElement, ddfFieldNameAlias != null && !ddfFieldNameAlias.isEmpty() ? ddfFieldNameAlias : fieldPathElement) // save the current field name (not the alias) currentEntityInfo.fields.add(fieldPathElement) // see if there are any conditions for this alias, if so add the fieldName/value to the entity conditions Map for (EntityValue dataDocumentCondition in dataDocumentConditionList) { if (dataDocumentCondition.fieldNameAlias == ddfFieldNameAlias) currentEntityInfo.conditions.put(fieldPathElement, (String) dataDocumentCondition.fieldValue) } } } } // logger.warn("============ got entityInfoMap for doc [${dataDocumentId}]: ${entityInfoMap}\n============ for fieldTree: ${fieldTree}") return entityInfoMap } static class DocumentEntityInfo implements Serializable { String fullEntityName String dataDocumentId String primaryEntityName String relationshipPath Set fields = new HashSet() Map conditions = [:] // will we need this? Map subEntities DocumentEntityInfo(String fullEntityName, String dataDocumentId, String primaryEntityName, String relationshipPath) { this.fullEntityName = fullEntityName this.dataDocumentId = dataDocumentId this.primaryEntityName = primaryEntityName this.relationshipPath = relationshipPath } @Override String toString() { StringBuilder sb = new StringBuilder() sb.append("DocumentEntityInfo [") sb.append("fullEntityName:").append(fullEntityName).append(",") sb.append("dataDocumentId:").append(dataDocumentId).append(",") sb.append("primaryEntityName:").append(primaryEntityName).append(",") sb.append("relationshipPath:").append(relationshipPath).append(",") sb.append("fields:").append(fields).append(",") sb.append("conditions:").append(conditions).append(",") sb.append("]") return sb.toString() } } @CompileStatic static class DataFeedSynchronization implements Synchronization { protected final static Logger logger = LoggerFactory.getLogger(DataFeedSynchronization.class) protected ExecutionContextFactoryImpl ecfi protected EntityDataFeed edf protected Transaction tx = null protected EntityList feedValues protected EntityList deleteValues protected Set allDataDocumentIds = new HashSet() DataFeedSynchronization(EntityDataFeed edf) { // logger.warn("========= Creating new DataFeedSynchronization") this.edf = edf ecfi = edf.getEfi().ecfi feedValues = new EntityListImpl(edf.getEfi()) deleteValues = new EntityListImpl(edf.getEfi()) } void enlist() { // logger.warn("========= Enlisting new DataFeedSynchronization") TransactionManager tm = ecfi.transactionFacade.getTransactionManager() if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException("Cannot enlist: no transaction manager or transaction not active") Transaction tx = tm.getTransaction() if (tx == null) throw new XAException(XAException.XAER_NOTA) this.tx = tx // logger.warn("================= puttng and enlisting new DataFeedSynchronization") ecfi.transactionFacade.putAndEnlistActiveSynchronization("DataFeedSynchronization", this) } void addValueToFeed(EntityValue ev, Set dataDocumentIdSet) { // this log message is for an issue where Atomikos seems to suspend and resume without calling start() on // this XAResource; everything seems to work fine, but it results in funny state // this can be reproduced by running the data load with DataFeed/DataDocument data already in the DB // if (!active && logger.isTraceEnabled()) logger.trace("Adding value to inactive DataFeedSynchronization! \nThis shouldn't happen and may mean the same DataFeedSynchronization is being used after a TX suspend; suspended=${suspended}") feedValues.add(ev) allDataDocumentIds.addAll(dataDocumentIdSet) } void addDeleteToFeed(EntityValue ev) { deleteValues.add(ev) } @Override void beforeCompletion() { } @Override void afterCompletion(int status) { if (status == Status.STATUS_COMMITTED) { // send feed in new thread and tx FeedRunnable runnable = new FeedRunnable(ecfi, edf, feedValues, allDataDocumentIds, deleteValues) try { ecfi.workerPool.execute(runnable) } catch (RejectedExecutionException e) { logger.error("Worker pool rejected DataFeed run: " + e.toString()) } // logger.warn("================================================================\n================ feeding DataFeed with documents ${allDataDocumentIds}") } } } static class FeedRunnable implements Runnable { private ExecutionContextFactoryImpl ecfi private EntityDataFeed edf private EntityList feedValues, deleteValues private Set allDataDocumentIds FeedRunnable(ExecutionContextFactoryImpl ecfi, EntityDataFeed edf, EntityList feedValues, Set allDataDocumentIds, EntityList deleteValues) { this.ecfi = ecfi this.edf = edf this.allDataDocumentIds = allDataDocumentIds this.feedValues = feedValues this.deleteValues = deleteValues } @Override void run() { Timestamp feedStamp = new Timestamp(System.currentTimeMillis()) ExecutionContextImpl threadEci = ecfi.getEci() try { if (logger.isTraceEnabled()) logger.trace("Doing DataFeed with allDataDocumentIds: ${allDataDocumentIds}, feedValues: ${feedValues}") // iterate through dataDocumentIdSet and generate/update for each for (String dataDocumentId in allDataDocumentIds) { try { feedDataDocument(dataDocumentId, feedStamp, threadEci) } catch (Throwable t) { logger.error("Error running Real-time DataFeed", t) } } // iterate through deleteValues, handle differently from updates as these are primary entities for relevant DataDocuments only if (deleteValues != null && deleteValues.size() > 0) { for (int di = 0; di < deleteValues.size(); di++) { EntityValue deleteEv = (EntityValue) deleteValues.get(di) deleteDataDocuments(deleteEv, feedStamp, threadEci) } } } finally { if (threadEci != null) threadEci.destroy() } } private void feedDataDocument(String dataDocumentId, Timestamp feedStamp, ExecutionContextImpl threadEci) { boolean beganTransaction = ecfi.transactionFacade.begin(1800) try { EntityFacadeImpl efi = ecfi.entityFacade // assemble data and call DataFeed services EntityValue dataDocument = null EntityList dataDocumentFieldList = null boolean alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz() try { // for each DataDocument go through feedValues and get the primary entity's PK field(s) for each dataDocument = efi.fastFindOne("moqui.entity.document.DataDocument", true, false, dataDocumentId) dataDocumentFieldList = dataDocument.findRelated("moqui.entity.document.DataDocumentField", null, null, true, false) } finally { if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz() } String primaryEntityName = dataDocument.primaryEntityName EntityDefinition primaryEd = efi.getEntityDefinition(primaryEntityName) ArrayList primaryPkFieldNames = primaryEd.getPkFieldNames() int primaryPkFieldNamesSize = primaryPkFieldNames.size() Set primaryPkFieldValues = new HashSet>() Map pkFieldAliasMap = [:] for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) boolean aliasSet = false for (EntityValue dataDocumentField in dataDocumentFieldList) { if (dataDocumentField.fieldPath == pkFieldName) { pkFieldAliasMap.put(pkFieldName, (String) dataDocumentField.fieldNameAlias ?: pkFieldName) aliasSet = true } } if (aliasSet) pkFieldAliasMap.put(pkFieldName, pkFieldName) } for (EntityValue currentEv in feedValues) { String currentEntityName = currentEv.resolveEntityName() List currentEntityInfoList = edf.getDataFeedEntityInfoList(currentEntityName) for (DocumentEntityInfo currentEntityInfo in currentEntityInfoList) { if (currentEntityInfo.dataDocumentId == dataDocumentId) { if (currentEntityName == primaryEntityName) { // this is the easy one, primary entity updated just use it's values Map pkFieldValue = new HashMap() for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) pkFieldValue.put(pkFieldName, currentEv.get(pkFieldName)) } primaryPkFieldValues.add(pkFieldValue) } else { // more complex, need to follow relationships backwards (reverse // relationships) to get the primary entity's value(s) List relationshipList = Arrays.asList(currentEntityInfo.relationshipPath.split(":")) // ArrayList relInfoList = new ArrayList() ArrayList backwardRelList = new ArrayList() // add the relationships backwards, get relInfo for each EntityDefinition lastRelEd = primaryEd for (String relElement in relationshipList) { RelationshipInfo relInfo = lastRelEd.getRelationshipInfo(relElement) backwardRelList.add(0, relInfo.relationshipName) lastRelEd = relInfo.relatedEd } // add the primary entity name to the end as that is the target backwardRelList.add(primaryEntityName) String prevRelName = backwardRelList.get(0) List prevRelValueList = [(EntityValueBase) currentEv] // skip the first one, it is the current entity for (int i = 1; i < backwardRelList.size(); i++) { // try to find the relationship be the title of the previous // relationship name + the current entity name, then by the current // entity name alone String currentRelName = backwardRelList.get(i) String currentRelEntityName = currentRelName.contains("#") ? currentRelName.substring(currentRelName.indexOf("#") + 1) : currentRelName // all values should be for the same entity, so just use the first EntityDefinition prevRelValueEd = prevRelValueList.get(0).getEntityDefinition() RelationshipInfo backwardRelInfo = null // Node backwardRelNode = null if (prevRelName.contains("#")) { String title = prevRelName.substring(0, prevRelName.indexOf("#")) backwardRelInfo = prevRelValueEd.getRelationshipInfo((String) title + "#" + currentRelEntityName) } if (backwardRelInfo == null) backwardRelInfo = prevRelValueEd.getRelationshipInfo(currentRelEntityName) if (backwardRelInfo == null) throw new EntityException("For DataFeed could not find backward relationship for DataDocument [${dataDocumentId}] from entity [${prevRelValueEd.getFullEntityName()}] to entity [${currentRelEntityName}], previous relationship is [${prevRelName}], current relationship is [${currentRelName}]") String backwardRelName = backwardRelInfo.relationshipName List currentRelValueList = [] alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz() try { for (EntityValueBase prevRelValue in prevRelValueList) { EntityList backwardRelValueList = prevRelValue.findRelated(backwardRelName, null, null, false, false) for (EntityValue backwardRelValue in backwardRelValueList) currentRelValueList.add((EntityValueBase) backwardRelValue) } } finally { if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz() } prevRelName = currentRelName prevRelValueList = currentRelValueList if (!prevRelValueList) { if (logger.isTraceEnabled()) logger.trace("Creating DataFeed for DataDocument [${dataDocumentId}], no backward rel values found for [${backwardRelName}] on updated values: ${prevRelValueList}") break } } // go through final prevRelValueList (which should be for the primary // entity) and get the PK for each if (prevRelValueList) for (EntityValue primaryEv in prevRelValueList) { Map pkFieldValue = new HashMap() for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) pkFieldValue.put(pkFieldName, primaryEv.get(pkFieldName)) } primaryPkFieldValues.add(pkFieldValue) } } } } } // if there aren't really any values for the document (a value updated that isn't really in // a document) then skip it, don't want to query with no constraints and get a huge document if (!primaryPkFieldValues) { if (logger.isTraceEnabled()) { String errMsg = "Skipping feed for DataDocument [${dataDocumentId}], no primary PK values found in feed values" /* StringBuilder sb = new StringBuilder() sb.append(errMsg).append('\n') sb.append("Primary Entity: ").append(primaryEntityName).append(": ").append(primaryPkFieldNames).append('\n') sb.append("Feed Values:").append('\n') for (EntityValue ev in feedValues) { sb.append(' ').append(ev).append('\n') } */ logger.trace(errMsg) } return } // logger.warn("Doing DataFeed with dataDocumentId: ${dataDocumentId}, feedValues: ${feedValues} primaryPkFieldValues ${primaryPkFieldValues.size()}") ArrayList primaryPkValueList = new ArrayList>(primaryPkFieldValues) int primaryPkValueListSize = primaryPkValueList.size() int chunkSize = 200 for (int outer = 0; outer < primaryPkValueListSize; ) { int remaining = primaryPkValueListSize - outer int curSize = remaining > chunkSize ? chunkSize : remaining int toIndex = outer + curSize primaryPkValueList.subList(outer, toIndex) // for primary entity with 1 PK field do an IN condition, for >1 PK field do an and cond for // each PK and an or list cond to combine them EntityCondition condition if (primaryPkFieldNames.size() == 1) { String pkFieldName = primaryPkFieldNames.get(0) Set pkValues = new HashSet() for (int inner = outer; inner < toIndex; inner++) { Map pkFieldValueMap = (Map) primaryPkValueList.get(inner) pkValues.add(pkFieldValueMap.get(pkFieldName)) } // if pk field is aliased use the alias name String aliasedPkName = pkFieldAliasMap.get(pkFieldName) ?: pkFieldName condition = efi.getConditionFactory().makeCondition(aliasedPkName, EntityCondition.IN, pkValues) } else { List condList = [] for (int inner = outer; inner < toIndex; inner++) { Map pkFieldValueMap = (Map) primaryPkValueList.get(inner) Map condAndMap = new LinkedHashMap() // if pk field is aliased used the alias name for (int pki = 0; pki < primaryPkFieldNamesSize; pki++) { String pkFieldName = (String) primaryPkFieldNames.get(pki) condAndMap.put(pkFieldAliasMap.get(pkFieldName), pkFieldValueMap.get(pkFieldName)) } condList.add(efi.getConditionFactory().makeCondition(condAndMap)) } condition = efi.getConditionFactory().makeCondition(condList, EntityCondition.OR) } alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz() try { // generate the document with the extra condition and send it to all DataFeeds // associated with the DataDocument List documents = efi.getDataDocuments(dataDocumentId, condition, null, null) if (documents) { EntityList dataFeedAndDocumentList = efi.find("moqui.entity.feed.DataFeedAndDocument") .condition("dataFeedTypeEnumId", "DTFDTP_RT_PUSH") .condition("dataDocumentId", dataDocumentId).useCache(true).list() // logger.warn("=========== FEED document ${dataDocumentId}, documents ${documents.size()}, condition: ${condition}\n dataFeedAndDocumentList: ${dataFeedAndDocumentList.feedReceiveServiceName}") // do the actual feed receive service calls (authz is disabled to allow the service // call, but also allows anything in the services...) for (EntityValue dataFeedAndDocument in dataFeedAndDocumentList) { // NOTE: this is a sync call so authz disabled is preserved; it is in its own thread // so user/etc are not inherited here String serviceName = (String) dataFeedAndDocument.feedReceiveServiceName ?: 'org.moqui.search.SearchServices.index#DataDocuments' try { ecfi.serviceFacade.sync().name(serviceName).parameters([dataFeedId:dataFeedAndDocument.dataFeedId, feedStamp:feedStamp, documentList:documents]).call() if (threadEci.messageFacade.hasError()) { logger.error("Error calling DataFeed ${dataFeedAndDocument.dataFeedId} service ${serviceName}: ${threadEci.messageFacade.getErrorsString()}") threadEci.messageFacade.clearErrors() } } catch (Throwable t) { logger.error("Error calling DataFeed ${dataFeedAndDocument.dataFeedId} service ${serviceName}", t) } } } else { // this is pretty common, some operation done on a record that doesn't match the conditions for the feed if (logger.isTraceEnabled()) logger.trace("In DataFeed no documents found for dataDocumentId [${dataDocumentId}]") } } finally { if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz() } outer += curSize } } catch (Throwable t) { logger.error("Error running Real-time DataFeed for DataDocument ${dataDocumentId}", t) ecfi.transactionFacade.rollback(beganTransaction, "Error running Real-time DataFeed for DataDocument ${dataDocumentId}", t) } finally { // commit transaction if we started one and still there if (beganTransaction && ecfi.transactionFacade.isTransactionInPlace()) ecfi.transactionFacade.commit() } } private void deleteDataDocuments(EntityValue deleteEv, Timestamp feedStamp, ExecutionContextImpl threadEci) { String entityName = deleteEv.resolveEntityName() ArrayList entityInfoList try { entityInfoList = edf.getDataFeedEntityInfoList(entityName) } catch (Throwable t) { logger.error("Error getting DataFeed info for delete for entity ${entityName}", t) return } String documentId = deleteEv.getPrimaryKeysString() int entityInfoListSize = entityInfoList != null ? entityInfoList.size() : 0 for (int ii = 0; ii < entityInfoListSize; ii++) { DocumentEntityInfo documentEntityInfo = (DocumentEntityInfo) entityInfoList.get(ii) if (!entityName.equals(documentEntityInfo.primaryEntityName)) continue String dataDocumentId = documentEntityInfo.dataDocumentId boolean alreadyDisabled = threadEci.artifactExecutionFacade.disableAuthz() try { EntityList dataFeedAndDocumentList = ecfi.entityFacade.find("moqui.entity.feed.DataFeedAndDocument") .condition("dataFeedTypeEnumId", "DTFDTP_RT_PUSH") .condition("dataDocumentId", dataDocumentId).useCache(true).list() // track servicesCalled to avoid redundant calls, on deletes subsequent calls with same parameters likely to result in errors HashSet servicesCalled = new HashSet<>() for (EntityValue dataFeedAndDocument in dataFeedAndDocumentList) { // NOTE: this is a sync call so authz disabled is preserved; it is in its own thread // so user/etc are not inherited here String serviceName = (String) dataFeedAndDocument.feedDeleteServiceName ?: 'org.moqui.search.SearchServices.delete#DataDocument' try { if (servicesCalled.contains(serviceName)) continue ecfi.serviceFacade.sync().name(serviceName) .parameters([dataFeedId:dataFeedAndDocument.dataFeedId, feedStamp:feedStamp, dataDocumentId:dataDocumentId, documentId:documentId]).call() servicesCalled.add(serviceName) if (threadEci.messageFacade.hasError()) { logger.error("Error calling DataFeed ${dataFeedAndDocument.dataFeedId} delete service ${serviceName} for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}: ${threadEci.messageFacade.getErrorsString()}") threadEci.messageFacade.clearErrors() } } catch (Throwable t) { logger.error("Error calling DataFeed ${dataFeedAndDocument.dataFeedId} delete service ${serviceName} for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}", t) } } } catch (Throwable t) { logger.error("Error processing DataFeed delete for entity ${entityName} PK ${deleteEv.getPrimaryKeys()}", t) } finally { if (!alreadyDisabled) threadEci.artifactExecutionFacade.enableAuthz() } } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDataLoaderImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.apache.commons.csv.CSVFormat import org.apache.commons.csv.CSVParser import org.apache.commons.csv.CSVRecord import org.moqui.BaseException import org.moqui.context.NotificationMessage import org.moqui.impl.context.TransactionFacadeImpl import org.moqui.resource.ResourceReference import org.moqui.context.TransactionFacade import org.moqui.entity.EntityDataLoader import org.moqui.entity.EntityException import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.service.ServiceCallSyncImpl import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.impl.service.runner.EntityAutoServiceRunner import org.moqui.service.ServiceCallSync import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import org.xml.sax.* import org.xml.sax.helpers.DefaultHandler import javax.sql.rowset.serial.SerialBlob import javax.xml.parsers.SAXParser import javax.xml.parsers.SAXParserFactory import java.nio.charset.StandardCharsets import java.util.zip.ZipEntry import java.util.zip.ZipInputStream @CompileStatic class EntityDataLoaderImpl implements EntityDataLoader { protected final static Logger logger = LoggerFactory.getLogger(EntityDataLoaderImpl.class) protected EntityFacadeImpl efi protected ServiceFacadeImpl sfi // NOTE: these are Groovy Beans style with no access modifier, results in private fields with implicit getters/setters List locationList = new LinkedList() String xmlText = null String csvText = null String jsonText = null Set dataTypes = new HashSet() List componentNameList = new LinkedList() int transactionTimeout = 600 boolean useTryInsert = false boolean onlyCreate = false boolean dummyFks = false boolean messageNoActionFiles = true boolean disableEeca = false boolean disableAuditLog = false boolean disableFkCreate = false boolean disableDataFeed = false char csvDelimiter = ',' char csvCommentStart = '#' char csvQuoteChar = '"' String csvEntityName = null List csvFieldNames = null Map defaultValues = null EntityDataLoaderImpl(EntityFacadeImpl efi) { this.efi = efi this.sfi = efi.ecfi.serviceFacade } EntityFacadeImpl getEfi() { return efi } @Override EntityDataLoader location(String location) { this.locationList.add(location); return this } @Override EntityDataLoader locationList(List ll) { this.locationList.addAll(ll); return this } @Override EntityDataLoader xmlText(String xmlText) { this.xmlText = xmlText; return this } @Override EntityDataLoader csvText(String csvText) { this.csvText = csvText; return this } @Override EntityDataLoader jsonText(String jsonText) { this.jsonText = jsonText; return this } @Override EntityDataLoader dataTypes(Set dataTypes) { for (String dt in dataTypes) this.dataTypes.add(dt.trim()) return this } @Override EntityDataLoader componentNameList(List componentNames) { for (String cn in componentNames) this.componentNameList.add(cn.trim()) return this } @Override EntityDataLoader transactionTimeout(int tt) { this.transactionTimeout = tt; return this } @Override EntityDataLoader useTryInsert(boolean useTryInsert) { this.useTryInsert = useTryInsert; return this } @Override EntityDataLoader onlyCreate(boolean onlyCreate) { this.onlyCreate = onlyCreate; return this } @Override EntityDataLoader dummyFks(boolean dummyFks) { this.dummyFks = dummyFks; return this } @Override EntityDataLoader messageNoActionFiles(boolean message) { this.messageNoActionFiles = message; return this } @Override EntityDataLoader disableEntityEca(boolean disable) { disableEeca = disable; return this } @Override EntityDataLoader disableAuditLog(boolean disable) { disableAuditLog = disable; return this } @Override EntityDataLoader disableFkCreate(boolean disable) { disableFkCreate = disable; return this } @Override EntityDataLoader disableDataFeed(boolean disable) { disableDataFeed = disable; return this } @Override EntityDataLoader csvDelimiter(char delimiter) { this.csvDelimiter = delimiter; return this } @Override EntityDataLoader csvCommentStart(char commentStart) { this.csvCommentStart = commentStart; return this } @Override EntityDataLoader csvQuoteChar(char quoteChar) { this.csvQuoteChar = quoteChar; return this } @Override EntityDataLoader csvEntityName(String entityName) { if (!efi.isEntityDefined(entityName) && !sfi.isServiceDefined(entityName)) throw new IllegalArgumentException("Name ${entityName} is not a valid entity or service name") this.csvEntityName = entityName return this } @Override EntityDataLoader csvFieldNames(List fieldNames) { this.csvFieldNames = fieldNames; return this } @Override EntityDataLoader defaultValues(Map defaultValues) { if (this.defaultValues == null) this.defaultValues = [:] this.defaultValues.putAll(defaultValues) return this } @Override List check() { CheckValueHandler cvh = new CheckValueHandler(this) EntityXmlHandler exh = new EntityXmlHandler(this, cvh) EntityCsvHandler ech = new EntityCsvHandler(this, cvh) EntityJsonHandler ejh = new EntityJsonHandler(this, cvh) internalRun(exh, ech, ejh) return cvh.messageList } @Override long check(List messageList) { CheckValueHandler cvh = new CheckValueHandler(this, messageList) EntityXmlHandler exh = new EntityXmlHandler(this, cvh) EntityCsvHandler ech = new EntityCsvHandler(this, cvh) EntityJsonHandler ejh = new EntityJsonHandler(this, cvh) internalRun(exh, ech, ejh) return cvh.getFieldsChecked() } @Override List> checkInfo() { CheckInfoValueHandler civh = new CheckInfoValueHandler(this) EntityXmlHandler exh = new EntityXmlHandler(this, civh) EntityCsvHandler ech = new EntityCsvHandler(this, civh) EntityJsonHandler ejh = new EntityJsonHandler(this, civh) internalRun(exh, ech, ejh) List messageList = civh.messageList if (messageList != null && messageList.size() > 0) { ExecutionContextImpl eci = this.efi.ecfi.getEci() for (String message in messageList) eci.messageFacade.addMessage(message, NotificationMessage.info) } return civh.getDiffInfoList() } @Override long checkInfo(List> diffInfoList, List messageList) { CheckInfoValueHandler civh = new CheckInfoValueHandler(this, diffInfoList, messageList) EntityXmlHandler exh = new EntityXmlHandler(this, civh) EntityCsvHandler ech = new EntityCsvHandler(this, civh) EntityJsonHandler ejh = new EntityJsonHandler(this, civh) internalRun(exh, ech, ejh) return civh.getFieldsChecked() } @Override long load() { load(null) } @Override long load(List messageList) { LoadValueHandler lvh = new LoadValueHandler(this, messageList) EntityXmlHandler exh = new EntityXmlHandler(this, lvh) EntityCsvHandler ech = new EntityCsvHandler(this, lvh) EntityJsonHandler ejh = new EntityJsonHandler(this, lvh) internalRun(exh, ech, ejh) return exh.getValuesRead() + ech.getValuesRead() + ejh.getValuesRead() } @Override EntityList list() { ListValueHandler lvh = new ListValueHandler(this) EntityXmlHandler exh = new EntityXmlHandler(this, lvh) EntityCsvHandler ech = new EntityCsvHandler(this, lvh) EntityJsonHandler ejh = new EntityJsonHandler(this, lvh) internalRun(exh, ech, ejh) return lvh.entityList } void internalRun(EntityXmlHandler exh, EntityCsvHandler ech, EntityJsonHandler ejh) { // make sure reverse relationships exist efi.createAllAutoReverseManyRelationships() ExecutionContextImpl eci = efi.ecfi.getEci() boolean reenableEeca = false if (this.disableEeca) reenableEeca = !eci.artifactExecutionFacade.disableEntityEca() boolean reenableAuditLog = false if (this.disableAuditLog) reenableAuditLog = !eci.artifactExecutionFacade.disableEntityAuditLog() boolean reenableFkCreate = false if (this.disableFkCreate) reenableFkCreate = !eci.artifactExecutionFacade.disableEntityFkCreate() boolean reenableDataFeed = false if (this.disableDataFeed) reenableDataFeed = !eci.artifactExecutionFacade.disableEntityDataFeed() // if no xmlText or locations, so find all of the component and entity-facade files if (!this.xmlText && !this.csvText && !this.jsonText && !this.locationList) { // if we're loading seed type data, add configured (Moqui Conf XML) entity def files to the list of locations to load if (!componentNameList && (!dataTypes || dataTypes.contains("seed"))) { for (ResourceReference entityRr in efi.getConfEntityFileLocations()) if (!entityRr.location.endsWith(".eecas.xml")) locationList.add(entityRr.location) } // loop through all of the entity-facade.load-data nodes if (!componentNameList) { for (MNode loadData in efi.ecfi.getConfXmlRoot().first("entity-facade").children("load-data")) { locationList.add((String) loadData.attribute("location")) } } LinkedHashMap loadCompLocations if (componentNameList) { LinkedHashMap allLocations = efi.ecfi.getComponentBaseLocations() loadCompLocations = new LinkedHashMap() for (String cn in componentNameList) loadCompLocations.put(cn, allLocations.get(cn)) } else { loadCompLocations = efi.ecfi.getComponentBaseLocations() } for (Map.Entry compLocEntry in loadCompLocations) { // if we're loading seed type data, add COMPONENT entity def files to the list of locations to load if (!dataTypes || dataTypes.contains("seed")) { for (ResourceReference entityRr in efi.getComponentEntityFileLocations([compLocEntry.key])) if (!entityRr.location.endsWith(".eecas.xml")) locationList.add(entityRr.location) } // load files in component data directory String location = compLocEntry.value ResourceReference dataDirRr = efi.ecfi.resourceFacade.getLocationReference(location + "/data") if (dataDirRr.supportsAll()) { // if directory doesn't exist skip it, component doesn't have a data directory if (!dataDirRr.exists || !dataDirRr.isDirectory()) continue // get all files in the directory TreeMap dataDirEntries = new TreeMap() for (ResourceReference dataRr in dataDirRr.directoryEntries) { if (!dataRr.isFile() || (!dataRr.location.endsWith(".xml") && !dataRr.location.endsWith(".csv") && !dataRr.location.endsWith(".json"))) continue dataDirEntries.put(dataRr.getFileName(), dataRr) } for (Map.Entry dataDirEntry in dataDirEntries) { locationList.add(dataDirEntry.getValue().location) } } else { // just warn here, no exception because any non-file component location would blow everything up logger.warn("Cannot load entity data file in component location [${location}] because protocol [${dataDirRr.uri.scheme}] is not yet supported.") } } } if (locationList && logger.isInfoEnabled()) { StringBuilder lm = new StringBuilder("Loading entity data from the following locations: ") for (String loc in locationList) lm.append("\n - ").append(loc) logger.info(lm.toString()) logger.info("Loading data types: ${dataTypes ?: 'ALL'}") } // efi.createAllAutoReverseManyRelationships() // logger.warn("========== Waiting 45s to attach profiler") // Thread.sleep(45000) TransactionFacadeImpl tf = efi.ecfi.transactionFacade tf.runRequireNew(transactionTimeout, "Error loading entity data", false, true, { // load the XML text in its own transaction if (this.xmlText) { tf.runUseOrBegin(transactionTimeout, "Error loading XML entity data", { XMLReader reader = SAXParserFactory.newInstance().newSAXParser().XMLReader exh.setLocation("xmlText") reader.setContentHandler(exh) reader.parse(new InputSource(new StringReader(this.xmlText))) }) } // load the CSV text in its own transaction if (this.csvText) { InputStream csvInputStream = new ByteArrayInputStream(csvText.getBytes("UTF-8")) try { tf.runUseOrBegin(transactionTimeout, "Error loading CSV entity data", { ech.loadFile("csvText", csvInputStream) }) } finally { if (csvInputStream != null) csvInputStream.close() } } // load the JSON text in its own transaction if (this.jsonText) { InputStream jsonInputStream = new ByteArrayInputStream(jsonText.getBytes("UTF-8")) try { tf.runUseOrBegin(transactionTimeout, "Error loading JSON entity data", { ejh.loadFile("jsonText", jsonInputStream) }) } finally { if (jsonInputStream != null) jsonInputStream.close() } } // load each file in its own transaction for (String location in this.locationList) { try { loadSingleFile(location, exh, ech, ejh) } catch (Throwable t) { logger.error("Skipping to next file after error: ${t.toString()} ${t.getCause() != null ? t.getCause().toString() : ''}") } } }) if (reenableEeca) eci.artifactExecutionFacade.enableEntityEca() if (reenableAuditLog) eci.artifactExecutionFacade.enableEntityAuditLog() if (reenableFkCreate) eci.artifactExecutionFacade.enableEntityFkCreate() if (reenableDataFeed) eci.artifactExecutionFacade.enableEntityDataFeed() // logger.warn("========== Done loading, waiting for a long time so process is still running for profiler") // Thread.sleep(60*1000*100) } void loadSingleFile(String location, EntityXmlHandler exh, EntityCsvHandler ech, EntityJsonHandler ejh) { TransactionFacade tf = efi.ecfi.transactionFacade boolean beganTransaction = tf.begin(transactionTimeout) try { InputStream inputStream = null try { logger.info("Loading entity data from ${location}") long beforeTime = System.currentTimeMillis() inputStream = efi.ecfi.resourceFacade.getLocationStream(location) if (inputStream == null) throw new BaseException("Data file not found at ${location}") long recordsLoaded = 0 int messagesBefore = exh.valueHandler.messageList != null ? exh.valueHandler.messageList.size() : 0 if (location.endsWith(".xml")) { long beforeRecords = exh.valuesRead ?: 0 exh.setLocation(location) SAXParser parser = SAXParserFactory.newInstance().newSAXParser() parser.parse(inputStream, exh) recordsLoaded = (exh.valuesRead?:0) - beforeRecords logger.info("Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s") } else if (location.endsWith(".csv")) { long beforeRecords = ech.valuesRead ?: 0 if (ech.loadFile(location, inputStream)) { recordsLoaded = (ech.valuesRead?:0) - beforeRecords logger.info("Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s") } } else if (location.endsWith(".json")) { long beforeRecords = ejh.valuesRead ?: 0 if (ejh.loadFile(location, inputStream)) { recordsLoaded = (ejh.valuesRead?:0) - beforeRecords logger.info("Loaded ${recordsLoaded} records from ${location} in ${((System.currentTimeMillis() - beforeTime)/1000)}s") } } else if (location.endsWith(".zip")) { NoCloseZipStream zis = new NoCloseZipStream(inputStream) ZipEntry entry while((entry = zis.getNextEntry()) != null) { try { String entryFile = entry.getName() long entryBeforeTime = System.currentTimeMillis() if (entryFile.endsWith(".xml")) { long beforeRecords = exh.valuesRead ?: 0 exh.setLocation(location) SAXParser parser = SAXParserFactory.newInstance().newSAXParser() parser.parse(zis, exh) long curFileLoaded = (exh.valuesRead?:0) - beforeRecords recordsLoaded += curFileLoaded logger.info("Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s") } else if (entryFile.endsWith(".csv")) { long beforeRecords = ech.valuesRead ?: 0 if (ech.loadFile(entryFile, zis)) { long curFileLoaded = (ech.valuesRead?:0) - beforeRecords recordsLoaded += curFileLoaded logger.info("Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s") } } else if (entryFile.endsWith(".json")) { long beforeRecords = ejh.valuesRead ?: 0 if (ejh.loadFile(entryFile, zis)) { long curFileLoaded = (ejh.valuesRead?:0) - beforeRecords recordsLoaded += curFileLoaded logger.info("Loaded ${curFileLoaded} records from ${entryFile} in zip file ${location} in ${((System.currentTimeMillis() - entryBeforeTime)/1000)}s") } } else { logger.warn("Found file ${entryFile} in zip file ${location} that is not a .xml file, ignoring") } } catch (TypeToSkipException e) { // nothing to do, this just stops the parsing when we know the file is not in the types we want } catch (Throwable t) { tf.rollback(beganTransaction, "Error loading entity data", t) throw new BaseException("Error loading entity data from ${entry.getName()} in zip file ${location}", t) } } } int messagesAdded = (exh.valueHandler.messageList != null ? exh.valueHandler.messageList.size() : 0) - messagesBefore if (exh.valueHandler instanceof CheckValueHandler) { if (messageNoActionFiles || messagesAdded > 0) exh.valueHandler.messageList.add("-- Checked data (${recordsLoaded} records) in ${location}".toString()) } else if (exh.valueHandler?.messageList != null) { if (messageNoActionFiles || recordsLoaded > 0) exh.valueHandler.messageList.add("-- Loaded data (${recordsLoaded} records) from ${location}".toString()) } } catch (TypeToSkipException e) { // nothing to do, this just stops the parsing when we know the file is not in the types we want } finally { if (inputStream != null) inputStream.close() } } catch (Throwable t) { tf.rollback(beganTransaction, "Error loading entity data", t) throw new BaseException("Error loading entity data from ${location}", t) } finally { tf.commit(beganTransaction) ExecutionContextImpl ec = efi.ecfi.getEci() if (ec.messageFacade.hasError()) { logger.error("Error messages loading entity data: " + ec.messageFacade.getErrorsString()) ec.messageFacade.clearErrors() } } } private static class NoCloseZipStream extends ZipInputStream { NoCloseZipStream(InputStream is) { super(is) } @Override void close() throws IOException { /* do nothing, the point is to not get closed by SAXParser */ } void reallyClose() { super.close() } } static abstract class ValueHandler { protected List messageList = (List) null protected EntityDataLoaderImpl edli ValueHandler(EntityDataLoaderImpl edli) { this.edli = edli } abstract void handleValue(EntityValue value, String location) abstract void handlePlainMap(String entityName, Map value, String location) abstract void handleService(ServiceCallSync scs, String location) } static class CheckValueHandler extends ValueHandler { protected long fieldsChecked = 0 CheckValueHandler(EntityDataLoaderImpl edli) { super(edli) messageList = new LinkedList<>() } CheckValueHandler(EntityDataLoaderImpl edli, List messages) { super(edli) messageList = messages if (messageList == null) messageList = new LinkedList<>() } long getFieldsChecked() { return fieldsChecked } void handleValue(EntityValue value, String location) { value.checkAgainstDatabase(messageList) } void handlePlainMap(String entityName, Map value, String location) { EntityList el = edli.getEfi().getValueListFromPlainMap(value, entityName) // logger.warn("=========== Check value: ${value}\nel: ${el}") for (EntityValue ev in el) fieldsChecked += ev.checkAgainstDatabase(messageList) } void handleService(ServiceCallSync scs, String location) { messageList.add("Doing check only so not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}".toString()) } } static class CheckInfoValueHandler extends ValueHandler { protected long fieldsChecked = 0 protected List> diffInfoList CheckInfoValueHandler(EntityDataLoaderImpl edli) { super(edli) messageList = new LinkedList<>() diffInfoList = new LinkedList<>() } CheckInfoValueHandler(EntityDataLoaderImpl edli, List> diffInfoList, List messages) { super(edli) messageList = messages if (messageList == null) messageList = new LinkedList<>() this.diffInfoList = diffInfoList if (this.diffInfoList == null) this.diffInfoList = new LinkedList<>() } long getFieldsChecked() { return fieldsChecked } List> getDiffInfoList() { return diffInfoList } void handleValue(EntityValue value, String location) { fieldsChecked += value.checkAgainstDatabaseInfo(diffInfoList, messageList, location) } void handlePlainMap(String entityName, Map value, String location) { EntityList el = edli.getEfi().getValueListFromPlainMap(value, entityName) // logger.warn("=========== Check value: ${value}\nel: ${el}") for (EntityValue ev in el) { fieldsChecked += ev.checkAgainstDatabaseInfo(diffInfoList, messageList, location) } } void handleService(ServiceCallSync scs, String location) { messageList.add("Doing check only so not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}".toString()) } } static class LoadValueHandler extends ValueHandler { protected ServiceFacadeImpl sfi protected ExecutionContextImpl ec LoadValueHandler(EntityDataLoaderImpl edli) { super(edli) sfi = edli.getEfi().ecfi.serviceFacade ec = edli.getEfi().ecfi.getEci() } LoadValueHandler(EntityDataLoaderImpl edli, List messages) { super(edli) sfi = edli.getEfi().ecfi.serviceFacade ec = edli.getEfi().ecfi.getEci() messageList = messages } void handleValue(EntityValue value, String location) { boolean tryInsert = edli.useTryInsert if (tryInsert && value instanceof EntityValueBase) { EntityValueBase evb = (EntityValueBase) value MNode databaseNode = ec.entityFacade.getDatabaseNode(evb.getEntityDefinition().getEntityGroupName()) if ("true".equals(databaseNode.attribute("never-try-insert"))) tryInsert = false } if (edli.onlyCreate) { if (value.containsPrimaryKey()) { if (ec.entityFacade.find(value.resolveEntityName()).condition(value.getPrimaryKeys()).one() == null) value.create() } else { String msg = "Doing only insert, not loading entity ${value.resolveEntityName()} value with partial primary key ${value.getPrimaryKeys()}" logger.info(msg) if (messageList != null) messageList.add(msg) } } else if (tryInsert) { try { value.create() } catch (EntityException e) { if (logger.isTraceEnabled()) logger.trace("Insert failed, trying update (${e.toString()})") boolean noFksMissing = true if (edli.dummyFks) noFksMissing = value.checkFks(true) // retry, then if this fails we have a real error so let the exception fall through // if there were no FKs missing then just do an update, if there were that may have been the error so createOrUpdate if (noFksMissing) { value.update() } else { value.createOrUpdate() } } } else { if (edli.dummyFks) value.checkFks(true) value.createOrUpdate() } } void handlePlainMap(String entityName, Map value, String location) { EntityDefinition ed = ec.entityFacade.getEntityDefinition(entityName) if (ed == null) throw new BaseException("Could not find entity ${entityName}") if (edli.onlyCreate) { EntityList el = ec.entityFacade.getValueListFromPlainMap(value, entityName) int elSize = el.size() for (int i = 0; i < elSize; i++) { EntityValue curValue = (EntityValue) el.get(i) if (curValue.containsPrimaryKey()) { if (ec.entityFacade.find(curValue.resolveEntityName()).condition(curValue.getPrimaryKeys()).one() == null) curValue.create() } else { String msg = "Doing only insert, not loading entity ${curValue.resolveEntityName()} value with partial primary key ${curValue.getPrimaryKeys()}" logger.info(msg) if (messageList != null) messageList.add(msg) } } } else { Map results = new HashMap() EntityAutoServiceRunner.storeEntity(ec, ed, value, results, null) // no need to call the store auto service, use storeEntity directly: // Map results = sfi.sync().name('store', entityName).parameters(value).call() if (logger.isTraceEnabled()) logger.trace("Called store service for entity [${entityName}] in data load, results: ${results}") if (ec.getMessage().hasError()) { String errStr = ec.getMessage().getErrorsString() ec.getMessage().clearErrors() throw new BaseException("Error handling data load plain Map: ${errStr}") } } } void handleService(ServiceCallSync scs, String location) { if (edli.onlyCreate) { String msg = "Not calling service ${scs.getServiceName()}, running with only insert" logger.info(msg) if (messageList != null) messageList.add(msg) return } Map results = scs.call() String msg = "Called service ${scs.getServiceName()} in data load, results: ${results}" logger.info(msg) if (messageList != null) messageList.add(msg) if (ec.getMessage().hasError()) { String errStr = ec.getMessage().getErrorsString() ec.getMessage().clearErrors() throw new BaseException("Error handling data load service call: ${errStr}") } } } static class ListValueHandler extends ValueHandler { protected EntityList el ListValueHandler(EntityDataLoaderImpl edli) { super(edli); el = new EntityListImpl(edli.efi) } EntityList getEntityList() { return el } void handleValue(EntityValue value, String location) { el.add(value) } void handlePlainMap(String entityName, Map value, String location) { EntityDefinition ed = edli.getEfi().getEntityDefinition(entityName) edli.getEfi().addValuesFromPlainMapRecursive(ed, value, el, null) } void handleService(ServiceCallSync scs, String location) { logger.warn("For load to EntityList not calling service [${scs.getServiceName()}] with parameters ${scs.getCurrentParameters()}") } } static class TypeToSkipException extends RuntimeException { TypeToSkipException() { } } static class EntityXmlHandler extends DefaultHandler { protected Locator locator protected EntityDataLoaderImpl edli protected ValueHandler valueHandler protected EntityDefinition currentEntityDef = (EntityDefinition) null protected String entityOperation = (String) null protected ServiceDefinition currentServiceDef = (ServiceDefinition) null protected Map rootValueMap = (Map) null // use a List as a stack, element 0 is the top protected List valueMapStack = (List) null protected List relatedEdStack = (List) null protected String currentFieldName = (String) null protected StringBuilder currentFieldValue = (StringBuilder) null protected long valuesRead = 0 protected List messageList = new LinkedList<>() String location protected boolean loadElements = false EntityXmlHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) { this.edli = edli this.valueHandler = valueHandler } ValueHandler getValueHandler() { return valueHandler } long getValuesRead() { return valuesRead } List getMessageList() { return messageList } void startElement(String ns, String localName, String qName, Attributes attributes) { // logger.info("startElement ns [${ns}], localName [${localName}] qName [${qName}]") String type = null if (qName == "entity-facade-xml") { type = attributes.getValue("type") } else if (qName == "seed-data") { type = "seed" } if (type && edli.dataTypes && !edli.dataTypes.contains(type)) { if (logger.isInfoEnabled()) logger.info("Skipping file [${location}], is a type to skip (${type})") throw new TypeToSkipException() } if (qName == "entity-facade-xml") { loadElements = true return } else if (qName == "seed-data") { loadElements = true return } if (!loadElements) return String elementName = qName // get everything after a colon, but replace - with # for verb#noun separation if (elementName.contains(':')) elementName = elementName.substring(elementName.indexOf(':') + 1) if (elementName.contains('-')) elementName = elementName.replace('-', '#') if (currentEntityDef != null) { EntityDefinition checkEd = currentEntityDef if (relatedEdStack) checkEd = relatedEdStack.get(0) if (checkEd.isField(elementName)) { // nested value/CDATA element currentFieldName = elementName } else if (checkEd.getRelationshipInfo(elementName) != null) { EntityJavaUtil.RelationshipInfo relInfo = checkEd.getRelationshipInfo(elementName) Map curRelMap = getAttributesMap(attributes, relInfo.relatedEd) String relationshipName = relInfo.relationshipName if (valueMapStack) { Map prevValueMap = valueMapStack.get(0) if (prevValueMap.containsKey(relationshipName)) { Object prevRelValue = prevValueMap.get(relationshipName) if (prevRelValue instanceof List) { ((List) prevRelValue).add(curRelMap) } else { prevValueMap.put(relationshipName, [prevRelValue, curRelMap]) } } else { prevValueMap.put(relationshipName, curRelMap) } valueMapStack.add(0, curRelMap) relatedEdStack.add(0, relInfo.relatedEd) } else { if (rootValueMap.containsKey(relationshipName)) { Object prevRelValue = rootValueMap.get(relationshipName) if (prevRelValue instanceof List) { ((List) prevRelValue).add(curRelMap) } else { rootValueMap.put(relationshipName, [prevRelValue, curRelMap]) } } else { rootValueMap.put(relationshipName, curRelMap) } valueMapStack = [curRelMap] as List relatedEdStack = [relInfo.relatedEd] } } else if (edli.efi.isEntityDefined(elementName)) { EntityDefinition subEd = edli.efi.getEntityDefinition(elementName) Map curRelMap = getAttributesMap(attributes, subEd) String relationshipName = subEd.getFullEntityName() if (valueMapStack) { Map prevValueMap = valueMapStack.get(0) if (prevValueMap.containsKey(relationshipName)) { Object prevRelValue = prevValueMap.get(relationshipName) if (prevRelValue instanceof List) { ((List) prevRelValue).add(curRelMap) } else { prevValueMap.put(relationshipName, [prevRelValue, curRelMap]) } } else { prevValueMap.put(relationshipName, curRelMap) } valueMapStack.add(0, curRelMap) relatedEdStack.add(0, subEd) } else { if (rootValueMap.containsKey(relationshipName)) { Object prevRelValue = rootValueMap.get(relationshipName) if (prevRelValue instanceof List) { ((List) prevRelValue).add(curRelMap) } else { rootValueMap.put(relationshipName, [prevRelValue, curRelMap]) } } else { rootValueMap.put(relationshipName, curRelMap) } valueMapStack = [curRelMap] as List relatedEdStack = [subEd] } } else { logger.warn("Found element [${elementName}] under element for entity [${checkEd.getFullEntityName()}] and it is not a field or relationship so ignoring (file ${location} line ${locator?.lineNumber})") } } else if (currentServiceDef != null) { currentFieldName = qName // TODO: support nested elements for services? ie look for attributes, somehow handle subelements, etc } else { if (edli.efi.isEntityDefined(elementName)) { currentEntityDef = edli.efi.getEntityDefinition(elementName) // logger.warn("Found entity ${currentEntityDef.getFullEntityName()} for ${entityName}") rootValueMap = getAttributesMap(attributes, currentEntityDef) } else if (edli.sfi.isServiceDefined(elementName)) { currentServiceDef = edli.sfi.getServiceDefinition(elementName) if (currentServiceDef == null) { int hashIndex = elementName.indexOf('#') entityOperation = elementName.substring(0, hashIndex) currentEntityDef = edli.efi.getEntityDefinition(elementName.substring(hashIndex + 1)) } rootValueMap = getAttributesMap(attributes, null) } else { throw new SAXException("Found element [${qName}] name, transformed to [${elementName}], that is not a valid entity name or service name (file ${location} line ${locator?.lineNumber})") } } } Map getAttributesMap(Attributes attributes, EntityDefinition checkEd) { Map attrMap = [:] int length = attributes.getLength() for (int i = 0; i < length; i++) { String name = attributes.getLocalName(i) String value = attributes.getValue(i) if (!name) name = attributes.getQName(i) if (checkEd == null || checkEd.isField(name)) { // treat empty strings as nulls if (value) { attrMap.put(name, value) } else { attrMap.put(name, null) } } else { logger.warn("Ignoring invalid attribute name [${name}] for entity [${checkEd.getFullEntityName()}] with value [${value}] because it is not field of that entity (file ${location} line ${locator?.lineNumber})") } } return attrMap } void characters(char[] chars, int offset, int length) { if (rootValueMap && currentFieldName) { if (currentFieldValue == null) currentFieldValue = new StringBuilder() currentFieldValue.append(chars, offset, length) } } void endElement(String ns, String localName, String qName) { if (qName == "entity-facade-xml" || qName == "seed-data") { loadElements = false return } if (!loadElements) return if (currentFieldName != null) { if (currentFieldValue) { EntityDefinition checkEd = currentEntityDef Map addToMap = rootValueMap if (relatedEdStack) { checkEd = relatedEdStack.get(0) addToMap = valueMapStack.get(0) } if (checkEd != null) { if (checkEd.isField(currentFieldName)) { FieldInfo fieldInfo = checkEd.getFieldInfo(currentFieldName) if ("binary-very-long".equals(fieldInfo.type)) { String curStringValue = currentFieldValue.toString() try { byte[] binData = Base64.getDecoder().decode(curStringValue) addToMap.put(currentFieldName, new SerialBlob(binData)) } catch (IllegalArgumentException e) { if (logger.isTraceEnabled()) logger.trace("Value for binary-very-long field ${currentFieldName} entity ${checkEd.getFullEntityName()} is not Base64, using UTF-8 bytes") addToMap.put(currentFieldName, new SerialBlob(curStringValue.getBytes(StandardCharsets.UTF_8))) } } else { addToMap.put(currentFieldName, currentFieldValue.toString()) } } else { logger.warn("Ignoring invalid field name ${currentFieldName} found for entity ${checkEd.getFullEntityName()} (file ${location} line ${locator?.lineNumber}) with value: ${currentFieldValue}") } } else if (currentServiceDef != null) { rootValueMap.put(currentFieldName, currentFieldValue) } currentFieldValue = null } currentFieldName = (String) null } else if (valueMapStack) { // end of nested relationship element, just pop the last valueMapStack.remove(0) relatedEdStack.remove(0) valuesRead++ } else { Map valueMap = [:] if (edli.defaultValues != null && edli.defaultValues.size() > 0) valueMap.putAll(edli.defaultValues) valueMap.putAll(rootValueMap) if (currentEntityDef != null) { if (entityOperation == null) { try { // if (currentEntityDef.getFullEntityName().contains("DbForm")) logger.warn("========= DbForm rootValueMap: ${rootValueMap}") if (edli.dummyFks || edli.useTryInsert) { EntityValue curValue = currentEntityDef.makeEntityValue() curValue.setAll(valueMap) valueHandler.handleValue(curValue, location) valuesRead++ } else { valueHandler.handlePlainMap(currentEntityDef.getFullEntityName(), valueMap, location) valuesRead++ } } catch (EntityException e) { throw new SAXException("Error storing entity [${currentEntityDef.getFullEntityName()}] value (file ${location} line ${locator?.lineNumber}): " + e.toString(), e) } finally { currentEntityDef = (EntityDefinition) null } } else { try { ServiceCallSync currentScs = edli.sfi.sync().name(entityOperation, currentEntityDef.getFullEntityName()).parameters(valueMap) valueHandler.handleService(currentScs, location) valuesRead++ } catch (Exception e) { throw new SAXException("Error running service [${currentServiceDef.serviceName}] (file ${location} line ${locator?.lineNumber}): " + e.toString(), e) } finally { currentEntityDef = (EntityDefinition) null entityOperation = (String) null } } } else if (currentServiceDef != null) { try { ServiceCallSync currentScs = edli.sfi.sync().name(currentServiceDef.serviceName).parameters(valueMap) valueHandler.handleService(currentScs, location) valuesRead++ } catch (Exception e) { throw new SAXException("Error running service [${currentServiceDef.serviceName}] (file ${location} line ${locator?.lineNumber}): " + e.toString(), e) } finally { currentServiceDef = (ServiceDefinition) null } } } } void setDocumentLocator(Locator locator) { this.locator = locator } } static class EntityCsvHandler { protected EntityDataLoaderImpl edli protected ValueHandler valueHandler protected long valuesRead = 0 protected List messageList = new LinkedList() EntityCsvHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) { this.edli = edli this.valueHandler = valueHandler } ValueHandler getValueHandler() { return valueHandler } long getValuesRead() { return valuesRead } List getMessageList() { return messageList } boolean loadFile(String location, InputStream is) { BufferedReader reader = new BufferedReader(new InputStreamReader(is, "UTF-8")) CSVParser parser = CSVFormat.newFormat(edli.csvDelimiter) .withCommentMarker(edli.csvCommentStart) .withQuote(edli.csvQuoteChar) .withSkipHeaderRecord(true) // TODO: remove this? does it even do anything? .withIgnoreEmptyLines(true) .withIgnoreSurroundingSpaces(true) .parse(reader) Iterator iterator = parser.iterator() if (!iterator.hasNext()) throw new BaseException("Not loading file [${location}], no data found") String entityName boolean isService if (edli.csvEntityName) { entityName = edli.csvEntityName // NOTE: when csvEntityName set it is checked to make sure it is a valid entity or service name, so // just check to see if it is a service isService = edli.sfi.isServiceDefined(entityName) } else { CSVRecord firstLineRecord = iterator.next() entityName = firstLineRecord.get(0) if (edli.efi.isEntityDefined(entityName)) { isService = false } else if (edli.sfi.isServiceDefined(entityName)) { isService = true } else { throw new BaseException("CSV first line first field [${entityName}] is not a valid entity name or service name") } if (firstLineRecord.size() > 1) { // second field is data type String type = firstLineRecord.get(1) if (type && edli.dataTypes && !edli.dataTypes.contains(type)) { if (logger.isInfoEnabled()) logger.info("Skipping file [${location}], is a type to skip (${type})") return false } } } Map headerMap = [:] if (edli.csvFieldNames) { for (int i = 0; i < edli.csvFieldNames.size(); i++) headerMap.put(edli.csvFieldNames.get(i), i) } else { if (!iterator.hasNext()) throw new BaseException("Not loading file [${location}], no second (header) line found") CSVRecord headerRecord = iterator.next() for (int i = 0; i < headerRecord.size(); i++) headerMap.put(headerRecord.get(i), i) } // logger.warn("======== CSV entity/service [${entityName}] headerMap: ${headerMap}") EntityDefinition entityDefinition = isService ? null : edli.efi.getEntityDefinition(entityName) while (iterator.hasNext()) { CSVRecord record = iterator.next() // logger.warn("======== CSV record: ${record.toString()}") if (isService) { ServiceCallSyncImpl currentScs = (ServiceCallSyncImpl) edli.sfi.sync().name(entityName) if (edli.defaultValues) currentScs.parameters(edli.defaultValues) for (Map.Entry header in headerMap) { // if not enough elements in the record for the index, skip it if (header.value >= record.size()) continue currentScs.parameter(header.key, record.get(header.value)) } valueHandler.handleService(currentScs, location) valuesRead++ } else { EntityValueImpl currentEntityValue = (EntityValueImpl) edli.efi.makeValue(entityName) if (edli.defaultValues) currentEntityValue.setFields(edli.defaultValues, true, null, null) for (Map.Entry header in headerMap) { String fieldStr = record.get(header.value) if (fieldStr == null) continue if (fieldStr.isEmpty()) { currentEntityValue.set(header.key, null) continue } // for BLOB field type do Base64 decode if (entityDefinition != null && fieldStr != null) { FieldInfo fi = entityDefinition.fieldInfoMap.get(header.key) if (fi.typeValue == 12) { byte[] bytes = Base64.getDecoder().decode(fieldStr) logger.warn("Load ${bytes.length} bytes: ${fieldStr}") currentEntityValue.setBytes(header.key, bytes) continue } } // handle generally with setString() currentEntityValue.setString(header.key, fieldStr) } if (!currentEntityValue.containsPrimaryKey()) { if (currentEntityValue.getEntityDefinition().getPkFieldNames().size() == 1) { currentEntityValue.setSequencedIdPrimary() } else { throw new BaseException("Cannot process value with incomplete primary key for [${currentEntityValue.resolveEntityName()}] with more than 1 primary key field: " + currentEntityValue) } } // logger.warn("======== CSV entity: ${currentEntityValue.toString()}") valueHandler.handleValue(currentEntityValue, location) valuesRead++ } } return true } } static class EntityJsonHandler { protected EntityDataLoaderImpl edli protected ValueHandler valueHandler protected long valuesRead = 0 protected List messageList = new LinkedList() EntityJsonHandler(EntityDataLoaderImpl edli, ValueHandler valueHandler) { this.edli = edli this.valueHandler = valueHandler } ValueHandler getValueHandler() { return valueHandler } long getValuesRead() { return valuesRead } List getMessageList() { return messageList } boolean loadFile(String location, InputStream is) { JsonSlurper slurper = new JsonSlurper() Object jsonObj try { jsonObj = slurper.parse(new BufferedReader(new InputStreamReader(is, "UTF-8"))) } catch (Throwable t) { String errMsg = "Error parsing HTTP request body JSON: ${t.toString()}" logger.error(errMsg, t) throw new BaseException(errMsg, t) } String type = null List valueList if (jsonObj instanceof Map) { Map jsonMap = (Map) jsonObj type = jsonMap.get("_dataType") valueList = [jsonObj] } else if (jsonObj instanceof List) { valueList = (List) jsonObj Object firstValue = valueList?.get(0) if (firstValue instanceof Map) { Map firstValMap = (Map) firstValue if (firstValMap.get("_dataType")) { type = firstValMap.get("_dataType") valueList.remove((int) 0I) } } } else { throw new BaseException("Root JSON field was not a Map/object or List/array, type is ${jsonObj.getClass().getName()}") } if (type && edli.dataTypes && !edli.dataTypes.contains(type)) { if (logger.isInfoEnabled()) logger.info("Skipping file [${location}], is a type to skip (${type})") return false } for (Object valueObj in valueList) { if (!(valueObj instanceof Map)) { logger.warn("Found non-Map object in JSON import, skipping: ${valueObj}") continue } Map value = [:] if (edli.defaultValues) value.putAll(edli.defaultValues) value.putAll((Map) valueObj) String entityName = value."_entity" boolean isService if (edli.efi.isEntityDefined(entityName)) { isService = false } else if (edli.sfi.isServiceDefined(entityName)) { isService = true } else { throw new BaseException("JSON _entity value [${entityName}] is not a valid entity name or service name") } if (isService) { ServiceCallSyncImpl currentScs = (ServiceCallSyncImpl) edli.sfi.sync().name(entityName).parameters(value) valueHandler.handleService(currentScs, location) valuesRead++ } else { valueHandler.handlePlainMap(entityName, value, location) // TODO: make this more complete, like counting nested Maps? valuesRead++ } } return true } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDataWriterImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.json.JsonBuilder import groovy.transform.CompileStatic import org.moqui.entity.EntityValue import org.moqui.util.ObjectUtilities import javax.sql.rowset.serial.SerialBlob import java.sql.Timestamp import org.moqui.context.TransactionException import org.moqui.context.TransactionFacade import org.moqui.entity.EntityDataWriter import org.moqui.entity.EntityListIterator import org.moqui.entity.EntityFind import org.moqui.entity.EntityCondition.ComparisonOperator import org.slf4j.LoggerFactory import org.slf4j.Logger import java.time.ZoneOffset import java.time.format.DateTimeFormatter import java.util.zip.ZipEntry import java.util.zip.ZipOutputStream @CompileStatic class EntityDataWriterImpl implements EntityDataWriter { private final static Logger logger = LoggerFactory.getLogger(EntityDataWriterImpl.class) private EntityFacadeImpl efi private FileType fileType = XML private int txTimeout = 3600 private LinkedHashSet entityNames = new LinkedHashSet<>() private LinkedHashSet skipEntityNames = new LinkedHashSet<>() private int dependentLevels = 0 private String masterName = null private String prefix = null private Map filterMap = [:] private List orderByList = [] private Timestamp fromDate = null private Timestamp thruDate = null private boolean isoDateTime = false private boolean tableColumnNames = false EntityDataWriterImpl(EntityFacadeImpl efi) { this.efi = efi } EntityFacadeImpl getEfi() { return efi } EntityDataWriter fileType(FileType ft) { fileType = ft; return this } EntityDataWriter fileType(String ft) { fileType = FileType.valueOf(ft); return this } EntityDataWriter entityName(String entityName) { entityNames.add(entityName); return this } EntityDataWriter entityNames(Collection enList) { entityNames.addAll(enList); return this } EntityDataWriter skipEntityName(String entityName) { skipEntityNames.add(entityName); return this } EntityDataWriter skipEntityNames(Collection enList) { skipEntityNames.addAll(enList); return this } EntityDataWriter allEntities() { LinkedHashSet newEntities = new LinkedHashSet<>(efi.getAllNonViewEntityNames()) newEntities.removeAll(entityNames) entityNames = newEntities return this } EntityDataWriter dependentRecords(boolean dr) { if (dr) { dependentLevels = 2 } else { dependentLevels = 0 }; return this } EntityDataWriter dependentLevels(int levels) { dependentLevels = levels; return this } EntityDataWriter master(String mn) { masterName = mn; return this } EntityDataWriter prefix(String p) { prefix = p; return this } EntityDataWriter filterMap(Map fm) { filterMap.putAll(fm); return this } EntityDataWriter orderBy(List obl) { orderByList.addAll(obl); return this } EntityDataWriter fromDate(Timestamp fd) { fromDate = fd; return this } EntityDataWriter thruDate(Timestamp td) { thruDate = td; return this } EntityDataWriter isoDateTime(boolean iso) { isoDateTime = iso; return this } EntityDataWriter tableColumnNames(boolean tcn) { tableColumnNames = tcn; return this } @Override int file(String filename) { File outFile = new File(filename) if (!outFile.createNewFile()) { efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.','',[filename:filename])) return 0 } if (filename.endsWith('.json')) fileType(JSON) else if (filename.endsWith('.xml')) fileType(XML) else if (filename.endsWith('.csv')) fileType(CSV) if (CSV.is(fileType) && entityNames.size() > 1) { efi.ecfi.executionContext.message.addError('Cannot write to single CSV file with multiple entity names') return 0 } PrintWriter pw = new PrintWriter(outFile) // NOTE: don't have to do anything different here for different file types, writer() method will handle that int valuesWritten = this.writer(pw) pw.close() efi.ecfi.executionContext.message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}', '', [valuesWritten:valuesWritten, filename:filename])) return valuesWritten } @Override int zipFile(String filenameWithinZip, String zipFilename) { File zipFile = new File(zipFilename) if (!zipFile.parentFile.exists()) zipFile.parentFile.mkdirs() if (!zipFile.createNewFile()) { efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.', '', [filename:zipFilename])) return 0 } if (filenameWithinZip.endsWith('.json')) fileType(JSON) else if (filenameWithinZip.endsWith('.xml')) fileType(XML) else if (filenameWithinZip.endsWith('.csv')) fileType(CSV) if (CSV.is(fileType) && entityNames.size() > 1) { efi.ecfi.executionContext.message.addError('Cannot write to single CSV file with multiple entity names') return 0 } ZipOutputStream out = new ZipOutputStream(new FileOutputStream(zipFile)) try { PrintWriter pw = new PrintWriter(out) ZipEntry e = new ZipEntry(filenameWithinZip) out.putNextEntry(e) try { int valuesWritten = this.writer(pw) pw.flush() efi.ecfi.executionContext.message.addMessage(efi.ecfi.resource.expand('Wrote ${valuesWritten} records to file ${filename}', '', [valuesWritten:valuesWritten, filename:zipFilename])) return valuesWritten } finally { out.closeEntry() } } finally { out.close() } } @Override int directory(String path) { File outDir = new File(path) if (!outDir.exists()) outDir.mkdir() if (!outDir.isDirectory()) { efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('Path ${path} is not a directory.','',[path:path])) return 0 } if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships() int valuesWritten = 0 TransactionFacade tf = efi.ecfi.transactionFacade boolean suspendedTransaction = false try { if (tf.isTransactionInPlace()) suspendedTransaction = tf.suspend() boolean beganTransaction = tf.begin(txTimeout) try { for (String en in entityNames) { if (skipEntityNames.contains(en)) continue EntityDefinition ed = efi.getEntityDefinition(en) boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null EntityFind ef = makeEntityFind(en) try (EntityListIterator eli = ef.iterator()) { if (!eli.hasNext()) continue String filename = path + '/' + en + '.' + fileType.name().toLowerCase() File outFile = new File(filename) if (outFile.exists()) { efi.ecfi.getEci().message.addError(efi.ecfi.resource.expand('File ${filename} already exists, skipping entity ${en}.','',[filename:filename,en:en])) continue } outFile.createNewFile() PrintWriter pw = new PrintWriter(outFile) try { startFile(pw, ed) int curValuesWritten = 0 EntityValue ev while ((ev = eli.next()) != null) { curValuesWritten += writeValue(ev, pw, useMaster) } endFile(pw) efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${curValuesWritten} records to file ${filename}','',[curValuesWritten:curValuesWritten,filename:filename])) valuesWritten += curValuesWritten } finally { pw.close() } } } } catch (Throwable t) { logger.warn("Error writing data", t) tf.rollback(beganTransaction, "Error writing data", t) efi.ecfi.getEci().messageFacade.addError(t.getMessage()) } finally { if (beganTransaction && tf.isTransactionInPlace()) tf.commit() } } catch (TransactionException e) { throw e } finally { try { if (suspendedTransaction) tf.resume() } catch (Throwable t) { logger.error("Error resuming parent transaction after data write", t) } } return valuesWritten } @Override int zipDirectory(String pathWithinZip, String zipFilename) { File zipFile = new File(zipFilename) if (!zipFile.parentFile.exists()) zipFile.parentFile.mkdirs() if (!zipFile.createNewFile()) { efi.ecfi.executionContext.message.addError(efi.ecfi.resource.expand('File ${filename} already exists.', '', [filename:zipFilename])) return 0 } return zipDirectory(pathWithinZip, new FileOutputStream(zipFile)) } @Override int zipDirectory(String pathWithinZip, OutputStream outputStream) { if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships() int valuesWritten = 0 ZipOutputStream out = new ZipOutputStream(outputStream) try { PrintWriter pw = new PrintWriter(out) for (String en in entityNames) { if (skipEntityNames.contains(en)) continue EntityDefinition ed = efi.getEntityDefinition(en) boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null EntityFind ef = makeEntityFind(en) try (EntityListIterator eli = ef.iterator()) { if (!eli.hasNext()) continue String filenameBase = tableColumnNames ? ed.getTableName() : en String filenameWithinZip = (pathWithinZip ? pathWithinZip + '/' : '') + filenameBase + '.' + fileType.name().toLowerCase() ZipEntry e = new ZipEntry(filenameWithinZip) out.putNextEntry(e) try { startFile(pw, ed) int curValuesWritten = 0 EntityValue ev while ((ev = eli.next()) != null) { curValuesWritten += writeValue(ev, pw, useMaster) } endFile(pw) pw.flush() efi.ecfi.getEci().message.addMessage(efi.ecfi.resource.expand('Wrote ${curValuesWritten} records to ${filename}','',[curValuesWritten:curValuesWritten,filename:filenameWithinZip])) valuesWritten += curValuesWritten } finally { out.closeEntry() } } } } finally { out.close() } return valuesWritten } @Override int writer(Writer writer) { if (dependentLevels > 0) efi.createAllAutoReverseManyRelationships() LinkedHashSet activeEntityNames if (skipEntityNames.size() == 0) { activeEntityNames = entityNames } else { activeEntityNames = new LinkedHashSet<>(entityNames) activeEntityNames.removeAll(skipEntityNames) } EntityDefinition singleEd = null if (activeEntityNames.size() == 1) singleEd = efi.getEntityDefinition(activeEntityNames.first()) TransactionFacade tf = efi.ecfi.transactionFacade boolean suspendedTransaction = false int valuesWritten = 0 try { if (tf.isTransactionInPlace()) suspendedTransaction = tf.suspend() boolean beganTransaction = tf.begin(txTimeout) try { startFile(writer, singleEd) for (String en in activeEntityNames) { EntityDefinition ed = efi.getEntityDefinition(en) boolean useMaster = masterName != null && masterName.length() > 0 && ed.getMasterDefinition(masterName) != null try (EntityListIterator eli = makeEntityFind(en).iterator()) { EntityValue ev while ((ev = eli.next()) != null) { valuesWritten+= writeValue(ev, writer, useMaster) } } } endFile(writer) } catch (Throwable t) { logger.warn("Error writing data: " + t.toString(), t) tf.rollback(beganTransaction, "Error writing data", t) efi.ecfi.getEci().messageFacade.addError(t.getMessage()) } finally { if (beganTransaction && tf.isTransactionInPlace()) tf.commit() } } catch (TransactionException e) { throw e } finally { try { if (suspendedTransaction) tf.resume() } catch (Throwable t) { logger.error("Error resuming parent transaction after data write", t) } } return valuesWritten } private void startFile(Writer writer, EntityDefinition ed) { if (JSON.is(fileType)) { writer.println("[") } else if (XML.is(fileType)) { writer.println("") writer.println("") } else if (CSV.is(fileType)) { if (ed == null) throw new IllegalArgumentException("Tried to start CSV file with no single entity specified") // first record: entity name, 'export' for file type, then each PK field if (tableColumnNames) { writer.println(ed.getTableName() + ",export," + ed.getPkFieldNames().collect({ ed.getFieldInfo(it).columnName }).join(",")) } else { writer.println(ed.getFullEntityName() + ",export," + ed.getPkFieldNames().join(",")) } // second record: header row with all field names if (tableColumnNames) { writer.println(ed.getAllFieldNames().collect({ ed.getFieldInfo(it).columnName }).join(",")) } else { writer.println(ed.getAllFieldNames().join(",")) } } } private void endFile(Writer writer) { if (JSON.is(fileType)) { writer.println("]") writer.println("") } else if (XML.is(fileType)) { writer.println("") writer.println("") } else if (CSV.is(fileType)) { // could add empty line at end but is effectively an empty record, better to do nothing to end the file // writer.println("") } } private int writeValue(EntityValue ev, Writer writer, boolean useMaster) { int valuesWritten if (JSON.is(fileType)) { // TODO: support isoDateTime and tableColumnNames Map plainMap if (useMaster) { plainMap = ev.getMasterValueMap(masterName) } else { plainMap = ev.getPlainValueMap(dependentLevels) } JsonBuilder jb = new JsonBuilder() jb.call(plainMap) String jsonStr = jb.toPrettyString() writer.write(jsonStr) writer.println(",") // TODO: consider including dependent records in the count too... maybe write something to recursively count the nested Maps valuesWritten = 1 } else if (XML.is(fileType)) { // TODO: support isoDateTime and tableColumnNames if (useMaster) { valuesWritten = ev.writeXmlTextMaster(writer, prefix, masterName) } else { valuesWritten = ev.writeXmlText(writer, prefix, dependentLevels) } } else if (CSV.is(fileType)) { EntityValueBase evb = (EntityValueBase) ev // NOTE: master entity def concept doesn't apply to CSV, file format cannot handle multiple entities in single file FieldInfo[] fieldInfoArray = evb.getEntityDefinition().entityInfo.allFieldInfoArray for (int i = 0; i < fieldInfoArray.length; i++) { Object fieldValue = evb.getKnownField(fieldInfoArray[i]) String fieldStr = convertFieldValue(fieldValue) // write the field value if (fieldStr.contains(",") || fieldStr.contains("\"") || fieldStr.contains("\n")) { writer.write("\"") writer.write(fieldStr.replace("\"", "\"\"")) writer.write("\"") } else { writer.write(fieldStr) } // add the comma if (i < (fieldInfoArray.length - 1)) writer.write(",") } // end the line writer.println() valuesWritten = 1 } return valuesWritten } String convertFieldValue(Object fieldValue) { String fieldStr if (fieldValue instanceof byte[]) { fieldStr = Base64.getEncoder().encodeToString((byte[]) fieldValue) } else if (fieldValue instanceof SerialBlob) { if (((SerialBlob) fieldValue).length() == 0) { fieldStr = "" } else { byte[] objBytes = ((SerialBlob) fieldValue).getBytes(1, (int) ((SerialBlob) fieldValue).length()) fieldStr = Base64.getEncoder().encodeToString(objBytes) } } else if (isoDateTime && fieldValue instanceof java.util.Date) { if (fieldValue instanceof Timestamp) { fieldStr = fieldValue.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_INSTANT) } else if (fieldValue instanceof java.sql.Date) { fieldStr = efi.ecfi.getEci().l10nFacade.formatDate(fieldValue, "yyyy-MM-dd", null, null) } else if (fieldValue instanceof java.sql.Time) { fieldStr = efi.ecfi.getEci().l10nFacade.formatTime(fieldValue, "HH:mm:ssZ", null, TimeZone.getTimeZone(ZoneOffset.UTC)) } else { fieldStr = fieldValue.toInstant().atZone(ZoneOffset.UTC.normalized()).format(DateTimeFormatter.ISO_DATE_TIME) } } else { fieldStr = ObjectUtilities.toPlainString(fieldValue) } if (fieldStr == null) fieldStr = "" return fieldStr } private EntityFind makeEntityFind(String en) { EntityFind ef = efi.find(en).condition(filterMap).orderBy(orderByList) EntityDefinition ed = efi.getEntityDefinition(en) if (ed.isField("lastUpdatedStamp")) { if (fromDate) ef.condition("lastUpdatedStamp", ComparisonOperator.GREATER_THAN_EQUAL_TO, fromDate) if (thruDate) ef.condition("lastUpdatedStamp", ComparisonOperator.LESS_THAN, thruDate) } return ef } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDatasourceFactoryImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.context.TransactionInternal import org.moqui.entity.* import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.naming.Context import javax.naming.InitialContext import javax.naming.NamingException import javax.sql.DataSource @CompileStatic class EntityDatasourceFactoryImpl implements EntityDatasourceFactory { protected final static Logger logger = LoggerFactory.getLogger(EntityDatasourceFactoryImpl.class) protected final static int DS_RETRY_COUNT = 5 protected final static long DS_RETRY_SLEEP = 5000 protected EntityFacadeImpl efi = null protected MNode datasourceNode = null protected DataSource dataSource = null EntityFacadeImpl.DatasourceInfo dsi = null EntityDatasourceFactoryImpl() { } @Override EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode) { // local fields this.efi = (EntityFacadeImpl) ef this.datasourceNode = datasourceNode // init the DataSource dsi = new EntityFacadeImpl.DatasourceInfo(efi, datasourceNode) if (dsi.jndiName != null && !dsi.jndiName.isEmpty()) { try { InitialContext ic; if (dsi.serverJndi) { Hashtable h = new Hashtable() h.put(Context.INITIAL_CONTEXT_FACTORY, dsi.serverJndi.attribute("initial-context-factory")) h.put(Context.PROVIDER_URL, dsi.serverJndi.attribute("context-provider-url")) if (dsi.serverJndi.attribute("url-pkg-prefixes")) h.put(Context.URL_PKG_PREFIXES, dsi.serverJndi.attribute("url-pkg-prefixes")) if (dsi.serverJndi.attribute("security-principal")) h.put(Context.SECURITY_PRINCIPAL, dsi.serverJndi.attribute("security-principal")) if (dsi.serverJndi.attribute("security-credentials")) h.put(Context.SECURITY_CREDENTIALS, dsi.serverJndi.attribute("security-credentials")) ic = new InitialContext(h) } else { ic = new InitialContext() } this.dataSource = (DataSource) ic.lookup(dsi.jndiName) if (this.dataSource == null) { logger.error("Could not find DataSource with name [${dsi.jndiName}] in JNDI server [${dsi.serverJndi ? dsi.serverJndi.attribute("context-provider-url") : "default"}] for datasource with group-name [${datasourceNode.attribute("group-name")}].") } } catch (NamingException ne) { logger.error("Error finding DataSource with name [${dsi.jndiName}] in JNDI server [${dsi.serverJndi ? dsi.serverJndi.attribute("context-provider-url") : "default"}] for datasource with group-name [${datasourceNode.attribute("group-name")}].", ne) } } else if (dsi.inlineJdbc != null) { // special thing for embedded derby, just set an system property; for derby.log, etc if (datasourceNode.attribute("database-conf-name") == "derby" && !System.getProperty("derby.system.home")) { System.setProperty("derby.system.home", efi.ecfi.runtimePath + "/db/derby") logger.info("Set property derby.system.home to [${System.getProperty("derby.system.home")}]") } TransactionInternal ti = efi.ecfi.transactionFacade.getTransactionInternal() // init the DataSource, if it fails for any reason retry a few times for (int retry = 1; retry <= DS_RETRY_COUNT; retry++) { try { this.dataSource = ti.getDataSource(efi, datasourceNode) break } catch (Throwable t) { if (retry < DS_RETRY_COUNT) { Throwable cause = t while (cause.getCause() != null) cause = cause.getCause() logger.error("Error connecting to DataSource ${datasourceNode.attribute("group-name")} (${datasourceNode.attribute("database-conf-name")}), try ${retry} of ${DS_RETRY_COUNT}: ${cause}") sleep(DS_RETRY_SLEEP) } else { throw t } } } } else { throw new EntityException("Found datasource with no jdbc sub-element (in datasource with group-name ${datasourceNode.attribute("group-name")})") } return this } @Override void destroy() { // NOTE: TransactionInternal DataSource will be destroyed when the TransactionFacade is destroyed } @Override boolean checkTableExists(String entityName) { EntityDefinition ed // just ignore EntityException on getEntityDefinition try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false } // may happen if all entity names includes a DB view entity or other that doesn't really exist if (ed == null) return false return ed.tableExistsDbMetaOnly() } @Override boolean checkAndAddTable(String entityName) { EntityDefinition ed // just ignore EntityException on getEntityDefinition try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false } // may happen if all entity names includes a DB view entity or other that doesn't really exist if (ed == null) return false return efi.getEntityDbMeta().checkTableStartup(ed) } @Override int checkAndAddAllTables() { return efi.getEntityDbMeta().checkAndAddAllTables(datasourceNode.attribute("group-name")) } @Override EntityValue makeEntityValue(String entityName) { EntityDefinition entityDefinition = efi.getEntityDefinition(entityName) if (entityDefinition == null) throw new EntityException("Entity not found for name [${entityName}]") return new EntityValueImpl(entityDefinition, efi) } @Override EntityFind makeEntityFind(String entityName) { return new EntityFindImpl(efi, entityName) } @Override void createBulk(List valueList) { // basic approach, can probably do better with some JDBC tricks Iterator valueIterator = valueList.iterator() while (valueIterator.hasNext()) { EntityValue ev = (EntityValue) valueIterator.next() ev.create() } } @Override DataSource getDataSource() { return dataSource } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDbMeta.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.util.CollectionUtilities import org.moqui.util.MNode import org.moqui.util.SystemBinding import java.sql.Connection import java.sql.Statement import java.sql.DatabaseMetaData import java.sql.ResultSet import java.sql.Timestamp import org.moqui.entity.EntityException import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.locks.ReentrantLock @CompileStatic class EntityDbMeta { protected final static Logger logger = LoggerFactory.getLogger(EntityDbMeta.class) static final boolean useTxForMetaData = false // this keeps track of when tables are checked and found to exist or are created protected HashMap entityTablesChecked = new HashMap<>() // a separate Map for tables checked to exist only (used in finds) so repeated checks are needed for unused entities protected HashMap entityTablesExist = new HashMap<>() protected HashMap runtimeAddMissingMap = new HashMap<>() protected EntityFacadeImpl efi EntityDbMeta(EntityFacadeImpl efi) { this.efi = efi } static boolean shouldCreateFks(ExecutionContextFactoryImpl ecfi) { if (ecfi.getEci().artifactExecutionFacade.entityFkCreateDisabled()) return false if ("true".equals(SystemBinding.getPropOrEnv("entity_disable_fk_create"))) return false return true } boolean checkTableRuntime(EntityDefinition ed) { EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo // most common case: not view entity and already checked boolean alreadyChecked = entityTablesChecked.containsKey(entityInfo.fullEntityName) if (alreadyChecked) return false String groupName = entityInfo.groupName Boolean runtimeAddMissing = (Boolean) runtimeAddMissingMap.get(groupName) if (runtimeAddMissing == null) { MNode datasourceNode = efi.getDatasourceNode(groupName) MNode dbNode = efi.getDatabaseNode(groupName) String ramAttr = datasourceNode?.attribute("runtime-add-missing") runtimeAddMissing = ramAttr ? !"false".equals(ramAttr) : !"false".equals(dbNode.attribute("default-runtime-add-missing")) runtimeAddMissingMap.put(groupName, runtimeAddMissing) } if (!runtimeAddMissing.booleanValue()) return false if (entityInfo.isView) { boolean tableCreated = false for (MNode memberEntityNode in ed.entityNode.children("member-entity")) { EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute("entity-name")) if (checkTableRuntime(med)) tableCreated = true } return tableCreated } else { // already looked above to see if this entity has been checked // do the real check, in a synchronized method return internalCheckTable(ed, false) } } boolean checkTableStartup(EntityDefinition ed) { if (ed.isViewEntity) { boolean tableCreated = false for (MNode memberEntityNode in ed.entityNode.children("member-entity")) { EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute("entity-name")) if (checkTableStartup(med)) tableCreated = true } return tableCreated } else { return internalCheckTable(ed, true) } } int checkAndAddAllTables(String groupName) { int tablesAdded = 0 MNode datasourceNode = efi.getDatasourceNode(groupName) // MNode databaseNode = efi.getDatabaseNode(groupName) String schemaName = datasourceNode != null ? datasourceNode.attribute("schema-name") : null Set groupEntityNames = efi.getAllEntityNamesInGroup(groupName) String[] types = ["TABLE", "VIEW", "ALIAS", "SYNONYM", "PARTITIONED TABLE"] Set existingTableNames = new HashSet<>() boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(300) : false try { Connection con = efi.getConnection(groupName) try { DatabaseMetaData dbData = con.getMetaData() ResultSet tableSet = null try { tableSet = dbData.getTables(con.getCatalog(), schemaName, "%", types) while (tableSet.next()) { String tableName = tableSet.getString('TABLE_NAME') existingTableNames.add(tableName) } } catch (Exception e) { throw new EntityException("Exception getting tables in group ${groupName}", e) } finally { if (tableSet != null && !tableSet.isClosed()) tableSet.close() } Map> existingColumnsByTable = new HashMap<>() ResultSet colSet = null try { colSet = dbData.getColumns(con.getCatalog(), schemaName, "%", "%") while (colSet.next()) { String tableName = colSet.getString("TABLE_NAME") String colName = colSet.getString("COLUMN_NAME") Set existingColumns = existingColumnsByTable.get(tableName) if (existingColumns == null) { existingColumns = new HashSet<>() existingColumnsByTable.put(tableName, existingColumns) } existingColumns.add(colName) // FUTURE: while we're at it also get type info, etc to validate and warn? } } catch (Exception e) { throw new EntityException("Exception getting columns in group ${groupName}", e) } finally { if (colSet != null && !colSet.isClosed()) colSet.close() } Set remainingTableNames = new HashSet<>(existingTableNames) for (String entityName in groupEntityNames) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed.isViewEntity) continue String fullEntityName = ed.getFullEntityName() String tableName = ed.getTableName() boolean tableExists = existingTableNames.contains(tableName) || existingTableNames.contains(tableName.toLowerCase()) try { if (tableExists) { // table exists, see if it is missing any columns ArrayList fieldInfos = new ArrayList<>(ed.allFieldInfoList) Set existingColumns = (Set) existingColumnsByTable.get(tableName) if (existingColumns == null) existingColumns = (Set) existingColumnsByTable.get(tableName.toLowerCase()) if (existingColumns == null || existingColumns.size() == 0) { logger.warn("No existing columns found for table ${tableName} entity ${fullEntityName}, not trying to add columns but this is bad, probably a DB meta data issue so we can't check columns") } else { Set remainingColumns = new HashSet<>(existingColumns) for (int fii = 0; fii < fieldInfos.size(); fii++) { FieldInfo fi = (FieldInfo) fieldInfos.get(fii) if (existingColumns.contains(fi.columnName) || existingColumns.contains(fi.columnName.toLowerCase())) { remainingColumns.remove(fi.columnName) remainingColumns.remove(fi.columnName.toLowerCase()) } else { addColumn(ed, fi, con) } } if (remainingColumns.size() > 0) logger.warn("Found unknown columns on table ${tableName} for entity ${fullEntityName}: ${remainingColumns}") } // FUTURE: also check all indexes? on large DBs may take a long time... maybe just warn about? // create foreign keys after checking each to see if it already exists // DON'T DO THIS, will check all later in one pass: createForeignKeys(ed, true) remainingTableNames.remove(tableName) remainingTableNames.remove(tableName.toLowerCase()) } else { createTable(ed, con) existingTableNames.add(tableName) tablesAdded++ // create explicit and foreign key auto indexes createIndexes(ed, false, con) // create foreign keys to all other tables that exist createForeignKeys(ed, false, existingTableNames, con) } entityTablesChecked.put(fullEntityName, new Timestamp(System.currentTimeMillis())) entityTablesExist.put(fullEntityName, true) } catch (Throwable t) { logger.error("Error ${tableExists ? 'updating' : 'creating'} table for for entity ${entityName}", t) } } if (remainingTableNames.size() > 0) logger.warn("Found unknown tables in database for group ${groupName}: ${remainingTableNames}") } finally { if (con != null) con.close() } } finally { if (beganTx) efi.ecfi.transactionFacade.commit() } // do second pass to make sure all FKs created if (tablesAdded > 0 && shouldCreateFks(efi.ecfi)) { logger.info("Tables were created, checking FKs for all entities in group ${groupName}") beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(300) : false try { Connection con = efi.getConnection(groupName) try { DatabaseMetaData dbData = con.getMetaData() // NOTE: don't need to get fresh results for existing table names as created tables are added to the Set above Map>> fkInfoByFkTable = new HashMap<>() ResultSet ikSet = null try { // don't rely on constraint name, look at related table name, keys // get set of fields on main entity to match against (more unique than fields on related entity) ikSet = dbData.getImportedKeys(null, schemaName, "%") while (ikSet.next()) { // logger.info("Existing FK col: PKTABLE_NAME [${ikSet.getString("PKTABLE_NAME")}] PKCOLUMN_NAME [${ikSet.getString("PKCOLUMN_NAME")}] FKTABLE_NAME [${ikSet.getString("FKTABLE_NAME")}] FKCOLUMN_NAME [${ikSet.getString("FKCOLUMN_NAME")}]") String pkTable = ikSet.getString("PKTABLE_NAME") String fkTable = ikSet.getString("FKTABLE_NAME") String fkCol = ikSet.getString("FKCOLUMN_NAME") Map> fkInfo = (Map>) fkInfoByFkTable.get(fkTable) if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) } Set fkColsFound = (Set) fkInfo.get(pkTable) if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) } fkColsFound.add(fkCol) } } catch (Exception e) { logger.error("Error getting all foreign keys for group ${groupName}", e) } finally { if (ikSet != null && !ikSet.isClosed()) ikSet.close() } if (fkInfoByFkTable.size() == 0) { logger.warn("Bulk find imported keys got no results for group ${groupName}, getting per table (slower)") for (String entityName in groupEntityNames) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed.isViewEntity) continue String fkTable = ed.getTableName() boolean gotIkResults = false try { ikSet = dbData.getImportedKeys(null, schemaName, fkTable) while (ikSet.next()) { gotIkResults = true String pkTable = ikSet.getString("PKTABLE_NAME") String fkCol = ikSet.getString("FKCOLUMN_NAME") Map> fkInfo = (Map>) fkInfoByFkTable.get(fkTable) if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) } Set fkColsFound = (Set) fkInfo.get(pkTable) if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) } fkColsFound.add(fkCol) } } catch (Exception e) { logger.error("Error getting foreign keys for entity ${entityName} group ${groupName}", e) } finally { if (ikSet != null && !ikSet.isClosed()) ikSet.close() } if (!gotIkResults) { // no results found, try lower case table name try { ikSet = dbData.getImportedKeys(null, schemaName, fkTable.toLowerCase()) while (ikSet.next()) { String pkTable = ikSet.getString("PKTABLE_NAME") String fkCol = ikSet.getString("FKCOLUMN_NAME") Map> fkInfo = (Map>) fkInfoByFkTable.get(fkTable) if (fkInfo == null) { fkInfo = new HashMap(); fkInfoByFkTable.put(fkTable, fkInfo) } Set fkColsFound = (Set) fkInfo.get(pkTable) if (fkColsFound == null) { fkColsFound = new HashSet<>(); fkInfo.put(pkTable, fkColsFound) } fkColsFound.add(fkCol) } } catch (Exception e) { logger.error("Error getting foreign keys for entity ${entityName} group ${groupName}", e) } finally { if (ikSet != null && !ikSet.isClosed()) ikSet.close() } } } } for (String entityName in groupEntityNames) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed.isViewEntity) continue // use one big query for all FKs instead of per entity/table // createForeignKeys(ed, true) // fkTable is current entity's table name String fkTable = ed.getTableName() Map> fkInfo = (Map>) fkInfoByFkTable.get(fkTable) if (fkInfo == null) fkInfo = (Map>) fkInfoByFkTable.get(fkTable.toLowerCase()) // if (fkInfo == null) logger.warn("No FK info found for table ${fkTable}") int fksCreated = 0 int relOneCount = 0 int noRelTableCount = 0 for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue relOneCount++ EntityDefinition relEd = relInfo.relatedEd String relTableName = relEd.getTableName() if (!existingTableNames.contains(relTableName) && !existingTableNames.contains(relTableName.toLowerCase())) { if (logger.traceEnabled) logger.trace("Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table ${relTableName}") noRelTableCount++ continue } Map keyMap = relInfo.keyMap ArrayList fieldNames = new ArrayList(keyMap.keySet()) if (fkInfo != null) { // pkTable is related entity's table name String pkTable = relTableName Set fkColsFound = (Set) fkInfo.get(pkTable) if (fkColsFound == null) fkColsFound = (Set) fkInfo.get(pkTable.toLowerCase()) if (fkColsFound != null) { for (int fni = 0; fni < fieldNames.size(); ) { String fieldName = (String) fieldNames.get(fni) String colName = ed.getColumnName(fieldName) if (fkColsFound.contains(colName) || fkColsFound.contains(colName.toLowerCase())) { fieldNames.remove(fni) } else { fni++ } } } else { // logger.warn("No FK info found for FK table ${fkTable} PK table ${pkTable}") } // logger.info("Checking FK exists for entity [${ed.getFullEntityName()}] relationship [${relNode."@title"}${relEd.getFullEntityName()}] fields to match are [${keyMap.keySet()}] FK columns found [${fkColsFound}] final fieldNames (empty for match) [${fieldNames}]") } // if we found all of the key-map field-names then fieldNames will be empty, and we have a full fk if (fieldNames.size() > 0) { try { createForeignKey(ed, relInfo, relEd, con) fksCreated++ } catch (Throwable t) { logger.error("Error creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute("title")}", t) } } } if (noRelTableCount > 0) logger.warn("In full FK check found ${noRelTableCount} type one relationships where no table exists for related entity") if (fksCreated > 0) logger.info("Created ${fksCreated} FKs out of ${relOneCount} type one relationships for entity ${entityName}") } } finally { if (con != null) con.close() } } finally { if (beganTx) efi.ecfi.transactionFacade.commit() } } return tablesAdded } void forceCheckTableRuntime(EntityDefinition ed) { entityTablesExist.remove(ed.getFullEntityName()) entityTablesChecked.remove(ed.getFullEntityName()) checkTableRuntime(ed) } void forceCheckExistingTables() { entityTablesExist.clear() entityTablesChecked.clear() for (String entityName in efi.getAllEntityNames()) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed.isViewEntity) continue if (tableExists(ed)) checkTableRuntime(ed) } } synchronized boolean internalCheckTable(EntityDefinition ed, boolean startup) { // if it's in this table we've already checked it if (entityTablesChecked.containsKey(ed.getFullEntityName())) return false MNode datasourceNode = efi.getDatasourceNode(ed.getEntityGroupName()) // if there is no @database-conf-name skip this, it's probably not a SQL/JDBC datasource if (!datasourceNode.attribute('database-conf-name')) return false long startTime = System.currentTimeMillis() boolean doCreate = !tableExists(ed) if (doCreate) { createTable(ed, null) // create explicit and foreign key auto indexes createIndexes(ed, false, null) // create foreign keys to all other tables that exist createForeignKeys(ed, false, null, null) } else { // table exists, see if it is missing any columns ArrayList mcs = getMissingColumns(ed) int mcsSize = mcs.size() for (int i = 0; i < mcsSize; i++) addColumn(ed, (FieldInfo) mcs.get(i), null) // create foreign keys after checking each to see if it already exists if (startup) { createForeignKeys(ed, true, null, null) } else { MNode dbNode = efi.getDatabaseNode(ed.getEntityGroupName()) String runtimeAddFks = datasourceNode.attribute("runtime-add-fks") ?: "true" if ((!runtimeAddFks && "true".equals(dbNode.attribute("default-runtime-add-fks"))) || "true".equals(runtimeAddFks)) createForeignKeys(ed, true, null, null) } } entityTablesChecked.put(ed.getFullEntityName(), new Timestamp(System.currentTimeMillis())) entityTablesExist.put(ed.getFullEntityName(), true) if (logger.isTraceEnabled()) logger.trace("Checked table for entity [${ed.getFullEntityName()}] in ${(System.currentTimeMillis()-startTime)/1000} seconds") return doCreate } boolean tableExists(EntityDefinition ed) { Boolean exists = entityTablesExist.get(ed.getFullEntityName()) if (exists != null) return exists.booleanValue() return tableExistsInternal(ed) } synchronized boolean tableExistsInternal(EntityDefinition ed) { Boolean exists = entityTablesExist.get(ed.getFullEntityName()) if (exists != null) return exists.booleanValue() Boolean dbResult = null if (ed.isViewEntity) { boolean anyExist = false for (MNode memberEntityNode in ed.entityNode.children("member-entity")) { EntityDefinition med = efi.getEntityDefinition(memberEntityNode.attribute("entity-name")) if (tableExists(med)) { anyExist = true; break } } dbResult = anyExist } else { String groupName = ed.getEntityGroupName() Connection con = null ResultSet tableSet1 = null ResultSet tableSet2 = null boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(5) : false try { try { con = efi.getConnection(groupName) } catch (EntityException ee) { logger.warn("Could not get connection so treating entity ${ed.fullEntityName} in group ${groupName} as table does not exist: ${ee.toString()}") return false } DatabaseMetaData dbData = con.getMetaData() String[] types = ["TABLE", "VIEW", "ALIAS", "SYNONYM", "PARTITIONED TABLE"] tableSet1 = dbData.getTables(con.getCatalog(), ed.getSchemaName(), ed.getTableName(), types) if (tableSet1.next()) { dbResult = true } else { // try lower case, just in case DB is case sensitive tableSet2 = dbData.getTables(con.getCatalog(), ed.getSchemaName(), ed.getTableName().toLowerCase(), types) if (tableSet2.next()) { dbResult = true } else { if (logger.isTraceEnabled()) logger.trace("Table for entity ${ed.getFullEntityName()} does NOT exist") dbResult = false } } } catch (Exception e) { throw new EntityException("Exception checking to see if table ${ed.getTableName()} exists", e) } finally { if (tableSet1 != null && !tableSet1.isClosed()) tableSet1.close() if (tableSet2 != null && !tableSet2.isClosed()) tableSet2.close() if (con != null) con.close() if (beganTx) efi.ecfi.transactionFacade.commit() } } if (dbResult == null) throw new EntityException("No result checking if entity ${ed.getFullEntityName()} table exists") if (dbResult && !ed.isViewEntity) { // on the first check also make sure all columns/etc exist; we'll do this even on read/exist check otherwise query will blow up when doesn't exist ArrayList mcs = getMissingColumns(ed) int mcsSize = mcs.size() for (int i = 0; i < mcsSize; i++) addColumn(ed, (FieldInfo) mcs.get(i), null) } // don't remember the result for view-entities, get if from member-entities... if we remember it we have to set // it for all view-entities when a member-entity is created if (!ed.isViewEntity) entityTablesExist.put(ed.getFullEntityName(), dbResult) return dbResult } void createTable(EntityDefinition ed, Connection sharedCon) { if (ed == null) throw new IllegalArgumentException("No EntityDefinition specified, cannot create table") if (ed.isViewEntity) throw new IllegalArgumentException("Cannot create table for a view entity") String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) StringBuilder sql = new StringBuilder("CREATE TABLE ").append(ed.getFullTableName()).append(" (") FieldInfo[] allFieldInfoArray = ed.entityInfo.allFieldInfoArray for (int i = 0; i < allFieldInfoArray.length; i++) { FieldInfo fi = (FieldInfo) allFieldInfoArray[i] MNode fieldNode = fi.fieldNode String sqlType = efi.getFieldSqlType(fi.type, ed) String javaType = fi.javaType sql.append(fi.columnName).append(" ").append(sqlType) if ("String" == javaType || "java.lang.String" == javaType) { if (databaseNode.attribute("character-set")) sql.append(" CHARACTER SET ").append(databaseNode.attribute("character-set")) if (databaseNode.attribute("collate")) sql.append(" COLLATE ").append(databaseNode.attribute("collate")) } if (fi.isPk || fieldNode.attribute("not-null") == "true") { if (databaseNode.attribute("always-use-constraint-keyword") == "true") sql.append(" CONSTRAINT") sql.append(" NOT NULL") } sql.append(", ") } if (databaseNode.attribute("use-pk-constraint-names") != "false") { String pkName = "PK_" + ed.getTableName() int constraintNameClipLength = (databaseNode.attribute("constraint-name-clip-length")?:"30") as int if (pkName.length() > constraintNameClipLength) pkName = pkName.substring(0, constraintNameClipLength) sql.append("CONSTRAINT ") if (databaseNode.attribute("use-schema-for-all") == "true") sql.append(ed.getSchemaName() ? ed.getSchemaName() + "." : "") sql.append(pkName) } sql.append(" PRIMARY KEY (") FieldInfo[] pkFieldInfoArray = ed.entityInfo.pkFieldInfoArray for (int i = 0; i < pkFieldInfoArray.length; i++) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[i] if (i > 0) sql.append(", ") sql.append(fi.getFullColumnName()) } sql.append("))") // some MySQL-specific inconveniences... if (databaseNode.attribute("table-engine")) sql.append(" ENGINE ").append(databaseNode.attribute("table-engine")) if (databaseNode.attribute("character-set")) sql.append(" CHARACTER SET ").append(databaseNode.attribute("character-set")) if (databaseNode.attribute("collate")) sql.append(" COLLATE ").append(databaseNode.attribute("collate")) logger.info("Creating table for ${ed.getFullEntityName()} pks: ${ed.getPkFieldNames()}") if (logger.traceEnabled) logger.trace("Create Table with SQL: " + sql.toString()) runSqlUpdate(sql, groupName, sharedCon) if (logger.infoEnabled) logger.info("Created table ${ed.getFullTableName()} for entity ${ed.getFullEntityName()} in group ${groupName}") } ArrayList getMissingColumns(EntityDefinition ed) { if (ed.isViewEntity) return new ArrayList() String groupName = ed.getEntityGroupName() Connection con = null ResultSet colSet1 = null ResultSet colSet2 = null boolean beganTx = useTxForMetaData ? efi.ecfi.transactionFacade.begin(5) : false try { con = efi.getConnection(groupName) DatabaseMetaData dbData = con.getMetaData() // con.setAutoCommit(false) ArrayList fieldInfos = new ArrayList<>(ed.allFieldInfoList) int fieldCount = fieldInfos.size() colSet1 = dbData.getColumns(con.getCatalog(), ed.getSchemaName(), ed.getTableName(), "%") if (colSet1.isClosed()) { logger.error("Tried to get columns for entity ${ed.getFullEntityName()} but ResultSet was closed!") return new ArrayList() } while (colSet1.next()) { String colName = colSet1.getString("COLUMN_NAME") int fieldInfosSize = fieldInfos.size() for (int i = 0; i < fieldInfosSize; i++) { FieldInfo fi = (FieldInfo) fieldInfos.get(i) if (fi.columnName == colName || fi.columnName.toLowerCase() == colName) { fieldInfos.remove(i) break } } } if (fieldInfos.size() == fieldCount) { // try lower case table name colSet2 = dbData.getColumns(con.getCatalog(), ed.getSchemaName(), ed.getTableName().toLowerCase(), "%") if (colSet2.isClosed()) { logger.error("Tried to get columns for entity ${ed.getFullEntityName()} but ResultSet was closed!") return new ArrayList() } while (colSet2.next()) { String colName = colSet2.getString("COLUMN_NAME") int fieldInfosSize = fieldInfos.size() for (int i = 0; i < fieldInfosSize; i++) { FieldInfo fi = (FieldInfo) fieldInfos.get(i) if (fi.columnName == colName || fi.columnName.toLowerCase() == colName) { fieldInfos.remove(i) break } } } if (fieldInfos.size() == fieldCount) { logger.warn("Could not find any columns to match fields for entity ${ed.getFullEntityName()}") return new ArrayList() } } return fieldInfos } catch (Exception e) { logger.error("Exception checking for missing columns in table ${ed.getTableName()}", e) return new ArrayList() } finally { if (colSet1 != null && !colSet1.isClosed()) colSet1.close() if (colSet2 != null && !colSet2.isClosed()) colSet2.close() if (con != null && !con.isClosed()) con.close() if (beganTx) efi.ecfi.transactionFacade.commit() } } void addColumn(EntityDefinition ed, FieldInfo fi, Connection sharedCon) { if (ed == null) throw new IllegalArgumentException("No EntityDefinition specified, cannot add column") if (ed.isViewEntity) throw new IllegalArgumentException("Cannot add column for a view entity") String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) MNode fieldNode = fi.fieldNode String sqlType = efi.getFieldSqlType(fieldNode.attribute("type"), ed) String javaType = efi.getFieldJavaType(fieldNode.attribute("type"), ed) StringBuilder sql = new StringBuilder("ALTER TABLE ").append(ed.getFullTableName()) String colName = fi.columnName // NOTE: if any databases need "ADD COLUMN" instead of just "ADD", change this to try both or based on config sql.append(" ADD ").append(colName).append(" ").append(sqlType) if ("String" == javaType || "java.lang.String" == javaType) { if (databaseNode.attribute("character-set")) sql.append(" CHARACTER SET ").append(databaseNode.attribute("character-set")) if (databaseNode.attribute("collate")) sql.append(" COLLATE ").append(databaseNode.attribute("collate")) } runSqlUpdate(sql, groupName, sharedCon) if (logger.infoEnabled) logger.info("Added column ${colName} to table ${ed.tableName} for field ${fi.name} of entity ${ed.getFullEntityName()} in group ${groupName}") } int createIndexes(EntityDefinition ed, boolean checkIdxExists, Connection sharedCon) { if (ed == null) throw new IllegalArgumentException("No EntityDefinition specified, cannot create indexes") if (ed.isViewEntity) throw new IllegalArgumentException("Cannot create indexes for a view entity") String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) if (databaseNode.attribute("use-indexes") == "false") return 0 int constraintNameClipLength = (databaseNode.attribute("constraint-name-clip-length")?:"30") as int // first do index elements int created = 0 for (MNode indexNode in ed.entityNode.children("index")) { String indexName = indexNode.attribute('name') if (checkIdxExists) { Boolean idxExists = indexExists(ed, indexName, indexNode.children("index-field").collect {it.attribute('name')}) if (idxExists != null && idxExists) { if (logger.infoEnabled) logger.info("Not creating index ${indexName} for entity ${ed.getFullEntityName()} because it already exists.") continue } } StringBuilder sql = new StringBuilder("CREATE ") if (databaseNode.attribute("use-indexes-unique") != "false" && indexNode.attribute("unique") == "true") { sql.append("UNIQUE ") if (databaseNode.attribute("use-indexes-unique-where-not-null") == "true") sql.append("WHERE NOT NULL ") } sql.append("INDEX ") if (databaseNode.attribute("use-schema-for-all") == "true") sql.append(ed.getSchemaName() ? ed.getSchemaName() + "." : "") sql.append(indexNode.attribute("name")).append(" ON ").append(ed.getFullTableName()) sql.append(" (") boolean isFirst = true for (MNode indexFieldNode in indexNode.children("index-field")) { if (isFirst) isFirst = false else sql.append(", ") sql.append(ed.getColumnName(indexFieldNode.attribute("name"))) } sql.append(")") Integer curCreated = runSqlUpdate(sql, groupName, sharedCon) if (curCreated != null) { if (logger.infoEnabled) logger.info("Created index ${indexName} for entity ${ed.getFullEntityName()}") created ++ } } // do fk auto indexes // nothing after fk indexes to return now if disabled if (databaseNode.attribute("use-foreign-key-indexes") == "false") return created for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue String indexName = makeFkIndexName(ed, relInfo, constraintNameClipLength) if (checkIdxExists) { Boolean idxExists = indexExists(ed, indexName, relInfo.keyMap.keySet()) if (idxExists != null && idxExists) { if (logger.infoEnabled) logger.info("Not creating index ${indexName} for entity ${ed.getFullEntityName()} because it already exists.") continue } } StringBuilder sql = new StringBuilder("CREATE INDEX ") if (databaseNode.attribute("use-schema-for-all") == "true") sql.append(ed.getSchemaName() ? ed.getSchemaName() + "." : "") sql.append(indexName).append(" ON ").append(ed.getFullTableName()) sql.append(" (") Map keyMap = relInfo.keyMap boolean isFirst = true for (String fieldName in keyMap.keySet()) { if (isFirst) isFirst = false else sql.append(", ") sql.append(ed.getColumnName(fieldName)) } sql.append(")") // logger.warn("====== create relationship index [${indexName}] for entity [${ed.getFullEntityName()}]") Integer curCreated = runSqlUpdate(sql, groupName, sharedCon) if (curCreated != null) { if (logger.infoEnabled) logger.info("Created index ${indexName} for entity ${ed.getFullEntityName()}") created ++ } } return created } static String makeFkIndexName(EntityDefinition ed, RelationshipInfo relInfo, int constraintNameClipLength) { String relatedEntityName = relInfo.relatedEd.entityInfo.internalEntityName StringBuilder indexName = new StringBuilder() if (relInfo.relNode.attribute("fk-name")) indexName.append(relInfo.relNode.attribute("fk-name")) if (!indexName) { String title = relInfo.title ?: "" String edEntityName = ed.entityInfo.internalEntityName int edEntityNameLength = edEntityName.length() int commonChars = 0 while (title.length() > commonChars && edEntityNameLength > commonChars && title.charAt(commonChars) == edEntityName.charAt(commonChars)) commonChars++ int relLength = relatedEntityName.length() int relEndCommonChars = relatedEntityName.length() - 1 while (relEndCommonChars > 0 && edEntityNameLength > relEndCommonChars && relatedEntityName.charAt(relEndCommonChars) == edEntityName.charAt(edEntityNameLength - (relLength - relEndCommonChars))) relEndCommonChars-- if (commonChars > 0) { indexName.append(edEntityName) for (char cc in title.substring(0, commonChars).chars) if (Character.isUpperCase(cc)) indexName.append(cc) indexName.append(title.substring(commonChars)) indexName.append(relatedEntityName.substring(0, relEndCommonChars + 1)) if (relEndCommonChars < (relLength - 1)) for (char cc in relatedEntityName.substring(relEndCommonChars + 1).chars) if (Character.isUpperCase(cc)) indexName.append(cc) } else { indexName.append(edEntityName).append(title) indexName.append(relatedEntityName.substring(0, relEndCommonChars + 1)) if (relEndCommonChars < (relLength - 1)) for (char cc in relatedEntityName.substring(relEndCommonChars + 1).chars) if (Character.isUpperCase(cc)) indexName.append(cc) } // logger.warn("Index for entity [${ed.getFullEntityName()}], title=${title}, commonChars=${commonChars}, indexName=${indexName}") // logger.warn("Index for entity [${ed.getFullEntityName()}], relatedEntityName=${relatedEntityName}, relEndCommonChars=${relEndCommonChars}, indexName=${indexName}") } shrinkName(indexName, constraintNameClipLength - 3) indexName.insert(0, "IDX") return indexName.toString() } int createIndexesForExistingTables() { int created = 0 for (String en in efi.getAllEntityNames()) { EntityDefinition ed = efi.getEntityDefinition(en) if (ed.isViewEntity) continue if (tableExists(ed)) { int result = createIndexes(ed, true, null) created += result } } return created } /** Loop through all known entities and for each that has an existing table check each foreign key to see if it * exists in the database, and if it doesn't but the related table does exist then add the foreign key. */ int createForeignKeysForExistingTables() { int created = 0 for (String en in efi.getAllEntityNames()) { EntityDefinition ed = efi.getEntityDefinition(en) if (ed.isViewEntity) continue if (tableExists(ed)) { int result = createForeignKeys(ed, true, null, null) created += result } } return created } int dropAllForeignKeys() { int dropped = 0 for (String en in efi.getAllEntityNames()) { EntityDefinition ed = efi.getEntityDefinition(en) if (ed.isViewEntity) continue if (tableExists(ed)) { int result = dropForeignKeys(ed) logger.info("Dropped ${result} FKs for entity ${ed.fullEntityName}") dropped += result } } return dropped } Boolean indexExists(EntityDefinition ed, String indexName, Collection indexFields) { String groupName = ed.getEntityGroupName() Connection con = null ResultSet ikSet1 = null ResultSet ikSet2 = null try { con = efi.getConnection(groupName) DatabaseMetaData dbData = con.getMetaData() Set fieldNames = new HashSet(indexFields) ikSet1 = dbData.getIndexInfo(null, ed.getSchemaName(), ed.getTableName(), false, true) while (ikSet1.next()) { String dbIdxName = ikSet1.getString("INDEX_NAME") if (dbIdxName == null || dbIdxName.toLowerCase() != indexName.toLowerCase()) continue String idxCol = ikSet1.getString("COLUMN_NAME") for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName.toLowerCase() == idxCol.toLowerCase()) { fieldNames.remove(fn) break } } } if (fieldNames.size() > 0) { // try with lower case table name ikSet2 = dbData.getIndexInfo(null, ed.getSchemaName(), ed.getTableName().toLowerCase(), false, true) while (ikSet2.next()) { String dbIdxName = ikSet2.getString("INDEX_NAME") if (dbIdxName == null || dbIdxName.toLowerCase() != indexName.toLowerCase()) continue String idxCol = ikSet2.getString("COLUMN_NAME") for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName.toLowerCase() == idxCol.toLowerCase()) { fieldNames.remove(fn) break } } } } // if we found all of the index-field field-names then fieldNames will be empty, and we have a full index return (fieldNames.size() == 0) } catch (Exception e) { logger.error("Exception checking to see if index exists for table ${ed.getTableName()}", e) return null } finally { if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close() if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close() if (con != null) con.close() } } Boolean foreignKeyExists(EntityDefinition ed, RelationshipInfo relInfo) { String groupName = ed.getEntityGroupName() EntityDefinition relEd = relInfo.relatedEd Connection con = null ResultSet ikSet1 = null ResultSet ikSet2 = null try { con = efi.getConnection(groupName) DatabaseMetaData dbData = con.getMetaData() // don't rely on constraint name, look at related table name, keys // get set of fields on main entity to match against (more unique than fields on related entity) Map keyMap = relInfo.keyMap Set fieldNames = new HashSet(keyMap.keySet()) Set fkColsFound = new HashSet() ikSet1 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName()) while (ikSet1.next()) { String pkTable = ikSet1.getString("PKTABLE_NAME") // logger.info("FK exists [${ed.getFullEntityName()}] - [${relNode."@title"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString("PKTABLE_NAME")}] PKCOLUMN_NAME [${ikSet.getString("PKCOLUMN_NAME")}] FKCOLUMN_NAME [${ikSet.getString("FKCOLUMN_NAME")}]") if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue String fkCol = ikSet1.getString("FKCOLUMN_NAME") fkColsFound.add(fkCol) for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) { fieldNames.remove(fn) break } } } if (fieldNames.size() > 0) { // try with lower case table name ikSet2 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName().toLowerCase()) while (ikSet2.next()) { String pkTable = ikSet2.getString("PKTABLE_NAME") // logger.info("FK exists [${ed.getFullEntityName()}] - [${relNode."@title"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString("PKTABLE_NAME")}] PKCOLUMN_NAME [${ikSet.getString("PKCOLUMN_NAME")}] FKCOLUMN_NAME [${ikSet.getString("FKCOLUMN_NAME")}]") if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue String fkCol = ikSet2.getString("FKCOLUMN_NAME") fkColsFound.add(fkCol) for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) { fieldNames.remove(fn) break } } } } // logger.info("Checking FK exists for entity [${ed.getFullEntityName()}] relationship [${relNode."@title"}${relEd.getFullEntityName()}] fields to match are [${keyMap.keySet()}] FK columns found [${fkColsFound}] final fieldNames (empty for match) [${fieldNames}]") // if we found all of the key-map field-names then fieldNames will be empty, and we have a full fk return (fieldNames.size() == 0) } catch (Exception e) { logger.error("Exception checking to see if foreign key exists for table ${ed.getTableName()}", e) return null } finally { if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close() if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close() if (con != null) con.close() } } String getForeignKeyName(EntityDefinition ed, RelationshipInfo relInfo) { String groupName = ed.getEntityGroupName() EntityDefinition relEd = relInfo.relatedEd Connection con = null ResultSet ikSet1 = null ResultSet ikSet2 = null try { con = efi.getConnection(groupName) DatabaseMetaData dbData = con.getMetaData() // don't rely on constraint name, look at related table name, keys // get set of fields on main entity to match against (more unique than fields on related entity) Map keyMap = relInfo.keyMap List fieldNames = new ArrayList(keyMap.keySet()) Map> fieldsByFkName = new HashMap<>() ikSet1 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName()) while (ikSet1.next()) { String pkTable = ikSet1.getString("PKTABLE_NAME") // logger.info("FK exists [${ed.getFullEntityName()}] - [${relNode."@title"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString("PKTABLE_NAME")}] PKCOLUMN_NAME [${ikSet.getString("PKCOLUMN_NAME")}] FKCOLUMN_NAME [${ikSet.getString("FKCOLUMN_NAME")}]") if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue String fkCol = ikSet1.getString("FKCOLUMN_NAME") String fkName = ikSet1.getString("FK_NAME") // logger.warn("FK pktable ${pkTable} fkcol ${fkCol} fkName ${fkName}") if (!fkName) continue for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) { CollectionUtilities.addToSetInMap(fkName, fn, fieldsByFkName) break } } } if (fieldNames.size() > 0) { // try with lower case table name ikSet2 = dbData.getImportedKeys(null, ed.getSchemaName(), ed.getTableName().toLowerCase()) while (ikSet2.next()) { String pkTable = ikSet2.getString("PKTABLE_NAME") // logger.info("FK exists [${ed.getFullEntityName()}] - [${relNode."@title"}${relEd.getFullEntityName()}] PKTABLE_NAME [${ikSet.getString("PKTABLE_NAME")}] PKCOLUMN_NAME [${ikSet.getString("PKCOLUMN_NAME")}] FKCOLUMN_NAME [${ikSet.getString("FKCOLUMN_NAME")}]") if (pkTable != relEd.getTableName() && pkTable != relEd.getTableName().toLowerCase()) continue String fkCol = ikSet2.getString("FKCOLUMN_NAME") String fkName = ikSet2.getString("FK_NAME") // logger.warn("FK pktable ${pkTable} fkcol ${fkCol} fkName ${fkName}") if (!fkName) continue for (String fn in fieldNames) { String fnColName = ed.getColumnName(fn) if (fnColName == fkCol || fnColName.toLowerCase() == fkCol) { CollectionUtilities.addToSetInMap(fkName, fn, fieldsByFkName) break } } } } // logger.warn("fieldNames: ${fieldNames}"); logger.warn("fieldsByFkName: ${fieldsByFkName}") for (Map.Entry> entry in fieldsByFkName.entrySet()) { if (entry.value.containsAll(fieldNames)) return entry.key } return null } catch (Exception e) { logger.error("Exception getting foreign key name for table ${ed.getTableName()}", e) return null } finally { if (ikSet1 != null && !ikSet1.isClosed()) ikSet1.close() if (ikSet2 != null && !ikSet2.isClosed()) ikSet2.close() if (con != null) con.close() } } int createForeignKeys(EntityDefinition ed, boolean checkFkExists, Set existingTableNames, Connection sharedCon) { if (ed == null) throw new IllegalArgumentException("No EntityDefinition specified, cannot create foreign keys") if (ed.isViewEntity) throw new IllegalArgumentException("Cannot create foreign keys for a view entity") if (!shouldCreateFks(ed.getEfi().ecfi)) return 0 // NOTE: in order to get all FKs in place by the time they are used we will probably need to check all incoming // FKs as well as outgoing because of entity use order, tables not rechecked after first hit, etc // NOTE2: with the createForeignKeysForExistingTables() method this isn't strictly necessary, that can be run // after the system is run for a bit and/or all tables desired have been created and it will take care of it String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) if (databaseNode.attribute("use-foreign-keys") == "false") return 0 int created = 0 for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue EntityDefinition relEd = relInfo.relatedEd String relTableName = relEd.getTableName() boolean relTableExists if (existingTableNames != null) { relTableExists = existingTableNames.contains(relTableName) || existingTableNames.contains(relTableName.toLowerCase()) } else { relTableExists = tableExists(relEd) } if (!relTableExists) { if (logger.traceEnabled) logger.trace("Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table") continue } if (checkFkExists) { Boolean fkExists = foreignKeyExists(ed, relInfo) if (fkExists != null && fkExists) { if (logger.traceEnabled) logger.trace("Not creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute("title")} because it already exists (matched by key mappings)") continue } // if we get a null back there was an error, and we'll try to create the FK, which may result in another error } try { createForeignKey(ed, relInfo, relEd, sharedCon) created++ } catch (Throwable t) { logger.error("Error creating foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute("title")}", t) } } if (created > 0 && checkFkExists) logger.info("Created ${created} FKs for entity ${ed.fullEntityName}") return created } void createForeignKey(EntityDefinition ed, RelationshipInfo relInfo, EntityDefinition relEd, Connection sharedCon) { String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) int constraintNameClipLength = (databaseNode.attribute("constraint-name-clip-length")?:"30") as int String constraintName = makeFkConstraintName(ed, relInfo, constraintNameClipLength) Map keyMap = relInfo.keyMap List keyMapKeys = new ArrayList(keyMap.keySet()) StringBuilder sql = new StringBuilder("ALTER TABLE ").append(ed.getFullTableName()).append(" ADD ") if (databaseNode.attribute("fk-style") == "name_fk") { sql.append("FOREIGN KEY ").append(constraintName).append(" (") boolean isFirst = true for (String fieldName in keyMapKeys) { if (isFirst) isFirst = false else sql.append(", ") sql.append(ed.getColumnName(fieldName)) } sql.append(")") } else { sql.append("CONSTRAINT ") if (databaseNode.attribute("use-schema-for-all") == "true") sql.append(ed.getSchemaName() ? ed.getSchemaName() + "." : "") sql.append(constraintName).append(" FOREIGN KEY (") boolean isFirst = true for (String fieldName in keyMapKeys) { if (isFirst) isFirst = false else sql.append(", ") sql.append(ed.getColumnName(fieldName)) } sql.append(")") } sql.append(" REFERENCES ").append(relEd.getFullTableName()).append(" (") boolean isFirst = true for (String keyName in keyMapKeys) { if (isFirst) isFirst = false else sql.append(", ") sql.append(relEd.getColumnName((String) keyMap.get(keyName))) } sql.append(")") if (databaseNode.attribute("use-fk-initially-deferred") == "true") { sql.append(" INITIALLY DEFERRED") } runSqlUpdate(sql, groupName, sharedCon) } int dropForeignKeys(EntityDefinition ed) { if (ed == null) throw new IllegalArgumentException("No EntityDefinition specified, cannot drop foreign keys") if (ed.isViewEntity) throw new IllegalArgumentException("Cannot drop foreign keys for a view entity") // NOTE: in order to get all FKs in place by the time they are used we will probably need to check all incoming // FKs as well as outgoing because of entity use order, tables not rechecked after first hit, etc // NOTE2: with the createForeignKeysForExistingTables() method this isn't strictly necessary, that can be run // after the system is run for a bit and/or all tables desired have been created and it will take care of it String groupName = ed.getEntityGroupName() MNode databaseNode = efi.getDatabaseNode(groupName) if (databaseNode.attribute("use-foreign-keys") == "false") return 0 int constraintNameClipLength = (databaseNode.attribute("constraint-name-clip-length")?:"30") as int int dropped = 0 for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue EntityDefinition relEd = relInfo.relatedEd if (!tableExists(relEd)) { if (logger.traceEnabled) logger.trace("Not dropping foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} because related entity does not yet have a table") continue } Boolean fkExists = foreignKeyExists(ed, relInfo) if (fkExists != null && !fkExists) { if (logger.traceEnabled) logger.trace("Not dropping foreign key from entity ${ed.getFullEntityName()} to related entity ${relEd.getFullEntityName()} with title ${relInfo.relNode.attribute("title")} because it does not exist (matched by key mappings)") continue } String fkName = getForeignKeyName(ed, relInfo) String constraintName = fkName ?: makeFkConstraintName(ed, relInfo, constraintNameClipLength) StringBuilder sql = new StringBuilder("ALTER TABLE ").append(ed.getFullTableName()).append(" DROP ") if (databaseNode.attribute("fk-style") == "name_fk") { sql.append("FOREIGN KEY ").append(constraintName.toString()) } else { sql.append("CONSTRAINT ") if (databaseNode.attribute("use-schema-for-all") == "true") sql.append(ed.getSchemaName() ? ed.getSchemaName() + "." : "") sql.append(constraintName.toString()) } Integer records = runSqlUpdate(sql, groupName, null) if (records != null) dropped++ } return dropped } static String makeFkConstraintName(EntityDefinition ed, RelationshipInfo relInfo, int constraintNameClipLength) { StringBuilder constraintName = new StringBuilder() if (relInfo.relNode.attribute("fk-name")) constraintName.append(relInfo.relNode.attribute("fk-name")) if (!constraintName) { EntityDefinition relEd = relInfo.relatedEd String title = relInfo.title ?: "" String edEntityName = ed.entityInfo.internalEntityName int commonChars = 0 while (title.length() > commonChars && edEntityName.length() > commonChars && title.charAt(commonChars) == edEntityName.charAt(commonChars)) commonChars++ String relatedEntityName = relEd.entityInfo.internalEntityName if (commonChars > 0) { constraintName.append(ed.entityInfo.internalEntityName) for (char cc in title.substring(0, commonChars).chars) if (Character.isUpperCase(cc)) constraintName.append(cc) constraintName.append(title.substring(commonChars)).append(relatedEntityName) } else { constraintName.append(ed.entityInfo.internalEntityName).append(title).append(relatedEntityName) } // logger.warn("ed.getFullEntityName()=${ed.entityName}, title=${title}, commonChars=${commonChars}, constraintName=${constraintName}") } shrinkName(constraintName, constraintNameClipLength) return constraintName.toString() } static void shrinkName(StringBuilder name, int maxLength) { if (name.length() > maxLength) { // remove vowels from end toward beginning for (int i = name.length()-1; i >= 0 && name.length() > maxLength; i--) { if ("AEIOUaeiou".contains(name.charAt(i) as String)) name.deleteCharAt(i) } // clip if (name.length() > maxLength) { name.delete(maxLength-1, name.length()) } } } final ReentrantLock sqlLock = new ReentrantLock() Integer runSqlUpdate(CharSequence sql, String groupName, Connection sharedCon) { // only do one DB meta data operation at a time; may lock above before checking for existence of something to make sure it doesn't get created twice sqlLock.lock() Integer records = null try { // use a short timeout here just in case this is in the middle of stuff going on with tables locked, may happen a lot for FK ops efi.ecfi.transactionFacade.runRequireNew(10, "Error in DB meta data change", useTxForMetaData, true, { Connection con = null Statement stmt = null try { con = sharedCon != null ? sharedCon : efi.getConnection(groupName) stmt = con.createStatement() records = stmt.executeUpdate(sql.toString()) } finally { if (stmt != null) stmt.close() if (con != null && sharedCon == null) con.close() } }) } catch (Throwable t) { logger.error("SQL Exception while executing the following SQL [${sql.toString()}]: ${t.toString()}") } finally { sqlLock.unlock() } return records } /* ================= */ /* Liquibase Methods */ /* ================= */ /** Generate a Liquibase Changelog for a set of entity definitions */ MNode liquibaseInitChangelog(String filterRegexp) { MNode rootNode = new MNode("databaseChangeLog", [xmlns:"http://www.liquibase.org/xml/ns/dbchangelog", "xmlns:xsi":"http://www.w3.org/2001/XMLSchema-instance", "xmlns:ext":"http://www.liquibase.org/xml/ns/dbchangelog-ext", "xsi:schemaLocation":"http://www.liquibase.org/xml/ns/dbchangelog http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-2.0.xsd http://www.liquibase.org/xml/ns/dbchangelog-ext http://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-ext.xsd"]) // add property elements for data type dictionary entry for each database // see http://www.liquibase.org/documentation/changelog_parameters.html // see http://www.liquibase.org/databases.html // MNode databaseListNode = efi.ecfi.confXmlRoot.first("database-list") ArrayList dictTypeList = databaseListNode.children("dictionary-type") ArrayList databaseList = databaseListNode.children("database") for (MNode dictType in dictTypeList) { String type = dictType.attribute("type") String propName = "type." + type.replaceAll("-", "_") Set dbmsDefault = new TreeSet<>() for (MNode database in databaseList) { String lbName = database.attribute("lb-name") ?: database.attribute("name") MNode dbTypeNode = database.first({ MNode it -> it.name == 'database-type' && it.attribute("type") == type }) if (dbTypeNode != null) { rootNode.append("property", [name:propName, value:dbTypeNode.attribute("sql-type"), dbms:lbName]) } else { dbmsDefault.add(lbName) } } if (dbmsDefault.size() > 0) rootNode.append("property", [name:propName, value:dictType.attribute("default-sql-type"), dbms:dbmsDefault.join(",")]) } String dateStr = efi.ecfi.l10n.format(new Timestamp(System.currentTimeMillis()), "yyyyMMdd") int changeSetIdx = 1 Set entityNames = efi.getAllEntityNames(filterRegexp) // add changeSet per entity // see http://www.liquibase.org/documentation/generating_changelogs.html for (String en in entityNames) { EntityDefinition ed = null try { ed = efi.getEntityDefinition(en) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) } if (ed == null || ed.isViewEntity) continue MNode changeSet = rootNode.append("changeSet", [author:"moqui-init", id:"${dateStr}-${changeSetIdx}".toString()]) changeSetIdx++ // createTable MNode createTable = changeSet.append("createTable", [name:ed.getTableName()]) if (ed.getSchemaName()) createTable.attributes.put("schemaName", ed.getSchemaName()) FieldInfo[] allFieldInfoArray = ed.entityInfo.allFieldInfoArray for (int i = 0; i < allFieldInfoArray.length; i++) { FieldInfo fi = (FieldInfo) allFieldInfoArray[i] MNode fieldNode = fi.fieldNode MNode column = createTable.append("column", [name:fi.columnName, type:('${type.' + fi.type.replaceAll("-", "_") + '}')]) if (fi.isPk || fieldNode.attribute("not-null") == "true") { MNode constraints = column.append("constraints", [nullable:"false"]) if (fi.isPk) constraints.attributes.put("primaryKey", "true") } } // createIndex: first do index elements for (MNode indexNode in ed.entityNode.children("index")) { MNode createIndex = changeSet.append("createIndex", [indexName:indexNode.attribute("name"), tableName:ed.getTableName()]) if (ed.getSchemaName()) createIndex.attributes.put("schemaName", ed.getSchemaName()) createIndex.attributes.put("unique", indexNode.attribute("unique") ?: "false") for (MNode indexFieldNode in indexNode.children("index-field")) createIndex.append("column", [name:ed.getColumnName(indexFieldNode.attribute("name"))]) } // do fk auto indexes for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue String indexName = makeFkIndexName(ed, relInfo, 30) MNode createIndex = changeSet.append("createIndex", [indexName:indexName, tableName:ed.getTableName(), unique:"false"]) if (ed.getSchemaName()) createIndex.attributes.put("schemaName", ed.getSchemaName()) Map keyMap = relInfo.keyMap for (String fieldName in keyMap.keySet()) createIndex.append("column", [name:ed.getColumnName(fieldName)]) } } // do foreign keys in a separate pass for (String en in entityNames) { EntityDefinition ed = null try { ed = efi.getEntityDefinition(en) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) } if (ed == null || ed.isViewEntity) continue MNode changeSet = rootNode.append("changeSet", [author:"moqui-init", id:"${dateStr}-${changeSetIdx}".toString()]) changeSetIdx++ for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { if (relInfo.type != "one") continue EntityDefinition relEd = relInfo.relatedEd String constraintName = makeFkConstraintName(ed, relInfo, 30) Map keyMap = relInfo.keyMap List keyMapKeys = new ArrayList(keyMap.keySet()) StringBuilder baseNames = new StringBuilder() for (String fieldName in keyMapKeys) { if (baseNames.length() > 0) baseNames.append(",") baseNames.append(ed.getColumnName(fieldName)) } StringBuilder referencedNames = new StringBuilder() for (String keyName in keyMapKeys) { if (referencedNames.length() > 0) referencedNames.append(",") referencedNames.append(relEd.getColumnName((String) keyMap.get(keyName))) } MNode addForeignKeyConstraint = changeSet.append("addForeignKeyConstraint", [baseTableName:ed.getTableName(), baseColumnNames:baseNames.toString(), constraintName:constraintName, referencedTableName:relEd.getTableName(), referencedColumnNames:referencedNames.toString()]) if (ed.getSchemaName()) addForeignKeyConstraint.attributes.put("baseTableSchemaName", ed.getSchemaName()) if (relEd.getSchemaName()) addForeignKeyConstraint.attributes.put("referencedTableSchemaName", relEd.getSchemaName()) } } return rootNode } MNode liquibaseDiffChangelog(String filterRegexp) { return null } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDefinition.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.entity.EntityFind import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.condition.ConditionAlias import org.moqui.impl.entity.condition.DateCondition import org.moqui.util.LiteStringMap import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import javax.cache.Cache import java.sql.Timestamp import org.moqui.entity.EntityCondition import org.moqui.entity.EntityCondition.JoinOperator import org.moqui.entity.EntityException import org.moqui.entity.EntityValue import org.moqui.impl.entity.condition.EntityConditionImplBase import org.moqui.impl.entity.condition.ConditionField import org.moqui.impl.entity.condition.FieldValueCondition import org.moqui.impl.entity.condition.FieldToFieldCondition import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class EntityDefinition { protected final static Logger logger = LoggerFactory.getLogger(EntityDefinition.class) protected final EntityFacadeImpl efi public final MNode internalEntityNode public final String fullEntityName // NOTE: these fields were primitive type boolean, changed to object type Boolean because of issue introduced in // Groovy 3.0.10; with boolean it worked fine in 3.0.9, after that once the constructor completes true values in // these two fields get flipped to false; see commented logs at EntityDefinition.groovy:94-95, EntityFindBuild.java:112-114 public final Boolean isViewEntity, isDynamicView public final String groupName public final EntityJavaUtil.EntityInfo entityInfo protected final HashMap fieldNodeMap = new HashMap<>() protected final HashMap fieldInfoMap = new HashMap<>() // small lists, but very frequently accessed protected final ArrayList pkFieldNameList = new ArrayList<>() protected final ArrayList nonPkFieldNameList = new ArrayList<>() protected final ArrayList allFieldNameList = new ArrayList<>() protected final ArrayList allFieldInfoList = new ArrayList<>() protected Map pqExpressionNodeMap = null protected Map> mePkFieldToAliasNameMapMap = null protected Map>> memberEntityFieldAliases = null protected Map memberEntityAliasMap = null protected boolean hasSubSelectMembers = false // these are used for every list find, so keep them here public final MNode entityConditionNode public final MNode entityHavingEconditions protected boolean tableExistVerified = false private List expandedRelationshipList = null // this is kept separately for quick access to relationships by name or short-alias private Map relationshipInfoMap = null private ArrayList relationshipInfoList = null private boolean hasReverseRelationships = false private Map masterDefinitionMap = null EntityDefinition(EntityFacadeImpl efi, MNode entityNode) { this.efi = efi // copy the entityNode because we may be modifying it internalEntityNode = entityNode.deepCopy(null) // prepare a few things needed by initFields() before calling it String packageName = internalEntityNode.attribute("package") if (packageName == null || packageName.isEmpty()) packageName = internalEntityNode.attribute("package-name") fullEntityName = packageName + "." + internalEntityNode.attribute("entity-name") isViewEntity = "view-entity".equals(internalEntityNode.getName()) // if (fullEntityName.contains("ArtifactTarpitCheckView") || fullEntityName.contains("DataFeedDocumentDetail")) // logger.warn("===== TOREMOVE ===== entity ${fullEntityName} node ${internalEntityNode.getName()} isViewEntity ${isViewEntity} ${this}") isDynamicView = "true".equals(internalEntityNode.attribute("is-dynamic-view")) boolean memberNeverCache = false if (isViewEntity) { // init some view-entity only fields memberEntityFieldAliases = [:] memberEntityAliasMap = [:] // expand member-relationship into member-entity if (internalEntityNode.hasChild("member-relationship")) for (MNode memberRel in internalEntityNode.children("member-relationship")) { String joinFromAlias = memberRel.attribute("join-from-alias") String relName = memberRel.attribute("relationship") MNode jfme = internalEntityNode.first("member-entity", "entity-alias", joinFromAlias) if (jfme == null) throw new EntityException("Could not find member-entity ${joinFromAlias} referenced in member-relationship ${memberRel.attribute("entity-alias")} of view-entity ${fullEntityName}") String fromEntityName = jfme.attribute("entity-name") EntityDefinition jfed = efi.getEntityDefinition(fromEntityName) if (jfed == null) throw new EntityException("No definition found for member-entity ${jfme.attribute("entity-alias")} name ${fromEntityName} in view-entity ${fullEntityName}") // can't use getRelationshipInfo as not all entities loaded: RelationshipInfo relInfo = jfed.getRelationshipInfo(relName) MNode relNode = jfed.internalEntityNode.first({ MNode it -> "relationship".equals(it.name) && (relName.equals(it.attribute("short-alias")) || relName.equals(it.attribute("related")) || relName.equals(it.attribute("related") + '#' + it.attribute("related"))) }) if (relNode == null) throw new EntityException("Could not find relationship ${relName} from member-entity ${joinFromAlias} referenced in member-relationship ${memberRel.attribute("entity-alias")} of view-entity ${fullEntityName}") // mutate the current MNode memberRel.setName("member-entity") memberRel.attributes.put("entity-name", relNode.attribute("related")) ArrayList kmList = relNode.children("key-map") if (kmList) { for (MNode keyMap in relNode.children("key-map")) memberRel.append("key-map", ["field-name":keyMap.attribute("field-name"), "related":keyMap.attribute("related")]) } else { EntityDefinition relEd = efi.getEntityDefinition(relNode.attribute("related")) for (String pkName in relEd.getPkFieldNames()) memberRel.append("key-map", ["field-name":pkName, "related":pkName]) } } if (internalEntityNode.hasChild("member-relationship")) logger.warn("view-entity ${fullEntityName} members: ${internalEntityNode.children("member-entity")}") // get group, etc from member-entity Set allGroupNames = new TreeSet<>() for (MNode memberEntity in internalEntityNode.children("member-entity")) { String memberEntityName = memberEntity.attribute("entity-name") memberEntityAliasMap.put(memberEntity.attribute("entity-alias"), memberEntity) String subSelectAttr = memberEntity.attribute("sub-select") if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) hasSubSelectMembers = true EntityDefinition memberEd = efi.getEntityDefinition(memberEntityName) if (memberEd == null) throw new EntityException("No definition found for member-entity ${memberEntity.attribute("entity-alias")} name ${memberEntityName} in view-entity ${fullEntityName}") MNode memberEntityNode = memberEd.getEntityNode() String memberGroupAttr = memberEntityNode.attribute("group") ?: memberEntityNode.attribute("group-name") if (memberGroupAttr == null || memberGroupAttr.length() == 0) { // use the default group memberGroupAttr = efi.getDefaultGroupName() } // only set on view-entity for the first/primary member-entity String veGroupAttr = internalEntityNode.attribute("group") if (allGroupNames.size() == 0 && (veGroupAttr == null || veGroupAttr.isEmpty())) internalEntityNode.attributes.put("group", memberGroupAttr) // remember all group names applicable to the view entity allGroupNames.add(memberGroupAttr) // if is view entity and any member entities set to never cache set this to never cache if ("never".equals(memberEntityNode.attribute("cache"))) memberNeverCache = true } // warn if view-entity has members in more than one group (join will fail if deployed in different DBs) if (allGroupNames.size() > 1) logger.warn("view-entity ${getFullEntityName()} has members in more than one group: ${allGroupNames}") } // get group from entity node now that view-entity group handled String groupAttr = internalEntityNode.attribute("group") if (groupAttr == null || groupAttr.isEmpty()) groupAttr = internalEntityNode.attribute("group-name") if (groupAttr == null || groupAttr.isEmpty()) groupAttr = efi.getDefaultGroupName() groupName = groupAttr // now initFields() and create EntityInfo if (isViewEntity) { // if this is a view-entity, expand the alias-all elements into alias elements here this.expandAliasAlls() // set @type, set is-pk on all alias Nodes if the related field is-pk for (MNode aliasNode in internalEntityNode.children("alias")) { if (aliasNode.hasChild("complex-alias") || aliasNode.hasChild("case")) continue if (aliasNode.attribute("pq-expression")) continue String entityAlias = aliasNode.attribute("entity-alias") MNode memberEntity = memberEntityAliasMap.get(entityAlias) if (memberEntity == null) throw new EntityException("Could not find member-entity with entity-alias ${entityAlias} in view-entity ${fullEntityName}") EntityDefinition memberEd = efi.getEntityDefinition(memberEntity.attribute("entity-name")) String fieldName = aliasNode.attribute("field") ?: aliasNode.attribute("name") MNode fieldNode = memberEd.getFieldNode(fieldName) if (fieldNode == null) throw new EntityException("In view-entity ${fullEntityName} alias ${aliasNode.attribute("name")} referred to field ${fieldName} that does not exist on entity ${memberEd.fullEntityName}.") if (!aliasNode.attribute("type")) aliasNode.attributes.put("type", fieldNode.attribute("type")) if ("true".equals(fieldNode.attribute("is-pk"))) aliasNode.attributes.put("is-pk", "true") if ("true".equals(fieldNode.attribute("enable-localization"))) aliasNode.attributes.put("enable-localization", "true") if ("true".equals(fieldNode.attribute("encrypt"))) aliasNode.attributes.put("encrypt", "true") // add to aliases by field name by entity name if (!memberEntityFieldAliases.containsKey(memberEd.getFullEntityName())) memberEntityFieldAliases.put(memberEd.getFullEntityName(), [:]) Map> fieldInfoByEntity = memberEntityFieldAliases.get(memberEd.getFullEntityName()) if (!fieldInfoByEntity.containsKey(fieldName)) fieldInfoByEntity.put(fieldName, new ArrayList()) ArrayList aliasByField = fieldInfoByEntity.get(fieldName) aliasByField.add(aliasNode) } int curIndex = 0 for (MNode aliasNode in internalEntityNode.children("alias")) { if (aliasNode.attribute("pq-expression")) { if (pqExpressionNodeMap == null) pqExpressionNodeMap = new HashMap<>() String pqFieldName = aliasNode.attribute("name") pqExpressionNodeMap.put(pqFieldName, aliasNode) continue } FieldInfo fi = new FieldInfo(this, aliasNode, curIndex) addFieldInfo(fi) curIndex++ } entityConditionNode = internalEntityNode.first("entity-condition") if (entityConditionNode != null) entityHavingEconditions = entityConditionNode.first("having-econditions") else entityHavingEconditions = null } else { if (internalEntityNode.attribute("no-update-stamp") != "true") { // automatically add the lastUpdatedStamp field internalEntityNode.append("field", [name:"lastUpdatedStamp", type:"date-time"]) } ArrayList fieldNodeList = internalEntityNode.children("field") for (int i = 0; i < fieldNodeList.size(); i++) { MNode fieldNode = (MNode) fieldNodeList.get(i) FieldInfo fi = new FieldInfo(this, fieldNode, i) addFieldInfo(fi) } entityConditionNode = null entityHavingEconditions = null } // finally create the EntityInfo object entityInfo = new EntityJavaUtil.EntityInfo(this, memberNeverCache) } private void addFieldInfo(FieldInfo fi) { fieldNodeMap.put(fi.name, fi.fieldNode) fieldInfoMap.put(fi.name, fi) allFieldNameList.add(fi.name) allFieldInfoList.add(fi) if (fi.isPk) { pkFieldNameList.add(fi.name) } else { nonPkFieldNameList.add(fi.name) } } private String getBasicFieldColName(String entityAlias, String fieldName) { MNode memberEntity = memberEntityAliasMap.get(entityAlias) if (memberEntity == null) throw new EntityException("Could not find member-entity with entity-alias [${entityAlias}] in view-entity [${getFullEntityName()}]") EntityDefinition memberEd = this.efi.getEntityDefinition(memberEntity.attribute("entity-name")) FieldInfo fieldInfo = memberEd.getFieldInfo(fieldName) if (fieldInfo == null) throw new EntityException("Invalid field name ${fieldName} for entity ${memberEd.getFullEntityName()}") String subSelectAttr = memberEntity.attribute("sub-select") if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) { // sub-select uses alias field name changed to underscored return EntityJavaUtil.camelCaseToUnderscored(fieldInfo.name) } else { return fieldInfo.getFullColumnName() } } String makeFullColumnName(MNode fieldNode, boolean includeEntityAlias) { if (!isViewEntity) return null String memberAliasName = fieldNode.attribute("name") String memberFieldName = fieldNode.attribute("field") if (memberFieldName == null || memberFieldName.isEmpty()) memberFieldName = memberAliasName String entityAlias = fieldNode.attribute("entity-alias") if (includeEntityAlias) { if (entityAlias == null || entityAlias.isEmpty()) { Set entityAliasUsedSet = new HashSet<>() ArrayList cafList = fieldNode.descendants("complex-alias-field") int cafListSize = cafList.size() for (int i = 0; i < cafListSize; i++) { MNode cafNode = (MNode) cafList.get(i) String cafEntityAlias = cafNode.attribute("entity-alias") if (cafEntityAlias != null && cafEntityAlias.length() > 0) entityAliasUsedSet.add(cafEntityAlias) } if (entityAliasUsedSet.size() == 1) entityAlias = entityAliasUsedSet.iterator().next() } // might have added entityAlias so check again if (entityAlias != null && !entityAlias.isEmpty()) { // special case for member-entity with sub-select=true, use alias underscored MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAlias) EntityDefinition memberEd = this.efi.getEntityDefinition(memberEntity.attribute("entity-name")) String subSelectAttr = memberEntity.attribute("sub-select") if (!memberEd.isViewEntity && ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr))) { return entityAlias + '.' + EntityJavaUtil.camelCaseToUnderscored(memberAliasName) } } } // NOTE: for view-entity the incoming fieldNode will actually be for an alias element StringBuilder colNameBuilder = new StringBuilder() MNode caseNode = fieldNode.first("case") MNode complexAliasNode = fieldNode.first("complex-alias") String function = fieldNode.attribute("function") boolean hasFunction = function != null && !function.isEmpty() if (hasFunction) colNameBuilder.append(getFunctionPrefix(function)) if (caseNode != null) { colNameBuilder.append("CASE") String caseExpr = caseNode.attribute("expression") if (caseExpr != null) colNameBuilder.append(" ").append(caseExpr) ArrayList whenNodeList = caseNode.children("when") int whenNodeListSize = whenNodeList.size() if (whenNodeListSize == 0) throw new EntityException("No when element under case in alias ${fieldNode.attribute("name")} in view-entity ${getFullEntityName()}") for (int i = 0; i < whenNodeListSize; i++) { MNode whenNode = (MNode) whenNodeList.get(i) colNameBuilder.append(" WHEN ").append(whenNode.attribute("expression")).append(" THEN ") MNode whenComplexAliasNode = whenNode.first("complex-alias") if (whenComplexAliasNode == null) throw new EntityException("No complex-alias element under case.when in alias ${fieldNode.attribute("name")} in view-entity ${getFullEntityName()}") buildComplexAliasName(whenComplexAliasNode, colNameBuilder, true, includeEntityAlias) } MNode elseNode = caseNode.first("else") if (elseNode != null) { colNameBuilder.append(" ELSE ") MNode elseComplexAliasNode = elseNode.first("complex-alias") if (elseComplexAliasNode == null) throw new EntityException("No complex-alias element under case.else in alias ${fieldNode.attribute("name")} in view-entity ${getFullEntityName()}") buildComplexAliasName(elseComplexAliasNode, colNameBuilder, true, includeEntityAlias) } colNameBuilder.append(" END") } else if (complexAliasNode != null) { buildComplexAliasName(complexAliasNode, colNameBuilder, !hasFunction, includeEntityAlias) } else { // column name for view-entity (prefix with "${entity-alias}.") if (includeEntityAlias) colNameBuilder.append(entityAlias).append('.') colNameBuilder.append(getBasicFieldColName(entityAlias, memberFieldName)) } if (hasFunction) colNameBuilder.append(')') return colNameBuilder.toString() } private void buildComplexAliasName(MNode parentNode, StringBuilder colNameBuilder, boolean addParens, boolean includeEntityAlias) { String expression = parentNode.attribute("expression") // NOTE: this is expanded in FieldInfo.getFullColumnName() if needed if (expression != null && expression.length() > 0) colNameBuilder.append(expression) ArrayList childList = parentNode.children int childListSize = childList.size() if (childListSize == 0) return String caFunction = parentNode.attribute("function") if (caFunction != null && !caFunction.isEmpty()) { colNameBuilder.append(caFunction).append('(') for (int i = 0; i < childListSize; i++) { MNode childNode = (MNode) childList.get(i) if (i > 0) colNameBuilder.append(", ") if ("complex-alias".equals(childNode.name)) { buildComplexAliasName(childNode, colNameBuilder, true, includeEntityAlias) } else if ("complex-alias-field".equals(childNode.name)) { appenComplexAliasField(childNode, colNameBuilder, includeEntityAlias) } } colNameBuilder.append(')') } else { String operator = parentNode.attribute("operator") if (operator == null || operator.isEmpty()) operator = "+" if (addParens && childListSize > 1) colNameBuilder.append('(') for (int i = 0; i < childListSize; i++) { MNode childNode = (MNode) childList.get(i) if (i > 0) colNameBuilder.append(' ').append(operator).append(' ') if ("complex-alias".equals(childNode.name)) { buildComplexAliasName(childNode, colNameBuilder, true, includeEntityAlias) } else if ("complex-alias-field".equals(childNode.name)) { appenComplexAliasField(childNode, colNameBuilder, includeEntityAlias) } } if (addParens && childListSize > 1) colNameBuilder.append(')') } } private void appenComplexAliasField(MNode childNode, StringBuilder colNameBuilder, boolean includeEntityAlias) { String entityAlias = childNode.attribute("entity-alias") String basicColName = getBasicFieldColName(entityAlias, childNode.attribute("field")) String colName = includeEntityAlias ? entityAlias + "." + basicColName : basicColName String defaultValue = childNode.attribute("default-value") String function = childNode.attribute("function") if (function) colNameBuilder.append(getFunctionPrefix(function)) if (defaultValue) colNameBuilder.append("COALESCE(") colNameBuilder.append(colName) if (defaultValue) colNameBuilder.append(',').append(defaultValue).append(')') if (function) colNameBuilder.append(')') } protected static String getFunctionPrefix(String function) { return (function == "count-distinct") ? "COUNT(DISTINCT " : function.toUpperCase() + '(' } private void expandAliasAlls() { if (!isViewEntity) return Set existingAliasNames = new HashSet<>() ArrayList aliasList = internalEntityNode.children("alias") int aliasListSize = aliasList.size() for (int i = 0; i < aliasListSize; i++) { MNode aliasNode = (MNode) aliasList.get(i) existingAliasNames.add(aliasNode.attribute("name")) } ArrayList aliasAllList = internalEntityNode.children("alias-all") ArrayList memberEntityList = internalEntityNode.children("member-entity") int memberEntityListSize = memberEntityList.size() for (int aInd = 0; aInd < aliasAllList.size(); aInd++) { MNode aliasAll = (MNode) aliasAllList.get(aInd) String aliasAllEntityAlias = aliasAll.attribute("entity-alias") MNode memberEntity = memberEntityAliasMap.get(aliasAllEntityAlias) if (memberEntity == null) { logger.error("In view-entity ${getFullEntityName()} in alias-all with entity-alias [${aliasAllEntityAlias}], member-entity with same entity-alias not found, ignoring") continue } EntityDefinition aliasedEntityDefinition = efi.getEntityDefinition(memberEntity.attribute("entity-name")) if (aliasedEntityDefinition == null) { logger.error("Entity [${memberEntity.attribute("entity-name")}] referred to in member-entity with entity-alias [${aliasAllEntityAlias}] not found, ignoring") continue } FieldInfo[] aliasFieldInfos = aliasedEntityDefinition.entityInfo.allFieldInfoArray for (int i = 0; i < aliasFieldInfos.length; i++) { FieldInfo fi = (FieldInfo) aliasFieldInfos[i] String aliasName = fi.name // never auto-alias these if ("lastUpdatedStamp".equals(aliasName)) continue // if specified as excluded, leave it out ArrayList excludeList = aliasAll.children("exclude") int excludeListSize = excludeList.size() boolean foundExclude = false for (int j = 0; j < excludeListSize; j++) { MNode excludeNode = (MNode) excludeList.get(j) if (aliasName.equals(excludeNode.attribute("field"))) { foundExclude = true break } } if (foundExclude) continue if (aliasAll.attribute("prefix")) { StringBuilder newAliasName = new StringBuilder(aliasAll.attribute("prefix")) newAliasName.append(Character.toUpperCase(aliasName.charAt(0))) newAliasName.append(aliasName.substring(1)) aliasName = newAliasName.toString() } // see if there is already an alias with this name if (existingAliasNames.contains(aliasName)) { //log differently if this is part of a member-entity view link key-map because that is a common case when a field will be auto-expanded multiple times boolean isInViewLink = false for (int j = 0; j < memberEntityListSize; j++) { MNode viewMeNode = (MNode) memberEntityList.get(j) boolean isRel = false if (viewMeNode.attribute("entity-alias") == aliasAllEntityAlias) { isRel = true } else if (viewMeNode.attribute("join-from-alias") != aliasAllEntityAlias) { // not the rel-entity-alias or the entity-alias, so move along continue; } for (MNode keyMap in viewMeNode.children("key-map")) { if (!isRel && keyMap.attribute("field-name") == fi.name) { isInViewLink = true break } else if (isRel && ((keyMap.attribute("related") ?: keyMap.attribute("related-field-name") ?: keyMap.attribute("field-name"))) == fi.name) { isInViewLink = true break } } if (isInViewLink) break } MNode existingAliasNode = internalEntityNode.children("alias").find({ aliasName.equals(it.attribute("name")) }) // already exists... probably an override, but log just in case String warnMsg = "Throwing out field alias in view entity " + this.getFullEntityName() + " because one already exists with the alias name [" + aliasName + "] and field name [" + memberEntity.attribute("entity-alias") + "(" + aliasedEntityDefinition.getFullEntityName() + ")." + fi.name + "], existing field name is [" + existingAliasNode.attribute("entity-alias") + "." + existingAliasNode.attribute("field") + "]" if (isInViewLink) { if (logger.isTraceEnabled()) logger.trace(warnMsg) } else { logger.info(warnMsg) } // ship adding the new alias continue } existingAliasNames.add(aliasName) MNode newAlias = this.internalEntityNode.append("alias", [name:aliasName, field:fi.name, "entity-alias":aliasAllEntityAlias, "is-from-alias-all":"true"]) if (fi.fieldNode.hasChild("description")) newAlias.append(fi.fieldNode.first("description")) } } } EntityFacadeImpl getEfi() { return efi } String getEntityName() { return entityInfo.internalEntityName } String getFullEntityName() { return fullEntityName } String getShortAlias() { return entityInfo.shortAlias } String getShortOrFullEntityName() { return entityInfo.shortAlias != null ? entityInfo.shortAlias : entityInfo.fullEntityName } MNode getEntityNode() { return internalEntityNode } Map> getMemberFieldAliases(String memberEntityName) { return memberEntityFieldAliases?.get(memberEntityName) } String getEntityGroupName() { return groupName } /** Returns the table name, ie table-name or converted entity-name */ String getTableName() { return entityInfo.tableName } String getTableNameLowerCase() { return entityInfo.tableNameLowerCase } String getFullTableName() { return entityInfo.fullTableName } String getSchemaName() { return entityInfo.schemaName } String getColumnName(String fieldName) { FieldInfo fieldInfo = getFieldInfo(fieldName) if (fieldInfo == null) throw new EntityException("Invalid field name ${fieldName} for entity ${this.getFullEntityName()}") return fieldInfo.getFullColumnName() } ArrayList getPkFieldNames() { return pkFieldNameList } ArrayList getNonPkFieldNames() { return nonPkFieldNameList } ArrayList getAllFieldNames() { return allFieldNameList } boolean isField(String fieldName) { return fieldInfoMap.containsKey(fieldName) } boolean isPkField(String fieldName) { FieldInfo fieldInfo = fieldInfoMap.get(fieldName) if (fieldInfo == null) return false return fieldInfo.isPk } boolean containsPrimaryKey(Map fields) { if (fields == null || fields.size() == 0) return false ArrayList fieldNameList = this.getPkFieldNames() int size = fieldNameList.size() for (int i = 0; i < size; i++) { String fieldName = (String) fieldNameList.get(i) Object fieldValue = fields.get(fieldName) if (ObjectUtilities.isEmpty(fieldValue)) return false } return true } LiteStringMap getPrimaryKeys(Map fields) { // NOTE: for pks Map don't use manual indexes, want compact with no extra entries and causes issues FieldInfo[] pkFieldInfos = this.entityInfo.pkFieldInfoArray LiteStringMap pks = new LiteStringMap<>(pkFieldInfos.length) if (fields instanceof LiteStringMap) { LiteStringMap fieldsLsm = (LiteStringMap) fields for (int i = 0; i < pkFieldInfos.length; i++) { FieldInfo fi = pkFieldInfos[i] pks.putByIString(fi.name, fieldsLsm.getByIString(fi.name)) } } else { for (int i = 0; i < pkFieldInfos.length; i++) { FieldInfo fi = pkFieldInfos[i] pks.putByIString(fi.name, fields.get(fi.name)) } } return pks } String getPrimaryKeysString(Map fieldValues) { if (fieldValues == null) { logger.warn("EntityDefinition.getPrimaryKeysString() fieldValues is null", new Exception("location")) return null } FieldInfo[] pkFieldInfoArray = entityInfo.pkFieldInfoArray if (pkFieldInfoArray.length == 1) { FieldInfo fi = pkFieldInfoArray[0] return ObjectUtilities.toPlainString(fieldValues.get(fi.name)) } else { StringBuilder pkCombinedSb = new StringBuilder(); for (int pki = 0; pki < pkFieldInfoArray.length; pki++) { FieldInfo fi = pkFieldInfoArray[pki] // NOTE: separator of '::' matches separator used for combined PK String in EntityValueBase.getPrimaryKeysString() and EntityDataDocument.makeDocId() if (pkCombinedSb.length() > 0) pkCombinedSb.append("::") pkCombinedSb.append(ObjectUtilities.toPlainString(fieldValues.get(fi.name))) } return pkCombinedSb.toString() } } ArrayList getFieldNames(boolean includePk, boolean includeNonPk) { ArrayList baseList if (includePk) { if (includeNonPk) baseList = getAllFieldNames() else baseList = getPkFieldNames() } else { if (includeNonPk) baseList = getNonPkFieldNames() // all false is weird, but okay else baseList = new ArrayList() } return baseList } String getDefaultDescriptionField() { ArrayList nonPkFields = nonPkFieldNameList // find the first *Name for (String fn in nonPkFields) if (fn.endsWith("Name")) return fn // no name? try literal description if (isField("description")) return "description" // no description? just use the first non-pk field: nonPkFields.get(0) // not any more, can be confusing... just return empty String return "" } MNode getMemberEntityNode(String entityAlias) { return memberEntityAliasMap.get(entityAlias) } String getMemberEntityName(String entityAlias) { MNode memberEntityNode = memberEntityAliasMap.get(entityAlias) return memberEntityNode?.attribute("entity-name") } MNode getFieldNode(String fieldName) { return (MNode) fieldNodeMap.get(fieldName) } FieldInfo getFieldInfo(String fieldName) { return (FieldInfo) fieldInfoMap.get(fieldName) } static Map getRelationshipExpandedKeyMapInternal(MNode relationship, EntityDefinition relEd) { Map eKeyMap = [:] ArrayList keyMapList = relationship.children("key-map") if (!keyMapList && ((String) relationship.attribute("type")).startsWith("one")) { // go through pks of related entity, assume field names match ArrayList relPkFields = relEd.getPkFieldNames() int relPkFieldSize = relPkFields.size() for (int i = 0; i < relPkFieldSize; i++) { String pkFieldName = (String) relPkFields.get(i) eKeyMap.put(pkFieldName, pkFieldName) } } else { int keyMapListSize = keyMapList.size() if (keyMapListSize == 1) { MNode keyMap = (MNode) keyMapList.get(0) String fieldName = keyMap.attribute("field-name") String relFn = keyMap.attribute("related") ?: keyMap.attribute("related-field-name") if (relFn == null || relFn.isEmpty()) { ArrayList relPks = relEd.getPkFieldNames() if (relationship.attribute("type").startsWith("one") && relPks.size() == 1) { relFn = (String) relPks.get(0) } else { relFn = fieldName } } eKeyMap.put(fieldName, relFn) } else { for (int i = 0; i < keyMapListSize; i++) { MNode keyMap = (MNode) keyMapList.get(i) String fieldName = keyMap.attribute("field-name") String relFn = keyMap.attribute("related") ?: keyMap.attribute("related-field-name") ?: fieldName if (!relEd.isField(relFn) && relationship.attribute("type").startsWith("one")) { ArrayList pks = relEd.getPkFieldNames() if (pks.size() == 1) relFn = (String) pks.get(0) // if we don't match these constraints and get this default we'll get an error later... } eKeyMap.put(fieldName, relFn) } } } return eKeyMap } static Map getRelationshipKeyValueMapInternal(MNode relationship) { ArrayList keyValueList = relationship.children("key-value") int keyValueListSize = keyValueList.size() if (keyValueListSize == 0) return null Map eKeyMap = [:] for (int i = 0; i < keyValueListSize; i++) { MNode keyValue = (MNode) keyValueList.get(i) eKeyMap.put(keyValue.attribute("related"), keyValue.attribute("value")) } return eKeyMap } RelationshipInfo getRelationshipInfo(String relationshipName) { if (relationshipName == null || relationshipName.isEmpty()) return null return getRelationshipInfoMap().get(relationshipName) } Map getRelationshipInfoMap() { if (relationshipInfoMap == null) makeRelInfoMap() return relationshipInfoMap } private synchronized void makeRelInfoMap() { if (relationshipInfoMap != null) return Map relInfoMap = new HashMap() List relInfoList = getRelationshipsInfo(false) for (RelationshipInfo relInfo in relInfoList) { // always use the full relationshipName relInfoMap.put(relInfo.relationshipName, relInfo) // if there is a shortAlias add it under that if (relInfo.shortAlias) relInfoMap.put(relInfo.shortAlias, relInfo) // if there is no title, allow referring to the relationship by just the simple entity name (no package) if (!relInfo.title) relInfoMap.put(relInfo.relatedEd.entityInfo.internalEntityName, relInfo) } relationshipInfoMap = relInfoMap } ArrayList getRelationshipsInfo(boolean dependentsOnly) { if (relationshipInfoList == null) makeRelInfoList() if (!dependentsOnly) return new ArrayList(relationshipInfoList) // just get dependents ArrayList infoListCopy = new ArrayList<>() for (RelationshipInfo info in relationshipInfoList) if (info.dependent) infoListCopy.add(info) return infoListCopy } private synchronized void makeRelInfoList() { if (relationshipInfoList != null) return if (!this.expandedRelationshipList) { // make sure this is done before as this isn't done by default if (!hasReverseRelationships) efi.createAllAutoReverseManyRelationships() this.expandedRelationshipList = this.internalEntityNode.children("relationship") } ArrayList infoList = new ArrayList<>() for (MNode relNode in this.expandedRelationshipList) { RelationshipInfo relInfo = new RelationshipInfo(relNode, this, efi) infoList.add(relInfo) } relationshipInfoList = infoList } void setHasReverseRelationships() { hasReverseRelationships = true } MasterDefinition getMasterDefinition(String name) { if (name == null || name.length() == 0) name = "default" if (masterDefinitionMap == null) makeMasterDefinitionMap() return masterDefinitionMap.get(name) } Map getMasterDefinitionMap() { if (masterDefinitionMap == null) makeMasterDefinitionMap() return masterDefinitionMap } private synchronized void makeMasterDefinitionMap() { if (masterDefinitionMap != null) return Map defMap = [:] for (MNode masterNode in internalEntityNode.children("master")) { MasterDefinition curDef = new MasterDefinition(this, masterNode) defMap.put(curDef.name, curDef) } masterDefinitionMap = defMap } Map getPqExpressionNodeMap() { return pqExpressionNodeMap } MNode getPqExpressionNode(String name) { if (pqExpressionNodeMap == null) return null return pqExpressionNodeMap.get(name) } @CompileStatic static class MasterDefinition { String name ArrayList detailList = new ArrayList() MasterDefinition(EntityDefinition ed, MNode masterNode) { name = masterNode.attribute("name") ?: "default" List detailNodeList = masterNode.children("detail") for (MNode detailNode in detailNodeList) { try { detailList.add(new MasterDetail(ed, detailNode)) } catch (Exception e) { logger.error("Error adding detail ${detailNode.attribute("relationship")} to master ${name} of entity ${ed.getFullEntityName()}: ${e.toString()}") } } } } @CompileStatic static class MasterDetail { String relationshipName EntityDefinition parentEd RelationshipInfo relInfo String relatedMasterName ArrayList internalDetailList = new ArrayList<>() MasterDetail(EntityDefinition parentEd, MNode detailNode) { this.parentEd = parentEd relationshipName = detailNode.attribute("relationship") relInfo = parentEd.getRelationshipInfo(relationshipName) if (relInfo == null) throw new BaseArtifactException("Invalid relationship name [${relationshipName}] for entity ${parentEd.getFullEntityName()}") // logger.warn("Following relationship ${relationshipName}") List detailNodeList = detailNode.children("detail") for (MNode childNode in detailNodeList) internalDetailList.add(new MasterDetail(relInfo.relatedEd, childNode)) relatedMasterName = (String) detailNode.attribute("use-master") } ArrayList getDetailList() { if (relatedMasterName) { ArrayList combinedList = new ArrayList(internalDetailList) MasterDefinition relatedMaster = relInfo.relatedEd.getMasterDefinition(relatedMasterName) if (relatedMaster == null) throw new BaseArtifactException("Invalid use-master value [${relatedMasterName}], master not found in entity ${relInfo.relatedEntityName}") // logger.warn("Including master ${relatedMasterName} on entity ${relInfo.relatedEd.getFullEntityName()}") combinedList.addAll(relatedMaster.detailList) return combinedList } else { return internalDetailList } } } // NOTE: used in the DataEdit screen EntityDependents getDependentsTree() { EntityDependents edp = new EntityDependents(this, null, null) return edp } static class EntityDependents { String entityName EntityDefinition ed Map dependentEntities = new TreeMap() Set descendants = new TreeSet() Map relationshipInfos = new HashMap() EntityDependents(EntityDefinition ed, Deque ancestorEntities, Map allDependents) { this.ed = ed entityName = ed.fullEntityName if (ancestorEntities == null) ancestorEntities = new LinkedList() ancestorEntities.addFirst(entityName) if (allDependents == null) allDependents = new HashMap() allDependents.put(entityName, this) List relInfoList = ed.getRelationshipsInfo(true) for (RelationshipInfo relInfo in relInfoList) { if (!relInfo.dependent) continue descendants.add(relInfo.relatedEntityName) String relName = relInfo.relationshipName relationshipInfos.put(relName, relInfo) // if (relInfo.shortAlias) edp.relationshipInfos.put((String) relInfo.shortAlias, relInfo) EntityDefinition relEd = ed.efi.getEntityDefinition((String) relInfo.relatedEntityName) if (!dependentEntities.containsKey(relName) && !ancestorEntities.contains(relEd.fullEntityName)) { EntityDependents relEdp = allDependents.get(relEd.fullEntityName) if (relEdp == null) relEdp = new EntityDependents(relEd, ancestorEntities, allDependents) dependentEntities.put(relName, relEdp) } } ancestorEntities.removeFirst() } // used in EntityDetail screen TreeSet getAllDescendants() { TreeSet allSet = new TreeSet() populateAllDescendants(allSet) return allSet } protected void populateAllDescendants(TreeSet allSet) { allSet.addAll(descendants) for (EntityDependents edp in dependentEntities.values()) edp.populateAllDescendants(allSet) } String toString() { StringBuilder builder = new StringBuilder(10000) Set entitiesVisited = new HashSet<>() buildString(builder, 0, entitiesVisited) return builder.toString() } static final String indentBase = "- " void buildString(StringBuilder builder, int level, Set entitiesVisited) { StringBuilder ib = new StringBuilder() for (int i = 0; i <= level; i++) ib.append(indentBase) String indent = ib.toString() for (Map.Entry entry in dependentEntities) { RelationshipInfo relInfo = relationshipInfos.get(entry.getKey()) builder.append(indent).append(relInfo.relationshipName).append(" ").append(relInfo.keyMap).append("\n") if (level < 4 && !entitiesVisited.contains(entry.getValue().entityName)) { entry.getValue().buildString(builder, level + 1I, entitiesVisited) entitiesVisited.add(entry.getValue().entityName) } else if (entitiesVisited.contains(entry.getValue().entityName)) { builder.append(indent).append(indentBase).append("Dependants already displayed\n") } else if (level == 4) { builder.append(indent).append(indentBase).append("Reached level limit\n") } } } } String getPrettyName(String title, String baseName) { Set baseNameParts = baseName != null ? new HashSet<>(Arrays.asList(baseName.split("(?=[A-Z])"))) : null; StringBuilder prettyName = new StringBuilder() for (String part in entityInfo.internalEntityName.split("(?=[A-Z])")) { if (baseNameParts != null && baseNameParts.contains(part)) continue if (prettyName.length() > 0) prettyName.append(" ") prettyName.append(part) } if (title) { boolean addParens = prettyName.length() > 0 if (addParens) prettyName.append(" (") for (String part in title.split("(?=[A-Z])")) prettyName.append(part).append(" ") prettyName.deleteCharAt(prettyName.length()-1) if (addParens) prettyName.append(")") } // make sure pretty name isn't empty, happens when baseName is a superset of entity name if (prettyName.length() == 0) return StringUtilities.camelCaseToPretty(entityInfo.internalEntityName) return prettyName.toString() } // used in EntityCache for view entities Map getMePkFieldToAliasNameMap(String entityAlias) { if (mePkFieldToAliasNameMapMap == null) mePkFieldToAliasNameMapMap = new HashMap>() Map mePkFieldToAliasNameMap = (Map) mePkFieldToAliasNameMapMap.get(entityAlias) if (mePkFieldToAliasNameMap != null) return mePkFieldToAliasNameMap mePkFieldToAliasNameMap = new HashMap() // do a reverse map on member-entity pk fields to view-entity aliases MNode memberEntityNode = memberEntityAliasMap.get(entityAlias) EntityDefinition med = this.efi.getEntityDefinition(memberEntityNode.attribute("entity-name")) ArrayList pkFieldNames = med.getPkFieldNames() int pkFieldNamesSize = pkFieldNames.size() for (int pkIdx = 0; pkIdx < pkFieldNamesSize; pkIdx++) { String pkName = (String) pkFieldNames.get(pkIdx) MNode matchingAliasNode = entityNode.children("alias").find({ it.attribute("entity-alias") == memberEntityNode.attribute("entity-alias") && (it.attribute("field") == pkName || (!it.attribute("field") && it.attribute("name") == pkName)) }) if (matchingAliasNode != null) { // found an alias Node mePkFieldToAliasNameMap.put(pkName, matchingAliasNode.attribute("name")) continue } // no alias, try to find in join key-maps that map to other aliased fields // first try the current member-entity if (memberEntityNode.attribute("join-from-alias") && memberEntityNode.hasChild("key-map")) { boolean foundOne = false ArrayList keyMapList = memberEntityNode.children("key-map") for (MNode keyMapNode in keyMapList) { String relatedField = keyMapNode.attribute("related") ?: keyMapNode.attribute("related-field-name") if (relatedField == null || relatedField.isEmpty()) { if (keyMapList.size() == 1 && pkFieldNamesSize == 1) { relatedField = pkName } else { relatedField = keyMapNode.attribute("field-name") } } if (pkName.equals(relatedField)) { String relatedPkName = keyMapNode.attribute("field-name") MNode relatedMatchingAliasNode = entityNode.children("alias").find({ it.attribute("entity-alias") == memberEntityNode.attribute("join-from-alias") && (it.attribute("field") == relatedPkName || (!it.attribute("field") && it.attribute("name") == relatedPkName)) }) if (relatedMatchingAliasNode) { mePkFieldToAliasNameMap.put(pkName, relatedMatchingAliasNode.attribute("name")) foundOne = true break } } } if (foundOne) continue } // then go through all other member-entity that might relate back to this one for (MNode relatedMeNode in entityNode.children("member-entity")) { if (relatedMeNode.attribute("join-from-alias") == entityAlias && relatedMeNode.hasChild("key-map")) { boolean foundOne = false for (MNode keyMapNode in relatedMeNode.children("key-map")) { if (keyMapNode.attribute("field-name") == pkName) { String relatedPkName = keyMapNode.attribute("related") ?: keyMapNode.attribute("related-field-name") ?: keyMapNode.attribute("field-name") MNode relatedMatchingAliasNode = entityNode.children("alias").find({ it.attribute("entity-alias") == relatedMeNode.attribute("entity-alias") && (it.attribute("field") == relatedPkName || (!it.attribute("field") && it.attribute("name") == relatedPkName)) }) if (relatedMatchingAliasNode) { mePkFieldToAliasNameMap.put(pkName, relatedMatchingAliasNode.attribute("name")) foundOne = true break } } } if (foundOne) break } } } if (pkFieldNames.size() != mePkFieldToAliasNameMap.size()) { logger.warn("Not all primary-key fields in view-entity [${fullEntityName}] for member-entity [${entityAlias}:${memberEntityNode.attribute("entity-name")}], skipping cache reverse-association, and note that if this record is updated the cache won't automatically clear; pkFieldNames=${pkFieldNames}; partial mePkFieldToAliasNameMap=${mePkFieldToAliasNameMap}") } mePkFieldToAliasNameMapMap.put(entityAlias, mePkFieldToAliasNameMap) return mePkFieldToAliasNameMap } Object convertFieldString(String name, String value, ExecutionContextImpl eci) { if (value == null) return null FieldInfo fieldInfo = getFieldInfo(name) if (fieldInfo == null) throw new EntityException("Invalid field name ${name} for entity ${fullEntityName}") return fieldInfo.convertFromString(value, eci.l10nFacade) } static String getFieldStringForFile(FieldInfo fieldInfo, Object value) { if (value == null) return null String outValue if (value instanceof Timestamp) { // use a Long number, no TZ issues outValue = ((Timestamp) value).getTime() as String } else if (value instanceof BigDecimal) { outValue = ((BigDecimal) value).toPlainString() } else { outValue = fieldInfo.convertToString(value) } return outValue } EntityConditionImplBase makeViewWhereCondition() { if (!isViewEntity || entityConditionNode == null) return (EntityConditionImplBase) null // add the view-entity.entity-condition.econdition(s) return makeViewListCondition(entityConditionNode, null) } EntityConditionImplBase makeViewHavingCondition() { if (!isViewEntity || entityHavingEconditions == null) return (EntityConditionImplBase) null // add the view-entity.entity-condition.having-econditions return makeViewListCondition(entityHavingEconditions, null) } protected EntityConditionImplBase makeViewListCondition(MNode conditionsParent, MNode joinMemberEntityNode) { if (conditionsParent == null) return null ExecutionContextImpl eci = efi.ecfi.getEci() EntityDefinition joinEntityDef = joinMemberEntityNode != null ? this.efi.getEntityDefinition(joinMemberEntityNode.attribute("entity-name")) : null List condList = new ArrayList() for (MNode dateFilter in conditionsParent.children("date-filter")) { ConditionField fromField, thruField String fromFieldName = dateFilter.attribute("from-field-name") ?: "fromDate" String thruFieldName = dateFilter.attribute("thru-field-name") ?: "thruDate" Timestamp validDate = dateFilter.attribute("valid-date") ? efi.ecfi.resourceFacade.expand(dateFilter.attribute("valid-date"), "") as Timestamp : null if (validDate == (Timestamp) null) validDate = efi.ecfi.getEci().userFacade.getNowTimestamp() String entityAliasAttr = dateFilter.attribute("entity-alias") // if no entity-alias specified, use entity-alias from join member-entity node (if field exists on join entity) if (joinEntityDef != null && (entityAliasAttr == null || entityAliasAttr.isEmpty()) && joinEntityDef.isField(fromFieldName)) entityAliasAttr = joinMemberEntityNode.attribute("entity-alias") if (entityAliasAttr != null && !entityAliasAttr.isEmpty()) { MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAliasAttr) if (memberEntity == null) throw new EntityException("The entity-alias [${entityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]") EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute("entity-name")) fromField = new ConditionAlias(entityAliasAttr, fromFieldName, aliasEntityDef) thruField = new ConditionAlias(entityAliasAttr, thruFieldName, aliasEntityDef) } else { FieldInfo fromFi = getFieldInfo(fromFieldName) FieldInfo thruFi = getFieldInfo(thruFieldName) if (fromFi == null) throw new EntityException("Field ${fromFieldName} not found in entity ${fullEntityName}") if (thruFi == null) throw new EntityException("Field ${thruFieldName} not found in entity ${fullEntityName}") fromField = fromFi.conditionField thruField = thruFi.conditionField } condList.add(new DateCondition(fromField, thruField, validDate)) } for (MNode econdition in conditionsParent.children("econdition")) { String fieldNameAttr = econdition.attribute("field-name") ConditionField field EntityConditionImplBase cond EntityDefinition condEd String entityAliasAttr = econdition.attribute("entity-alias") // if no entity-alias specified, use entity-alias from join member-entity node (if field exists on join entity) if (joinEntityDef != null && (entityAliasAttr == null || entityAliasAttr.isEmpty()) && joinEntityDef.isField(fieldNameAttr)) { String joinMemberAlias = joinMemberEntityNode.attribute("entity-alias") if (memberEntityAliasMap.containsKey(joinMemberAlias)) { entityAliasAttr = joinMemberAlias } else { // special case for entity-condition.econdition under view-entity.member-entity with sub-select=true // and when doing lateral joins, because WHERE clause is inside sub-select so should default to member-entity's internal alias // is the field an alias on this entity? use that entity-alias MNode aliasNode = this.getFieldNode(fieldNameAttr) if (aliasNode != null) entityAliasAttr = aliasNode.attribute("entity-alias") } } if (entityAliasAttr != null && !entityAliasAttr.isEmpty()) { MNode memberEntity = (MNode) memberEntityAliasMap.get(entityAliasAttr) if (memberEntity == null) throw new EntityException("The entity-alias [${entityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]") EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute("entity-name")) field = new ConditionAlias(entityAliasAttr, fieldNameAttr, aliasEntityDef) condEd = aliasEntityDef } else { FieldInfo fi = getFieldInfo(fieldNameAttr) if (fi == null) throw new EntityException("Field ${fieldNameAttr} not found in entity ${fullEntityName}") field = fi.conditionField condEd = this } String toFieldNameAttr = econdition.attribute("to-field-name") if (toFieldNameAttr != null) { String toEntityAliasAttr = econdition.attribute("to-entity-alias") if (joinEntityDef != null && (toEntityAliasAttr == null || toEntityAliasAttr.isEmpty()) && joinEntityDef.isField(toFieldNameAttr)) toEntityAliasAttr = joinMemberEntityNode.attribute("entity-alias") ConditionField toField if (toEntityAliasAttr != null && !toEntityAliasAttr.isEmpty()) { MNode memberEntity = (MNode) memberEntityAliasMap.get(toEntityAliasAttr) if (memberEntity == null) throw new EntityException("The entity-alias [${toEntityAliasAttr}] was not found in view-entity [${entityInfo.internalEntityName}]") EntityDefinition aliasEntityDef = this.efi.getEntityDefinition(memberEntity.attribute("entity-name")) toField = new ConditionAlias(toEntityAliasAttr, toFieldNameAttr, aliasEntityDef) } else { FieldInfo fi = getFieldInfo(toFieldNameAttr) if (fi == null) throw new EntityException("Field ${toFieldNameAttr} not found in entity ${fullEntityName}") toField = fi.conditionField } cond = new FieldToFieldCondition(field, EntityConditionFactoryImpl.getComparisonOperator(econdition.attribute("operator")), toField) } else { // NOTE: may need to convert value from String to object for field String condValue = econdition.attribute("value") ?: null // NOTE: only expand if contains "${", expanding normal strings does l10n and messes up key values; hopefully this won't result in a similar issue if (condValue && condValue.contains("\${")) condValue = efi.ecfi.resourceFacade.expand(condValue, "") as String Object condValueObj = condEd.convertFieldString(field.fieldName, condValue, eci); cond = new FieldValueCondition(field, EntityConditionFactoryImpl.getComparisonOperator(econdition.attribute("operator")), condValueObj) } if (cond != null) { if ("true".equals(econdition.attribute("ignore-case"))) cond.ignoreCase() if ("true".equals(econdition.attribute("or-null"))) { cond = (EntityConditionImplBase) this.efi.conditionFactory.makeCondition(cond, JoinOperator.OR, new FieldValueCondition(field, EntityCondition.EQUALS, null)) } condList.add(cond) } } for (MNode econditions in conditionsParent.children("econditions")) { EntityConditionImplBase cond = this.makeViewListCondition(econditions, joinMemberEntityNode) if (cond) condList.add(cond) } if (condList == null || condList.size() == 0) return null if (condList.size() == 1) return (EntityConditionImplBase) condList.get(0) JoinOperator op = "or".equals(conditionsParent.attribute("combine")) ? JoinOperator.OR : JoinOperator.AND EntityConditionImplBase entityCondition = (EntityConditionImplBase) this.efi.conditionFactory.makeCondition(condList, op) // logger.info("============== In makeViewListCondition for entity [${entityName}] resulting entityCondition: ${entityCondition}") return entityCondition } Cache internalCacheOne = null Cache> internalCacheOneRa = null Cache> getCacheOneViewRa = null Cache internalCacheList = null Cache> internalCacheListRa = null Cache> internalCacheListViewRa = null Cache internalCacheCount = null Cache getCacheOne(EntityCache ec) { if (internalCacheOne == null) internalCacheOne = ec.cfi.getCache(ec.oneKeyBase.concat(fullEntityName)) return internalCacheOne } Cache> getCacheOneRa(EntityCache ec) { if (internalCacheOneRa == null) internalCacheOneRa = ec.cfi.getCache(ec.oneRaKeyBase.concat(fullEntityName)) return internalCacheOneRa } Cache> getCacheOneViewRa(EntityCache ec) { if (getCacheOneViewRa == null) getCacheOneViewRa = ec.cfi.getCache(ec.oneViewRaKeyBase.concat(fullEntityName)) return getCacheOneViewRa } Cache getCacheList(EntityCache ec) { if (internalCacheList == null) internalCacheList = ec.cfi.getCache(ec.listKeyBase.concat(fullEntityName)) return internalCacheList } Cache> getCacheListRa(EntityCache ec) { if (internalCacheListRa == null) internalCacheListRa = ec.cfi.getCache(ec.listRaKeyBase.concat(fullEntityName)) return internalCacheListRa } Cache> getCacheListViewRa(EntityCache ec) { if (internalCacheListViewRa == null) internalCacheListViewRa = ec.cfi.getCache(ec.listViewRaKeyBase.concat(fullEntityName)) return internalCacheListViewRa } Cache getCacheCount(EntityCache ec) { if (internalCacheCount == null) internalCacheCount = ec.cfi.getCache(ec.countKeyBase.concat(fullEntityName)) return internalCacheCount } boolean tableExistsDbMetaOnly() { if (tableExistVerified) return true tableExistVerified = efi.getEntityDbMeta().tableExists(this) return tableExistVerified } // these methods used by EntityFacadeImpl to avoid redundant lookups of entity info EntityFind makeEntityFind() { if (entityInfo.isEntityDatasourceFactoryImpl) { return new EntityFindImpl(efi, this) } else { return entityInfo.datasourceFactory.makeEntityFind(fullEntityName) } } EntityValue makeEntityValue() { if (entityInfo.isEntityDatasourceFactoryImpl) { return new EntityValueImpl(this, efi) } else { return entityInfo.datasourceFactory.makeEntityValue(fullEntityName) } } @Override int hashCode() { return this.fullEntityName.hashCode() } @Override boolean equals(Object o) { if (o == null || o.getClass() != this.getClass()) return false EntityDefinition that = (EntityDefinition) o if (!this.fullEntityName.equals(that.fullEntityName)) return false return true } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityDynamicViewImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.entity.EntityDynamicView import org.moqui.entity.EntityException import org.moqui.util.MNode @CompileStatic class EntityDynamicViewImpl implements EntityDynamicView { protected EntityFacadeImpl efi protected String entityName = "DynamicView" protected MNode entityNode = new MNode("view-entity", ["package":"dynamic", "entity-name":"DynamicView", "is-dynamic-view":"true"]) EntityDynamicViewImpl(EntityFindImpl entityFind) { this.efi = entityFind.efi } EntityDynamicViewImpl(EntityFacadeImpl efi) { this.efi = efi } EntityDefinition makeEntityDefinition() { // System.out.println("========= MNode:\n${entityNode.toString()}") return new EntityDefinition(efi, entityNode) } @Override EntityDynamicView setEntityName(String entityName) { entityNode.attributes.put("entity-name", entityName) return this } @Override EntityDynamicView addMemberEntity(String entityAlias, String entityName, String joinFromAlias, Boolean joinOptional, Map entityKeyMaps) { MNode memberEntity = entityNode.append("member-entity", ["entity-alias":entityAlias, "entity-name":entityName]) if (joinFromAlias) { memberEntity.attributes.put("join-from-alias", joinFromAlias) memberEntity.attributes.put("join-optional", (joinOptional ? "true" : "false")) } if (entityKeyMaps) for (Map.Entry keyMapEntry in entityKeyMaps.entrySet()) { memberEntity.append("key-map", ["field-name":keyMapEntry.getKey(), "related":keyMapEntry.getValue()]) } return this } @Override EntityDynamicView addRelationshipMember(String entityAlias, String joinFromAlias, String relationshipName, Boolean joinOptional) { MNode joinFromMemberEntityNode = entityNode.first({ MNode it -> it.name == "member-entity" && it.attribute("entity-alias") == joinFromAlias }) String entityName = joinFromMemberEntityNode.attribute("entity-name") EntityDefinition joinFromEd = efi.getEntityDefinition(entityName) EntityJavaUtil.RelationshipInfo relInfo = joinFromEd.getRelationshipInfo(relationshipName) if (relInfo == null) throw new EntityException("Relationship not found with name [${relationshipName}] on entity [${entityName}]") Map relationshipKeyMap = relInfo.keyMap MNode memberEntity = entityNode.append("member-entity", ["entity-alias":entityAlias, "entity-name":relInfo.relatedEntityName]) memberEntity.attributes.put("join-from-alias", joinFromAlias) memberEntity.attributes.put("join-optional", (joinOptional ? "true" : "false")) for (Map.Entry keyMapEntry in relationshipKeyMap.entrySet()) { memberEntity.append("key-map", ["field-name":keyMapEntry.getKey(), "related":keyMapEntry.getValue()]) } if (relInfo.keyValueMap != null && relInfo.keyValueMap.size() > 0) { Map keyValueMap = relInfo.keyValueMap MNode entityCondition = memberEntity.append("entity-condition", null) for (Map.Entry keyValueEntry: keyValueMap.entrySet()) { entityCondition.append("econdition", ['entity-alias': entityAlias, 'field-name': keyValueEntry.getKey(), 'value': keyValueEntry.getValue()]) } } return this } MNode getViewEntityNode() { return entityNode } @Override List getMemberEntityNodes() { return entityNode.children("member-entity") } @Override EntityDynamicView addAliasAll(String entityAlias, String prefix) { entityNode.append("alias-all", ["entity-alias":entityAlias, "prefix":prefix]) return this } @Override EntityDynamicView addAlias(String entityAlias, String name) { entityNode.append("alias", ["entity-alias":entityAlias, "name":name]) return this } @Override EntityDynamicView addAlias(String entityAlias, String name, String field, String function) { return addAlias(entityAlias, name, field, function, null) } EntityDynamicView addAlias(String entityAlias, String name, String field, String function, String defaultDisplay) { MNode aNode = entityNode.append("alias", ["entity-alias":entityAlias, name:name]) if (field != null && !field.isEmpty()) aNode.attributes.put("field", field) if (function != null && !function.isEmpty()) aNode.attributes.put("function", function) if (defaultDisplay != null && !defaultDisplay.isEmpty()) aNode.attributes.put("default-display", defaultDisplay) return this } EntityDynamicView addPqExprAlias(String name, String pqExpression, String type, String defaultDisplay) { MNode aNode = entityNode.append("alias", [name:name, "pq-expression":pqExpression, type:(type ?: "text-long")]) if (defaultDisplay != null && !defaultDisplay.isEmpty()) aNode.attributes.put("default-display", defaultDisplay) return this } MNode getAlias(String name) { return entityNode.first("alias", "name", name) } @Override EntityDynamicView addRelationship(String type, String title, String relatedEntityName, Map entityKeyMaps) { MNode viewLink = entityNode.append("relationship", ["type":type, "title":title, "related":relatedEntityName]) for (Map.Entry keyMapEntry in entityKeyMaps.entrySet()) { viewLink.append("key-map", ["field-name":keyMapEntry.getKey(), "related":keyMapEntry.getValue()]) } return this } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityEcaRule.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.entity.EntityFind import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class EntityEcaRule { protected final static Logger logger = LoggerFactory.getLogger(EntityEcaRule.class) protected ExecutionContextFactoryImpl ecfi protected MNode eecaNode protected String location protected XmlAction condition = null protected XmlAction actions = null EntityEcaRule(ExecutionContextFactoryImpl ecfi, MNode eecaNode, String location) { this.ecfi = ecfi this.eecaNode = eecaNode this.location = location // prep condition if (eecaNode.hasChild("condition") && eecaNode.first("condition").children) { // the script is effectively the first child of the condition element condition = new XmlAction(ecfi, eecaNode.first("condition").children.get(0), location + ".condition") } // prep actions if (eecaNode.hasChild("actions")) { String actionsLocation = null String eecaId = eecaNode.attribute("id") if (eecaId != null && !eecaId.isEmpty()) actionsLocation = eecaId + "_" + StringUtilities.getRandomString(8) actions = new XmlAction(ecfi, eecaNode.first("actions"), actionsLocation) // was location + ".actions" but not unique! } } String getEntityName() { return eecaNode.attribute("entity") } MNode getEecaNode() { return eecaNode } void runIfMatches(String entityName, Map fieldValues, String operation, boolean before, ExecutionContextImpl ec) { // see if we match this event and should run // check this first since it is the most common disqualifier String attrName = "on-".concat(operation) if (!"true".equals(eecaNode.attribute(attrName))) return if (!entityName.equals(eecaNode.attribute("entity"))) return if (ec.messageFacade.hasError() && !"true".equals(eecaNode.attribute("run-on-error"))) return EntityValue curValue = null boolean isDelete = "delete".equals(operation) boolean isUpdate = !isDelete && "update".equals(operation) // grab DB values before a delete so they are available after; this modifies fieldValues used by EntityValueBase if (before && isDelete && eecaNode.attribute("get-entire-entity") == "true") { // fill in any missing (unset) values from the DB if (curValue == null) curValue = getDbValue(fieldValues) if (curValue != null) { // only add fields that fieldValues does not contain for (Map.Entry entry in curValue.entrySet()) if (!fieldValues.containsKey(entry.getKey())) fieldValues.put(entry.getKey(), entry.getValue()) } } // do this before even if EECA rule runs after to get the original value from the DB and put in the entity's dbValue Map EntityValue originalValue = null if (before && (isUpdate || isDelete) && "true".equals(eecaNode.attribute("get-original-value"))) { if (curValue == null) curValue = getDbValue(fieldValues) if (curValue != null) { originalValue = curValue // also put DB values in the fieldValues EntityValue if it isn't from DB (to have for future reference) if (fieldValues instanceof EntityValueBase && !((EntityValueBase) fieldValues).getIsFromDb()) { // NOTE: fresh from the DB the valueMap will have clean values and the dbValueMap will be null ((EntityValueBase) fieldValues).setDbValueMap(((EntityValueBase) originalValue).getValueMap()) } } } if (before && !"true".equals(eecaNode.attribute("run-before"))) return if (!before && "true".equals(eecaNode.attribute("run-before"))) return // now if we're running after the entity operation, pull the original value from the if (!before && fieldValues instanceof EntityValueBase && ((EntityValueBase) fieldValues).getIsFromDb() && (isUpdate || isDelete) && eecaNode.attribute("get-original-value") == "true") { originalValue = ((EntityValueBase) fieldValues).cloneDbValue(true) } if ((isUpdate || isDelete) && eecaNode.attribute("get-entire-entity") == "true") { // fill in any missing (unset) values from the DB if (curValue == null) curValue = getDbValue(fieldValues) if (curValue != null) { // only add fields that fieldValues does not contain for (Map.Entry entry in curValue.entrySet()) if (!fieldValues.containsKey(entry.getKey())) fieldValues.put(entry.getKey(), entry.getValue()) } } try { Map contextMap = new HashMap<>() ec.contextStack.push(contextMap) ec.contextStack.putAll(fieldValues) ec.contextStack.put("entityValue", fieldValues) ec.contextStack.put("originalValue", originalValue) ec.contextStack.put("eecaOperation", operation) // run the condition and if passes run the actions boolean conditionPassed = true if (condition != null) conditionPassed = condition.checkCondition(ec) if (conditionPassed && actions != null) { Object result = actions.run(ec) // if anything was set in the context that matches a field name set it on the EntityValue if ("true".equals(eecaNode.attribute("set-results"))) { Map resultMap if (result instanceof Map) { resultMap = (Map) result } else { resultMap = contextMap } if (resultMap != null && resultMap.size() > 0) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) ArrayList fieldNames = ed.getNonPkFieldNames() int fieldNamesSize = fieldNames.size() for (int i = 0; i < fieldNamesSize; i++) { String fieldName = (String) fieldNames.get(i) if (resultMap.containsKey(fieldName)) fieldValues.put(fieldName, resultMap.get(fieldName)) } } } } } finally { ec.contextStack.pop() } } EntityValue getDbValue(Map fieldValues) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) EntityFind ef = ecfi.entity.find(entityName) for (String pkFieldName in ed.getPkFieldNames()) ef.condition(pkFieldName, fieldValues.get(pkFieldName)) return ef.one() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.typehandling.GroovyCastException import org.moqui.BaseArtifactException import org.moqui.BaseException import org.moqui.context.ArtifactExecutionInfo import org.moqui.etl.SimpleEtl import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.condition.EntityConditionImplBase import org.moqui.impl.entity.condition.FieldValueCondition import org.moqui.impl.entity.condition.ListCondition import org.moqui.impl.service.runner.EntityAutoServiceRunner import org.moqui.resource.ResourceReference import org.moqui.entity.* import org.moqui.impl.context.ArtifactExecutionFacadeImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.TransactionFacadeImpl import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.util.CollectionUtilities import org.moqui.util.LiteStringMap import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.moqui.util.SystemBinding import org.slf4j.Logger import org.slf4j.LoggerFactory import org.w3c.dom.Element import javax.cache.Cache import javax.sql.DataSource import javax.sql.XAConnection import javax.sql.XADataSource import java.math.RoundingMode import java.sql.* import java.util.concurrent.ArrayBlockingQueue import java.util.concurrent.BlockingQueue import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ThreadFactory import java.util.concurrent.ThreadLocalRandom import java.util.concurrent.ThreadPoolExecutor import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.Lock import java.util.concurrent.locks.ReentrantLock @CompileStatic class EntityFacadeImpl implements EntityFacade { protected final static Logger logger = LoggerFactory.getLogger(EntityFacadeImpl.class) protected final static boolean isTraceEnabled = logger.isTraceEnabled() public final ExecutionContextFactoryImpl ecfi public final EntityConditionFactoryImpl entityConditionFactory protected final HashMap datasourceFactoryByGroupMap = new HashMap() /** Cache with entity name as the key and an EntityDefinition as the value; clear this cache to reload entity def */ final Cache entityDefinitionCache /** Cache with single entry so can be expired/cleared, contains Map with entity name as the key and List of file * location Strings as the value */ final Cache>> entityLocationSingleCache static final String entityLocSingleEntryName = "ALL_ENTITIES" /** Map for framework entity definitions, avoid cache overhead and timeout issues */ final HashMap frameworkEntityDefinitions = new HashMap<>() /** Sequence name (often entity name) is the key and the value is an array of 2 Longs the first is the next * available value and the second is the highest value reserved/cached in the bank. */ final Cache entitySequenceBankCache protected final ConcurrentHashMap dbSequenceLocks = new ConcurrentHashMap() protected final ReentrantLock locationLoadLock = new ReentrantLock() protected HashMap> eecaRulesByEntityName = new HashMap<>() protected final HashMap entityGroupNameMap = new HashMap<>() protected final HashMap databaseNodeByGroupName = new HashMap<>() protected final HashMap datasourceNodeByGroupName = new HashMap<>() protected final String defaultGroupName protected final TimeZone databaseTimeZone protected final Locale databaseLocale protected final ThreadLocal databaseTzLcCalendar = new ThreadLocal<>() protected final String sequencedIdPrefix boolean queryStats = false protected EntityDbMeta dbMeta = null protected final EntityCache entityCache protected final EntityDataFeed entityDataFeed protected final EntityDataDocument entityDataDocument protected final EntityListImpl emptyList private static class ExecThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("MoquiEntityExec") private final AtomicInteger threadNumber = new AtomicInteger(1) Thread newThread(Runnable r) { return new Thread(workerGroup, r, "MoquiEntityExec-" + threadNumber.getAndIncrement()) } } protected BlockingQueue statementWorkQueue = new ArrayBlockingQueue<>(1024); protected ThreadPoolExecutor statementExecutor = new ThreadPoolExecutor(5, 100, 60, TimeUnit.SECONDS, statementWorkQueue, new ExecThreadFactory()); EntityFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi entityConditionFactory = new EntityConditionFactoryImpl(this) MNode entityFacadeNode = getEntityFacadeNode() entityFacadeNode.setSystemExpandAttributes(true) defaultGroupName = entityFacadeNode.attribute("default-group-name") sequencedIdPrefix = entityFacadeNode.attribute("sequenced-id-prefix") ?: null queryStats = entityFacadeNode.attribute("query-stats") == "true" TimeZone theTimeZone = null if (entityFacadeNode.attribute("database-time-zone")) { try { theTimeZone = TimeZone.getTimeZone((String) entityFacadeNode.attribute("database-time-zone")) } catch (Exception e) { logger.warn("Error parsing database-time-zone: ${e.toString()}") } } databaseTimeZone = theTimeZone != null ? theTimeZone : TimeZone.getDefault() logger.info("Database time zone is ${databaseTimeZone}") Locale theLocale = null if (entityFacadeNode.attribute("database-locale")) { try { String localeStr = entityFacadeNode.attribute("database-locale") if (localeStr) theLocale = localeStr.contains("_") ? new Locale(localeStr.substring(0, localeStr.indexOf("_")), localeStr.substring(localeStr.indexOf("_")+1).toUpperCase()) : new Locale(localeStr) } catch (Exception e) { logger.warn("Error parsing database-locale: ${e.toString()}") } } databaseLocale = theLocale ?: Locale.getDefault() // init entity meta-data entityDefinitionCache = ecfi.cacheFacade.getCache("entity.definition") entityLocationSingleCache = ecfi.cacheFacade.getCache("entity.location") // NOTE: don't try to load entity locations before constructor is complete; this.loadAllEntityLocations() entitySequenceBankCache = ecfi.cacheFacade.getCache("entity.sequence.bank") // init connection pool (DataSource) for each group initAllDatasources() entityCache = new EntityCache(this) entityDataFeed = new EntityDataFeed(this) entityDataDocument = new EntityDataDocument(this) emptyList = new EntityListImpl(this) emptyList.setFromCache() } void postFacadeInit() { // ========== load a few things in advance so first page hit is faster in production (in dev mode will reload anyway as caches timeout) // load entity definitions logger.info("Loading entity definitions") long entityStartTime = System.currentTimeMillis() loadAllEntityLocations() int entityCount = loadAllEntityDefinitions() // don't always load/warm framework entities, in production warms anyway and in dev not needed: entityFacade.loadFrameworkEntities() logger.info("Loaded ${entityCount} entity definitions in ${System.currentTimeMillis() - entityStartTime}ms") // now that everything is started up, if configured check all entity tables checkInitDatasourceTables() // EECA rule tables loadEecaRulesAll() } void destroy() { Set groupNames = this.datasourceFactoryByGroupMap.keySet() for (String groupName in groupNames) { EntityDatasourceFactory edf = this.datasourceFactoryByGroupMap.get(groupName) this.datasourceFactoryByGroupMap.put(groupName, null) edf.destroy() } if (statementExecutor != null) { statementExecutor.shutdown() statementExecutor.awaitTermination(5, TimeUnit.SECONDS) } } EntityCache getEntityCache() { return entityCache } EntityDataFeed getEntityDataFeed() { return entityDataFeed } EntityDataDocument getEntityDataDocument() { return entityDataDocument } String getDefaultGroupName() { return defaultGroupName } // NOTE: used in scripts, etc TimeZone getDatabaseTimeZone() { return databaseTimeZone } Locale getDatabaseLocale() { return databaseLocale } EntityListImpl getEmptyList() { return emptyList } @Override Calendar getCalendarForTzLc() { // the OLD approach using user's TimeZone/Locale, bad idea because user may change for same record, getting different value, etc // return efi.getEcfi().getExecutionContext().getUser().getCalendarForTzLcOnly() // the safest approach but from profiling tests this is VERY slow // return Calendar.getInstance(databaseTimeZone, databaseLocale) // NOTE: this approach is faster but seems to cause errors with Derby (ERROR 22007: The string representation of a date/time value is out of range) // return databaseTzLcCalendar // NOTE this field was a Calendar object, is now a ThreadLocal // latest approach to avoid creating a Calendar object for each use, use a ThreadLocal field Calendar dbCal = databaseTzLcCalendar.get() if (dbCal == null) { dbCal = Calendar.getInstance(databaseTimeZone, databaseLocale) dbCal.clear() databaseTzLcCalendar.set(dbCal) } else { dbCal.clear() } return dbCal } MNode getEntityFacadeNode() { return ecfi.getConfXmlRoot().first("entity-facade") } void checkInitDatasourceTables() { // if startup-add-missing=true check tables now long currentTime = System.currentTimeMillis() Set startupAddMissingGroups = new TreeSet<>() Set allConfiguredGroups = new TreeSet<>() for (MNode datasourceNode in getEntityFacadeNode().children("datasource")) { String groupName = datasourceNode.attribute("group-name") MNode databaseNode = getDatabaseNode(groupName) String startupAddMissing = datasourceNode.attribute("startup-add-missing") if ((!startupAddMissing && "true".equals(databaseNode.attribute("default-startup-add-missing"))) || "true".equals(startupAddMissing)) { startupAddMissingGroups.add(groupName) } allConfiguredGroups.add(groupName) } boolean defaultStartAddMissing = startupAddMissingGroups.contains(getEntityFacadeNode().attribute("default-group-name")) if (startupAddMissingGroups.size() > 0) { logger.info("Checking tables for entities in groups ${startupAddMissingGroups}") // check and create all tables boolean createdTables = false for (String groupName in startupAddMissingGroups) { EntityDatasourceFactory edf = getDatasourceFactory(groupName) edf.checkAndAddAllTables() } /* old one at a time approach: for (String entityName in getAllEntityNames()) { String groupName = getEntityGroupName(entityName) ?: defaultGroupName if (startupAddMissingGroups.contains(groupName) || (!allConfiguredGroups.contains(groupName) && defaultStartAddMissing)) { EntityDatasourceFactory edf = getDatasourceFactory(groupName) if (edf.checkAndAddTable(entityName)) createdTables = true } } // do second pass to make sure all FKs created if (createdTables) { logger.info("Tables were created, checking FKs for all entities in groups ${startupAddMissingGroups}") for (String entityName in getAllEntityNames()) { String groupName = getEntityGroupName(entityName) ?: defaultGroupName if (startupAddMissingGroups.contains(groupName) || (!allConfiguredGroups.contains(groupName) && defaultStartAddMissing)) { EntityDatasourceFactory edf = getDatasourceFactory(groupName) if (edf instanceof EntityDatasourceFactoryImpl) { EntityDefinition ed = getEntityDefinition(entityName) if (ed.isViewEntity) continue getEntityDbMeta().createForeignKeys(ed, true) } } } } */ logger.info("Checked tables for all entities in ${(System.currentTimeMillis() - currentTime)/1000} seconds") } } protected void initAllDatasources() { for (MNode datasourceNode in getEntityFacadeNode().children("datasource")) { datasourceNode.setSystemExpandAttributes(true) String groupName = datasourceNode.attribute("group-name") if ("true".equals(datasourceNode.attribute("disabled"))) { logger.info("Skipping disabled datasource ${groupName}") continue } String objectFactoryClass = datasourceNode.attribute("object-factory") ?: "org.moqui.impl.entity.EntityDatasourceFactoryImpl" EntityDatasourceFactory edf = (EntityDatasourceFactory) Thread.currentThread().getContextClassLoader().loadClass(objectFactoryClass).newInstance() datasourceFactoryByGroupMap.put(groupName, edf.init(this, datasourceNode)) } } static class DatasourceInfo { EntityFacadeImpl efi MNode datasourceNode String uniqueName Map dsDetails = new LinkedHashMap<>() String jndiName MNode serverJndi String jdbcDriver = null, jdbcUri = null, jdbcUsername = null, jdbcPassword = null String xaDsClass = null Properties xaProps = null MNode inlineJdbc = null MNode database = null DatasourceInfo(EntityFacadeImpl efi, MNode datasourceNode) { this.efi = efi this.datasourceNode = datasourceNode String groupName = datasourceNode.attribute("group-name") uniqueName = groupName + "_DS" MNode jndiJdbcNode = datasourceNode.first("jndi-jdbc") inlineJdbc = datasourceNode.first("inline-jdbc") if (jndiJdbcNode == null && inlineJdbc == null) { MNode dbNode = efi.getDatabaseNode(groupName) inlineJdbc = dbNode.first("inline-jdbc") } MNode xaProperties = inlineJdbc?.first("xa-properties") database = efi.getDatabaseNode(groupName) if (jndiJdbcNode != null) { serverJndi = efi.getEntityFacadeNode().first("server-jndi") if (serverJndi != null) serverJndi.setSystemExpandAttributes(true) jndiName = jndiJdbcNode.attribute("jndi-name") } else if (xaProperties != null) { xaDsClass = inlineJdbc.attribute("xa-ds-class") ? inlineJdbc.attribute("xa-ds-class") : database.attribute("default-xa-ds-class") xaProps = new Properties() xaProperties.setSystemExpandAttributes(true) for (String key in xaProperties.attributes.keySet()) { if (xaProps.containsKey(key)) continue // various H2, Derby, etc properties have a ${moqui.runtime} which is a System property, others may have it too String propValue = xaProperties.attribute(key) if (propValue) xaProps.setProperty(key, propValue) } for (String propName in xaProps.stringPropertyNames()) { if (propName.toLowerCase().contains("password")) continue dsDetails.put(propName, xaProps.getProperty(propName)) } } else if (inlineJdbc != null) { inlineJdbc.setSystemExpandAttributes(true) jdbcDriver = inlineJdbc.attribute("jdbc-driver") ? inlineJdbc.attribute("jdbc-driver") : database.attribute("default-jdbc-driver") jdbcUri = inlineJdbc.attribute("jdbc-uri") if (jdbcUri.contains('${')) jdbcUri = SystemBinding.expand(jdbcUri) jdbcUsername = inlineJdbc.attribute("jdbc-username") jdbcPassword = inlineJdbc.attribute("jdbc-password") dsDetails.put("uri", jdbcUri) dsDetails.put("user", jdbcUsername) } else { throw new EntityException("Data source for group ${groupName} has no inline-jdbc or jndi-jdbc configuration") } } } void loadFrameworkEntities() { // load framework entity definitions (moqui.*) long startTime = System.currentTimeMillis() Set entityNames = getAllEntityNames() int entityCount = 0 for (String entityName in entityNames) { if (entityName.startsWith("moqui.")) { entityCount++ try { EntityDefinition ed = getEntityDefinition(entityName) ed.getRelationshipInfoMap() // must use EntityDatasourceFactory.checkTableExists, NOT entityDbMeta.tableExists(ed) ed.entityInfo.datasourceFactory.checkTableExists(ed.getFullEntityName()) } catch (Throwable t) { logger.warn("Error loading framework entity ${entityName} definitions: ${t.toString()}", t) } } } logger.info("Loaded ${entityCount} framework entity definitions in ${System.currentTimeMillis() - startTime}ms") } final static Set cachedCountEntities = new HashSet<>(["moqui.basic.EnumerationType"]) final static Set cachedListEntities = new HashSet<>([ "moqui.entity.document.DataDocument", "moqui.entity.document.DataDocumentCondition", "moqui.entity.document.DataDocumentField", "moqui.entity.feed.DataFeedAndDocument", "moqui.entity.view.DbViewEntity", "moqui.entity.view.DbViewEntityAlias", "moqui.entity.view.DbViewEntityKeyMap", "moqui.entity.view.DbViewEntityMember", "moqui.screen.ScreenThemeResource", "moqui.screen.SubscreensItem", "moqui.screen.form.DbFormField", "moqui.screen.form.DbFormFieldAttribute", "moqui.screen.form.DbFormFieldEntOpts", "moqui.screen.form.DbFormFieldEntOptsCond", "moqui.screen.form.DbFormFieldEntOptsOrder", "moqui.screen.form.DbFormFieldOption", "moqui.screen.form.DbFormLookup", "moqui.security.ArtifactAuthzCheckView", "moqui.security.ArtifactTarpitCheckView", "moqui.security.ArtifactTarpitLock", "moqui.security.UserGroupMember", "moqui.security.UserGroupPreference" ]) final static Set cachedOneEntities = new HashSet<>([ "moqui.basic.Enumeration", "moqui.basic.LocalizedMessage", "moqui.entity.document.DataDocument", "moqui.entity.view.DbViewEntity", "moqui.screen.form.DbForm", "moqui.security.UserAccount", "moqui.security.UserPreference", "moqui.security.UserScreenTheme", "moqui.server.Visit" ]) void warmCache() { logger.info("Warming cache for all entity definitions") long startTime = System.currentTimeMillis() Set entityNames = getAllEntityNames() for (String entityName in entityNames) { try { EntityDefinition ed = getEntityDefinition(entityName) ed.getRelationshipInfoMap() // must use EntityDatasourceFactory.checkTableExists, NOT entityDbMeta.tableExists(ed) ed.entityInfo.datasourceFactory.checkTableExists(ed.getFullEntityName()) if (cachedCountEntities.contains(entityName)) ed.getCacheCount(entityCache) if (cachedListEntities.contains(entityName)) { ed.getCacheList(entityCache) ed.getCacheListRa(entityCache) ed.getCacheListViewRa(entityCache) } if (cachedOneEntities.contains(entityName)) { ed.getCacheOne(entityCache) ed.getCacheOneRa(entityCache) ed.getCacheOneViewRa(entityCache) } } catch (Throwable t) { logger.warn("Error warming entity cache: ${t.toString()}") } } logger.info("Warmed entity definition cache for ${entityNames.size()} entities in ${System.currentTimeMillis() - startTime}ms") } Set getDatasourceGroupNames() { Set groupNames = new TreeSet() for (MNode datasourceNode in getEntityFacadeNode().children("datasource")) { groupNames.add((String) datasourceNode.attribute("group-name")) } return groupNames } static int getTxIsolationFromString(String isolationLevel) { if (!isolationLevel) return -1 if ("Serializable".equals(isolationLevel)) { return Connection.TRANSACTION_SERIALIZABLE } else if ("RepeatableRead".equals(isolationLevel)) { return Connection.TRANSACTION_REPEATABLE_READ } else if ("ReadUncommitted".equals(isolationLevel)) { return Connection.TRANSACTION_READ_UNCOMMITTED } else if ("ReadCommitted".equals(isolationLevel)) { return Connection.TRANSACTION_READ_COMMITTED } else if ("None".equals(isolationLevel)) { return Connection.TRANSACTION_NONE } else { return -1 } } List getAllEntityFileLocations() { List entityRrList = new LinkedList() entityRrList.addAll(getConfEntityFileLocations()) entityRrList.addAll(getComponentEntityFileLocations(null)) return entityRrList } List getConfEntityFileLocations() { List entityRrList = new LinkedList() // loop through all of the entity-facade.load-entity nodes, check each for "" root element for (MNode loadEntity in getEntityFacadeNode().children("load-entity")) { entityRrList.add(this.ecfi.resourceFacade.getLocationReference((String) loadEntity.attribute("location"))) } return entityRrList } List getComponentEntityFileLocations(List componentNameList) { List entityRrList = new LinkedList() List componentBaseLocations if (componentNameList) { componentBaseLocations = [] for (String cn in componentNameList) componentBaseLocations.add(ecfi.getComponentBaseLocations().get(cn)) } else { componentBaseLocations = new ArrayList(ecfi.getComponentBaseLocations().values()) } // loop through components look for XML files in the entity directory, check each for "" root element for (String location in componentBaseLocations) { ResourceReference entityDirRr = ecfi.resourceFacade.getLocationReference(location + "/entity") if (entityDirRr.supportsAll()) { // if directory doesn't exist skip it, component doesn't have an entity directory if (!entityDirRr.exists || !entityDirRr.isDirectory()) continue // get all files in the directory TreeMap entityDirEntries = new TreeMap() for (ResourceReference entityRr in entityDirRr.directoryEntries) { if (!entityRr.isFile() || !entityRr.location.endsWith(".xml")) continue entityDirEntries.put(entityRr.getFileName(), entityRr) } for (Map.Entry entityDirEntry in entityDirEntries) { entityRrList.add(entityDirEntry.getValue()) } } else { // just warn here, no exception because any non-file component location would blow everything up logger.warn("Cannot load entity directory in component location [${location}] because protocol [${entityDirRr.uri.scheme}] is not supported.") } } return entityRrList } Map> loadAllEntityLocations() { // lock or wait for lock, this lock used here and for checking entity defined locationLoadLock.lock() try { // load all entity files based on ResourceReference long startTime = System.currentTimeMillis() Map> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName) // when loading all entity locations we expect this to be null, if it isn't no need to load if (entityLocationCache != null) return entityLocationCache entityLocationCache = new HashMap<>() List allEntityFileLocations = getAllEntityFileLocations() for (ResourceReference entityRr in allEntityFileLocations) this.loadEntityFileLocations(entityRr, entityLocationCache) if (logger.isInfoEnabled()) logger.info("Found entities in ${allEntityFileLocations.size()} files in ${System.currentTimeMillis() - startTime}ms") // put in the cache for other code to use; needed before DbViewEntity load so DB queries work entityLocationSingleCache.put(entityLocSingleEntryName, entityLocationCache) // look for view-entity definitions in the database (moqui.entity.view.DbViewEntity) if (entityLocationCache.get("moqui.entity.view.DbViewEntity")) { int numDbViewEntities = 0 for (EntityValue dbViewEntity in find("moqui.entity.view.DbViewEntity").list()) { if (dbViewEntity.packageName) { List pkgList = (List) entityLocationCache.get((String) dbViewEntity.packageName + "." + dbViewEntity.dbViewEntityName) if (pkgList == null) { pkgList = new LinkedList<>() entityLocationCache.put((String) dbViewEntity.packageName + "." + dbViewEntity.dbViewEntityName, pkgList) } if (!pkgList.contains("_DB_VIEW_ENTITY_")) pkgList.add("_DB_VIEW_ENTITY_") } List nameList = (List) entityLocationCache.get((String) dbViewEntity.dbViewEntityName) if (nameList == null) { nameList = new LinkedList<>() // put in cache under both plain entityName and fullEntityName entityLocationCache.put((String) dbViewEntity.dbViewEntityName, nameList) } if (!nameList.contains("_DB_VIEW_ENTITY_")) nameList.add("_DB_VIEW_ENTITY_") numDbViewEntities++ } if (logger.infoEnabled) logger.info("Found ${numDbViewEntities} view-entity definitions in database (DbViewEntity records)") } else { logger.warn("Could not find view-entity definitions in database (moqui.entity.view.DbViewEntity), no location found for the moqui.entity.view.DbViewEntity entity.") } /* a little code to show all entities and their locations Set enSet = new TreeSet(entityLocationCache.keySet()) for (String en in enSet) { List lst = entityLocationCache.get(en) entityLocationCache.put(en, Collections.unmodifiableList(lst)) logger.warn("TOREMOVE entity ${en}: ${lst}") } */ return entityLocationCache } finally { locationLoadLock.unlock() } } // NOTE: only called by loadAllEntityLocations() which is synchronized/locked, so doesn't need to be protected void loadEntityFileLocations(ResourceReference entityRr, Map> entityLocationCache) { MNode entityRoot = getEntityFileRoot(entityRr) if (entityRoot.name == "entities") { // loop through all entity, view-entity, and extend-entity and add file location to List for any entity named int numEntities = 0 for (MNode entity in entityRoot.children) { String entityName = entity.attribute("entity-name") String packageName = entity.attribute("package") if (packageName == null || packageName.isEmpty()) packageName = entity.attribute("package-name") String shortAlias = entity.attribute("short-alias") if (entityName == null || entityName.length() == 0) { logger.warn("Skipping entity XML file [${entityRr.getLocation()}] element with no @entity-name: ${entity}") continue } List locList = (List) entityLocationCache.get(entityName) if (locList == null) { locList = new LinkedList<>() locList.add(entityRr.location) entityLocationCache.put(entityName, locList) } else if (!locList.contains(entityRr.location)) { locList.add(entityRr.location) } if (packageName != null && packageName.length() > 0) { String fullEntityName = packageName.concat(".").concat(entityName) if (!entityLocationCache.containsKey(fullEntityName)) entityLocationCache.put(fullEntityName, locList) } if (shortAlias != null && shortAlias.length() > 0) { if (!entityLocationCache.containsKey(shortAlias)) entityLocationCache.put(shortAlias, locList) } numEntities++ } if (isTraceEnabled) logger.trace("Found [${numEntities}] entity definitions in [${entityRr.location}]") } } protected static MNode getEntityFileRoot(ResourceReference entityRr) { return MNode.parse(entityRr) } int loadAllEntityDefinitions() { int entityCount = 0 for (String en in getAllEntityNames()) { try { getEntityDefinition(en) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) continue } entityCount++ } return entityCount } protected EntityDefinition loadEntityDefinition(String entityName) { if (entityName.contains("#")) { // this is a relationship name, definitely not an entity name so just return null; this happens because we // check if a name is an entity name or not in various places including where relationships are checked return null } EntityDefinition ed = (EntityDefinition) entityDefinitionCache.get(entityName) if (ed != null) return ed Map> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName) if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations() List entityLocationList = (List) entityLocationCache.get(entityName) if (entityLocationList == null) { if (logger.isWarnEnabled()) logger.warn("No location cache found for entity-name [${entityName}], reloading ALL entity file and DB locations") if (isTraceEnabled) logger.trace("Unknown entity name ${entityName} location", new BaseException("Unknown entity name location")) // remove the single cache entry entityLocationSingleCache.remove(entityLocSingleEntryName) // reload all locations entityLocationCache = this.loadAllEntityLocations() entityLocationList = (List) entityLocationCache.get(entityName) // no locations found for this entity, entity probably doesn't exist if (entityLocationList == null || entityLocationList.size() == 0) { // TODO: while this is helpful, if another unknown non-existing entity is looked for this will be lost entityLocationCache.put(entityName, new LinkedList()) if (logger.isWarnEnabled()) logger.warn("No definition found for entity-name [${entityName}]") throw new EntityNotFoundException("No definition found for entity-name [${entityName}]") } } if (entityLocationList.size() == 0) { if (isTraceEnabled) logger.trace("Entity name [${entityName}] is a known non-entity, returning null for EntityDefinition.") return null } String packageName = null if (entityName.contains('.')) { packageName = entityName.substring(0, entityName.lastIndexOf(".")) entityName = entityName.substring(entityName.lastIndexOf(".")+1) } // if (!packageName) logger.warn("TOREMOVE finding entity def for [${entityName}] with no packageName, entityLocationList=${entityLocationList}") // If this is a moqui.entity.view.DbViewEntity, handle that in a special way (generate the Nodes from the DB records) if (entityLocationList.contains("_DB_VIEW_ENTITY_")) { EntityValue dbViewEntity = find("moqui.entity.view.DbViewEntity").condition("dbViewEntityName", entityName).one() if (dbViewEntity == null) { logger.warn("Could not find DbViewEntity with name ${entityName}") return null } MNode dbViewNode = new MNode("view-entity", ["entity-name":entityName, "package":(String) dbViewEntity.packageName]) if (dbViewEntity.cache == "Y") dbViewNode.attributes.put("cache", "true") else if (dbViewEntity.cache == "N") dbViewNode.attributes.put("cache", "false") EntityList memberList = find("moqui.entity.view.DbViewEntityMember").condition("dbViewEntityName", entityName).list() for (EntityValue dbViewEntityMember in memberList) { MNode memberEntity = dbViewNode.append("member-entity", ["entity-alias":dbViewEntityMember.getString("entityAlias"), "entity-name":dbViewEntityMember.getString("entityName")]) if (dbViewEntityMember.joinFromAlias) { memberEntity.attributes.put("join-from-alias", (String) dbViewEntityMember.joinFromAlias) if (dbViewEntityMember.joinOptional == "Y") memberEntity.attributes.put("join-optional", "true") } EntityList dbViewEntityKeyMapList = find("moqui.entity.view.DbViewEntityKeyMap") .condition(["dbViewEntityName":entityName, "joinFromAlias":dbViewEntityMember.joinFromAlias, "entityAlias":dbViewEntityMember.getString("entityAlias")]) .list() for (EntityValue dbViewEntityKeyMap in dbViewEntityKeyMapList) { MNode keyMapNode = memberEntity.append("key-map", ["field-name":(String) dbViewEntityKeyMap.fieldName]) if (dbViewEntityKeyMap.relatedFieldName) keyMapNode.attributes.put("related", (String) dbViewEntityKeyMap.relatedFieldName) } } for (EntityValue dbViewEntityAlias in find("moqui.entity.view.DbViewEntityAlias").condition("dbViewEntityName", entityName).list()) { MNode aliasNode = dbViewNode.append("alias", ["name":(String) dbViewEntityAlias.fieldAlias, "entity-alias":(String) dbViewEntityAlias.entityAlias]) if (dbViewEntityAlias.fieldName) aliasNode.attributes.put("field", (String) dbViewEntityAlias.fieldName) if (dbViewEntityAlias.functionName) aliasNode.attributes.put("function", (String) dbViewEntityAlias.functionName) } // create the new EntityDefinition ed = new EntityDefinition(this, dbViewNode) // cache it under entityName, fullEntityName, and short-alias String fullEntityName = ed.fullEntityName if (fullEntityName.startsWith("moqui.")) { frameworkEntityDefinitions.put(ed.entityInfo.internalEntityName, ed) frameworkEntityDefinitions.put(fullEntityName, ed) if (ed.entityInfo.shortAlias) frameworkEntityDefinitions.put(ed.entityInfo.shortAlias, ed) } else { entityDefinitionCache.put(ed.entityInfo.internalEntityName, ed) entityDefinitionCache.put(fullEntityName, ed) if (ed.entityInfo.shortAlias) entityDefinitionCache.put(ed.entityInfo.shortAlias, ed) } // send it on its way return ed } // get entity, view-entity and extend-entity Nodes for entity from each location MNode entityNode = null List extendEntityNodes = new ArrayList() for (String location in entityLocationList) { MNode entityRoot = getEntityFileRoot(this.ecfi.resourceFacade.getLocationReference(location)) // filter by package if specified, otherwise grab whatever List packageChildren = entityRoot.children .findAll({ (it.attribute("entity-name") == entityName || it.attribute("short-alias") == entityName) && (packageName ? (it.attribute("package") == packageName || it.attribute("package-name") == packageName) : true) }) for (MNode childNode in packageChildren) { if (childNode.name == "extend-entity") { extendEntityNodes.add(childNode) } else { if (entityNode != null) logger.warn("Entity [${entityName}] was found again at [${location}], so overriding definition from previous location") entityNode = childNode.deepCopy(null) } } } if (entityNode == null) throw new EntityNotFoundException("No definition found for entity [${entityName}]${packageName ? ' in package ['+packageName+']' : ''}") // if entityName is a short-alias extend-entity elements won't match it, so find them again now that we have the main entityNode if (entityName == entityNode.attribute("short-alias")) { entityName = entityNode.attribute("entity-name") packageName = entityNode.attribute("package") ?: entityNode.attribute("package-name") for (String location in entityLocationList) { MNode entityRoot = getEntityFileRoot(this.ecfi.resourceFacade.getLocationReference(location)) List packageChildren = entityRoot.children .findAll({ it.attribute("entity-name") == entityName && (packageName ? (it.attribute("package") == packageName || it.attribute("package-name") == packageName) : true) }) for (MNode childNode in packageChildren) { if (childNode.name == "extend-entity") { extendEntityNodes.add(childNode) } } } } // if (entityName.endsWith("xample")) logger.warn("======== Creating Example ED entityNode=${entityNode}\nextendEntityNodes: ${extendEntityNodes}") // merge the extend-entity nodes for (MNode extendEntity in extendEntityNodes) { // if package attributes don't match, skip String entityPackage = entityNode.attribute("package") ?: entityNode.attribute("package-name") String extendPackage = extendEntity.attribute("package") ?: extendEntity.attribute("package-name") if (entityPackage != extendPackage) continue // merge attributes entityNode.attributes.putAll(extendEntity.attributes) // merge field nodes for (MNode childOverrideNode in extendEntity.children("field")) { String keyValue = childOverrideNode.attribute("name") MNode childBaseNode = entityNode.first({ MNode it -> it.name == "field" && it.attribute("name") == keyValue }) if (childBaseNode) childBaseNode.attributes.putAll(childOverrideNode.attributes) else entityNode.append(childOverrideNode) } // add relationship, key-map (copy over, will get child nodes too ArrayList relNodeList = extendEntity.children("relationship") for (int i = 0; i < relNodeList.size(); i++) { MNode copyNode = relNodeList.get(i) int curNodeIndex = entityNode.children .findIndexOf({ MNode it -> String itRelated = it.attribute('related') ?: it.attribute('related-entity-name'); String copyRelated = copyNode.attribute('related') ?: copyNode.attribute('related-entity-name'); return it.name == "relationship" && itRelated == copyRelated && it.attribute('title') == copyNode.attribute('title'); }) if (curNodeIndex >= 0) { entityNode.children.set(curNodeIndex, copyNode) } else { entityNode.append(copyNode) } } // add index, index-field for (MNode copyNode in extendEntity.children("index")) { int curNodeIndex = entityNode.children .findIndexOf({ MNode it -> it.name == "index" && it.attribute('name') == copyNode.attribute('name') }) if (curNodeIndex >= 0) { entityNode.children.set(curNodeIndex, copyNode) } else { entityNode.append(copyNode) } } // copy master nodes (will be merged on parse) // TODO: check master/detail existence before append it into entityNode for (MNode copyNode in extendEntity.children("master")) entityNode.append(copyNode) } // create the new EntityDefinition ed = new EntityDefinition(this, entityNode) // cache it under entityName, fullEntityName, and short-alias String fullEntityName = ed.fullEntityName if (fullEntityName.startsWith("moqui.")) { frameworkEntityDefinitions.put(ed.entityInfo.internalEntityName, ed) frameworkEntityDefinitions.put(fullEntityName, ed) if (ed.entityInfo.shortAlias) frameworkEntityDefinitions.put(ed.entityInfo.shortAlias, ed) } else { entityDefinitionCache.put(ed.entityInfo.internalEntityName, ed) entityDefinitionCache.put(fullEntityName, ed) if (ed.entityInfo.shortAlias) entityDefinitionCache.put(ed.entityInfo.shortAlias, ed) } // send it on its way return ed } synchronized void createAllAutoReverseManyRelationships() { int relationshipsCreated = 0 Set entityNameSet = getAllEntityNames() for (String entityName in entityNameSet) { EntityDefinition ed // for auto reverse relationships just ignore EntityException on getEntityDefinition try { ed = getEntityDefinition(entityName) } catch (EntityException e) { if (isTraceEnabled) logger.trace("Entity not found", e); continue; } // may happen if all entity names includes a DB view entity or other that doesn't really exist if (ed == null) continue String edEntityName = ed.entityInfo.internalEntityName String edFullEntityName = ed.fullEntityName List pkSet = ed.getPkFieldNames() ArrayList relationshipList = ed.entityNode.children("relationship") int relationshipListSize = relationshipList.size() for (int rlIndex = 0; rlIndex < relationshipListSize; rlIndex++) { MNode relNode = (MNode) relationshipList.get(rlIndex) // don't create reverse for auto reference relationships if ("true".equals(relNode.attribute("is-auto-reverse"))) continue String relatedEntityName = relNode.attribute("related") if (relatedEntityName == null || relatedEntityName.length() == 0) relatedEntityName = relNode.attribute("related-entity-name") // don't create reverse relationships coming back to the same entity, since it will have the same title // it would create multiple relationships with the same name if (entityName.equals(relatedEntityName)) continue EntityDefinition reverseEd try { reverseEd = getEntityDefinition(relatedEntityName) } catch (EntityException e) { logger.warn("Error getting definition for entity [${relatedEntityName}] referred to in a relationship of entity [${entityName}]: ${e.toString()}") continue } if (reverseEd == null) { logger.warn("Could not find definition for entity [${relatedEntityName}] referred to in a relationship of entity [${entityName}]") continue } List reversePkSet = reverseEd.getPkFieldNames() String relType = reversePkSet.equals(pkSet) ? "one-nofk" : "many" String title = relNode.attribute('title') boolean hasTitle = title != null && title.length() > 0 // does a relationship coming back already exist? boolean foundReverse = false ArrayList reverseRelList = reverseEd.entityNode.children("relationship") int reverseRelListSize = reverseRelList.size() for (int i = 0; i < reverseRelListSize; i++) { MNode reverseRelNode = (MNode) reverseRelList.get(i) String related = reverseRelNode.attribute("related") if (related == null || related.length() == 0) related = reverseRelNode.attribute("related-entity-name") if (!edEntityName.equals(related) && !edFullEntityName.equals(related)) continue // TODO: instead of checking title check reverse expanded key-map String reverseTitle = reverseRelNode.attribute("title") if (hasTitle) { if (!title.equals(reverseTitle)) continue } else { if (reverseTitle != null && reverseTitle.length() > 0) continue } foundReverse = true } // NOTE: removed "it."@type" == relType && ", if there is already any relationship coming back don't create the reverse if (foundReverse) { // NOTE DEJ 20150314 Just track auto-reverse, not one-reverse // make sure has is-one-reverse="true" // reverseRelNode.attributes().put("is-one-reverse", "true") continue } // track the fact that the related entity has others pointing back to it, unless original relationship is type many (doesn't qualify) if (!ed.isViewEntity && !"many".equals(relNode.attribute("type"))) reverseEd.entityNode.attributes.put("has-dependents", "true") // create a new reverse-many relationship Map keyMap = EntityDefinition.getRelationshipExpandedKeyMapInternal(relNode, reverseEd) MNode newRelNode = reverseEd.entityNode.append("relationship", ["related":edFullEntityName, "type":relType, "is-auto-reverse":"true", "mutable":"true"]) if (hasTitle) newRelNode.attributes.put("title", title) for (Map.Entry keyEntry in keyMap) { // add a key-map with the reverse fields newRelNode.append("key-map", ["field-name":keyEntry.value, "related":keyEntry.key]) } relationshipsCreated++ } } // all EntityDefinition objects now have reverse relationships in place, remember that so this will only be // called for new ones, not from cache for (String entityName in entityNameSet) { EntityDefinition ed try { ed = getEntityDefinition(entityName) } catch (EntityException e) { if (isTraceEnabled) logger.trace("Entity not found", e); continue; } if (ed == null) continue ed.setHasReverseRelationships() } if (logger.infoEnabled && relationshipsCreated > 0) logger.info("Created ${relationshipsCreated} automatic reverse relationships") } // used in tools screen int getEecaRuleCount() { int count = 0 for (List ruleList in eecaRulesByEntityName.values()) count += ruleList.size() return count } void loadEecaRulesAll() { int numLoaded = 0 int numFiles = 0 HashMap ruleByIdMap = new HashMap<>() LinkedList ruleNoIdList = new LinkedList<>() List allEntityFileLocations = getAllEntityFileLocations() for (ResourceReference rr in allEntityFileLocations) { if (!rr.fileName.endsWith(".eecas.xml")) continue numLoaded += loadEecaRulesFile(rr, ruleByIdMap, ruleNoIdList) numFiles++ } /* // search for the service def XML file in the components for (String location in this.ecfi.getComponentBaseLocations().values()) { ResourceReference entityDirRr = this.ecfi.resourceFacade.getLocationReference(location + "/entity") if (entityDirRr.supportsAll()) { // if for some weird reason this isn't a directory, skip it if (!entityDirRr.isDirectory()) continue for (ResourceReference rr in entityDirRr.directoryEntries) { if (!rr.fileName.endsWith(".eecas.xml")) continue numLoaded += loadEecaRulesFile(rr, ruleByIdMap, ruleNoIdList) numFiles++ } } else { logger.warn("Can't load EECA rules from component at [${entityDirRr.location}] because it doesn't support exists/directory/etc") } } */ if (logger.infoEnabled) logger.info("Loaded ${numLoaded} Entity ECA rules from ${numFiles} .eecas.xml files, ${ruleNoIdList.size()} rules have no id, ${ruleNoIdList.size() + ruleByIdMap.size()} EECA rules active") HashMap> ruleMap = new HashMap<>() ruleNoIdList.addAll(ruleByIdMap.values()) for (EntityEcaRule ecaRule in ruleNoIdList) { EntityDefinition ed = getEntityDefinition(ecaRule.entityName) String entityName = ed.getFullEntityName() ArrayList lst = ruleMap.get(entityName) if (lst == null) { lst = new ArrayList() ruleMap.put(entityName, lst) } lst.add(ecaRule) } // replace entire EECA rules Map in one operation eecaRulesByEntityName = ruleMap } int loadEecaRulesFile(ResourceReference rr, HashMap ruleByIdMap, LinkedList ruleNoIdList) { MNode eecasRoot = MNode.parse(rr) int numLoaded = 0 for (MNode eecaNode in eecasRoot.children("eeca")) { String entityName = eecaNode.attribute("entity") if (!isEntityDefined(entityName)) { logger.warn("Invalid entity name ${entityName} found in EECA file ${rr.location}, skipping") continue } EntityEcaRule ecaRule = new EntityEcaRule(ecfi, eecaNode, rr.location) String ruleId = eecaNode.attribute("id") if (ruleId != null && !ruleId.isEmpty()) ruleByIdMap.put(ruleId, ecaRule) else ruleNoIdList.add(ecaRule) numLoaded++ } if (logger.isTraceEnabled()) logger.trace("Loaded [${numLoaded}] Entity ECA rules from [${rr.location}]") return numLoaded } boolean hasEecaRules(String entityName) { return eecaRulesByEntityName.get(entityName) != null } void runEecaRules(String entityName, Map fieldValues, String operation, boolean before) { ArrayList lst = (ArrayList) eecaRulesByEntityName.get(entityName) if (lst != null && lst.size() > 0) { // if Entity ECA rules disabled in ArtifactExecutionFacade, just return immediately // do this only if there are EECA rules to run, small cost in getEci, etc if (ecfi.getEci().artifactExecutionFacade.entityEcaDisabled()) return for (int i = 0; i < lst.size(); i++) { EntityEcaRule eer = (EntityEcaRule) lst.get(i) eer.runIfMatches(entityName, fieldValues, operation, before, ecfi.getEci()) } } } // used in tools screen void checkAllEntityTables(String groupName) { // TODO: load framework entities first, then component/mantle/etc entities for better FKs on first pass EntityDatasourceFactory edf = getDatasourceFactory(groupName) for (String entityName in getAllEntityNamesInGroup(groupName)) edf.checkAndAddTable(entityName) } Set getAllEntityNames() { return getAllEntityNames(null) } Set getAllEntityNames(String filterRegexp) { Map> entityLocationCache = entityLocationSingleCache.get(entityLocSingleEntryName) if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations() TreeSet allNames = new TreeSet() // only add full entity names (with package in it, will always have at least one dot) // only include entities that have a non-empty List of locations in the cache (otherwise are invalid entities) for (Map.Entry> entry in entityLocationCache.entrySet()) { String en = entry.key List locList = entry.value if (en.contains(".") && locList != null && locList.size() > 0) { // Added (?i) to ignore the case and '*' in the starting and at ending to match if searched string is sub-part of entity name if (filterRegexp != null && !en.matches("(?i).*" + filterRegexp + ".*")) continue allNames.add(en) } } return allNames } Set getAllNonViewEntityNames() { Set allNames = getAllEntityNames() Set nonViewNames = new TreeSet<>() for (String name in allNames) { EntityDefinition ed = getEntityDefinition(name) if (ed != null && !ed.isViewEntity) nonViewNames.add(name) } return nonViewNames } Set getAllEntityNamesWithMaster() { Set allNames = getAllEntityNames() Set masterNames = new TreeSet<>() for (String name in allNames) { EntityDefinition ed try { ed = getEntityDefinition(name) } catch (EntityException e) { if (isTraceEnabled) logger.trace("Entity not found", e); continue; } if (ed != null && !ed.isViewEntity && ed.masterDefinitionMap) masterNames.add(name) } return masterNames } // used in tools screens List getAllEntityInfo(int levels, boolean excludeViewEntities) { Map entityInfoMap = [:] for (String entityName in getAllEntityNames()) { EntityDefinition ed = getEntityDefinition(entityName) boolean isView = ed.isViewEntity if (excludeViewEntities && isView) continue int lastDotIndex = 0 for (int i = 0; i < levels; i++) lastDotIndex = entityName.indexOf(".", lastDotIndex+1) String name = lastDotIndex == -1 ? entityName : entityName.substring(0, lastDotIndex) Map curInfo = entityInfoMap.get(name) if (curInfo) { if (isView) CollectionUtilities.addToBigDecimalInMap("viewEntities", 1.0, curInfo) else CollectionUtilities.addToBigDecimalInMap("entities", 1.0, curInfo) } else { entityInfoMap.put(name, [name:name, entities:(isView ? 0 : 1), viewEntities:(isView ? 1 : 0)]) } } TreeSet nameSet = new TreeSet(entityInfoMap.keySet()) List entityInfoList = [] for (String name in nameSet) entityInfoList.add(entityInfoMap.get(name)) return entityInfoList } /** This is used mostly by the service engine to quickly determine whether a noun is an entity. Called for all * ServiceDefinition init to see if the noun is an entity name. Called by entity auto check if no path and verb is * one of the entity-auto supported verbs. */ boolean isEntityDefined(String entityName) { if (entityName == null) return false // Special treatment for framework entities, quick Map lookup (also faster than Cache get) if (frameworkEntityDefinitions.containsKey(entityName)) return true Map> entityLocationCache = (Map>) entityLocationSingleCache.get(entityLocSingleEntryName) if (entityLocationCache == null) entityLocationCache = loadAllEntityLocations() List locList = (List) entityLocationCache.get(entityName) return locList != null && locList.size() > 0 } EntityDefinition getEntityDefinition(String entityName) { if (entityName == null) return null EntityDefinition ed = (EntityDefinition) frameworkEntityDefinitions.get(entityName) if (ed != null) return ed ed = (EntityDefinition) entityDefinitionCache.get(entityName) if (ed != null) return ed if (entityName.isEmpty()) return null if (entityName.startsWith("DataDocument.")) { return entityDataDocument.makeEntityDefinition(entityName.substring(entityName.indexOf(".") + 1)) } else { return loadEntityDefinition(entityName) } } // used in tools screens void clearEntityDefinitionFromCache(String entityName) { EntityDefinition ed = (EntityDefinition) this.entityDefinitionCache.get(entityName) if (ed != null) { this.entityDefinitionCache.remove(ed.entityInfo.internalEntityName) this.entityDefinitionCache.remove(ed.fullEntityName) if (ed.entityInfo.shortAlias) this.entityDefinitionCache.remove(ed.entityInfo.shortAlias) } } // used in tools screens ArrayList> getAllEntitiesInfo(String orderByField, String filterRegexp, boolean masterEntitiesOnly, boolean excludeViewEntities) { if (masterEntitiesOnly) createAllAutoReverseManyRelationships() ArrayList> eil = new ArrayList<>() for (String en in getAllEntityNames(filterRegexp)) { EntityDefinition ed = null try { ed = getEntityDefinition(en) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) } if (ed == null) continue if (excludeViewEntities && ed.isViewEntity) continue if (masterEntitiesOnly) { if (!(ed.entityNode.attribute("has-dependents") == "true") || en.endsWith("Type") || en == "moqui.basic.Enumeration" || en == "moqui.basic.StatusItem") continue if (ed.getPkFieldNames().size() > 1) continue } eil.add([entityName:ed.entityInfo.internalEntityName, "package":ed.entityNode.attribute("package"), isView:(ed.isViewEntity ? "true" : "false"), fullEntityName:ed.fullEntityName, tableName:ed.tableName] as Map) } if (orderByField != null && !orderByField.isEmpty()) CollectionUtilities.orderMapList(eil, [orderByField]) return eil } // used in tools screen (EntityDbView) ArrayList> getAllEntityRelatedFields(String en, String orderByField, String dbViewEntityName) { // make sure reverse-one many relationships exist createAllAutoReverseManyRelationships() EntityValue dbViewEntity = dbViewEntityName ? find("moqui.entity.view.DbViewEntity").condition("dbViewEntityName", dbViewEntityName).one() : null ArrayList> efl = new ArrayList<>() EntityDefinition ed = null try { ed = getEntityDefinition(en) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) } if (ed == null) return efl // first get fields of the main entity for (String fn in ed.getAllFieldNames()) { MNode fieldNode = ed.getFieldNode(fn) boolean inDbView = false String functionName = null EntityValue aliasVal = find("moqui.entity.view.DbViewEntityAlias") .condition([dbViewEntityName:dbViewEntityName, entityAlias:"MASTER", fieldName:fn] as Map).one() if (aliasVal) { inDbView = true functionName = aliasVal.functionName } efl.add([entityName:en, fieldName:fn, type:fieldNode.attribute("type"), cardinality:"one", inDbView:inDbView, functionName:functionName] as Map) } // loop through all related entities and get their fields too for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { //[type:relNode."@type", title:(relNode."@title"?:""), relatedEntityName:relNode."@related-entity-name", // keyMap:keyMap, targetParameterMap:targetParameterMap, prettyName:prettyName] EntityDefinition red = null try { red = getEntityDefinition((String) relInfo.relatedEntityName) } catch (EntityException e) { logger.warn("Problem finding entity definition", e) } if (red == null) continue EntityValue dbViewEntityMember = null if (dbViewEntity) dbViewEntityMember = find("moqui.entity.view.DbViewEntityMember") .condition([dbViewEntityName:dbViewEntityName, entityName:red.getFullEntityName()] as Map).one() for (String fn in red.getAllFieldNames()) { MNode fieldNode = red.getFieldNode(fn) boolean inDbView = false String functionName = null if (dbViewEntityMember) { EntityValue aliasVal = find("moqui.entity.view.DbViewEntityAlias") .condition([dbViewEntityName:dbViewEntityName, entityAlias:dbViewEntityMember.entityAlias, fieldName:fn]).one() if (aliasVal) { inDbView = true functionName = aliasVal.functionName } } efl.add([entityName:relInfo.relatedEntityName, fieldName:fn, type:fieldNode.attribute("type"), cardinality:relInfo.type, title:relInfo.title, inDbView:inDbView, functionName:functionName] as Map) } } if (orderByField) CollectionUtilities.orderMapList(efl, [orderByField]) return efl } MNode getDatabaseNode(String groupName) { MNode node = databaseNodeByGroupName.get(groupName) if (node != null) return node return findDatabaseNode(groupName) } protected MNode findDatabaseNode(String groupName) { MNode datasourceNode = getDatasourceNode(groupName) String databaseConfName = datasourceNode.attribute("database-conf-name") MNode node = ecfi.confXmlRoot.first("database-list") .first({ MNode it -> it.name == 'database' && it.attribute("name") == databaseConfName }) databaseNodeByGroupName.put(groupName, node) return node } protected MNode getDatabaseNodeByConf(String confName) { return ecfi.confXmlRoot.first("database-list") .first({ MNode it -> it.name == 'database' && it.attribute("name") == confName }) } String getDatabaseConfName(String entityName) { MNode dsNode = getDatasourceNode(getEntityGroupName(entityName)) if (dsNode == null) return null return dsNode.attribute("database-conf-name") } MNode getDatasourceNode(String groupName) { MNode node = datasourceNodeByGroupName.get(groupName) if (node != null) return node return findDatasourceNode(groupName) } protected MNode findDatasourceNode(String groupName) { MNode dsNode = getEntityFacadeNode().first({ MNode it -> it.name == 'datasource' && it.attribute("group-name") == groupName }) if (dsNode == null) dsNode = getEntityFacadeNode() .first({ MNode it -> it.name == 'datasource' && it.attribute("group-name") == defaultGroupName }) dsNode.setSystemExpandAttributes(true) datasourceNodeByGroupName.put(groupName, dsNode) return dsNode } EntityDbMeta getEntityDbMeta() { return dbMeta != null ? dbMeta : (dbMeta = new EntityDbMeta(this)) } /** Get a JDBC Connection based on xa-properties configuration. The Conf Map should contain the default entity_ds properties * including entity_ds_db_conf, entity_ds_host, entity_ds_port, entity_ds_database, entity_ds_user, entity_ds_password */ XAConnection getConfConnection(Map confMap) { String confName = confMap.entity_ds_db_conf MNode databaseNode = getDatabaseNodeByConf(confName) MNode xaPropsNode = databaseNode.first("inline-jdbc")?.first("xa-properties") if (xaPropsNode == null) throw new IllegalArgumentException("Could not find database.inline-jdbc.xa-properties element for conf name ${confName}") String xaDsClassName = databaseNode.attribute("default-xa-ds-class") if (!xaDsClassName) throw new IllegalArgumentException("Could database conf ${confName} has no default-xa-ds-class attribute") XADataSource xaDs = (XADataSource) ecfi.classLoader.loadClass(xaDsClassName).newInstance() for (Map.Entry attrEntry in xaPropsNode.attributes.entrySet()) { String propValue = ecfi.resourceFacade.expand(attrEntry.value, "", confMap) try { xaDs.putAt(attrEntry.key, propValue) } catch (GroovyCastException e) { if (isTraceEnabled) logger.trace("Cast failed, trying int", e) xaDs.putAt(attrEntry.key, propValue as int) } } return xaDs.getXAConnection(confMap.entity_ds_user, confMap.entity_ds_password) } // used in services int runSqlUpdateConf(CharSequence sql, Map confMap) { // only do one DB meta data operation at a time; may lock above before checking for existence of something to make sure it doesn't get created twice int records = 0 ecfi.transactionFacade.runRequireNew(30, "Error in DB meta data change", false, true, { XAConnection xacon = null Connection con = null Statement stmt = null try { xacon = getConfConnection(confMap) con = xacon.getConnection() stmt = con.createStatement() records = stmt.executeUpdate(sql.toString()) } finally { if (stmt != null) stmt.close() if (con != null) con.close() if (xacon != null) xacon.close() } }) return records } /* this needs more work, can't pass back ResultSet with Connection closed so need to somehow return Connection and ResultSet so both can be closed... ResultSet runSqlQueryConf(CharSequence sql, Map confMap) { Connection con = null Statement stmt = null ResultSet rs = null try { con = getConfConnection(confMap) stmt = con.createStatement() rs = stmt.executeQuery(sql.toString()) } finally { if (stmt != null) stmt.close() if (con != null) con.close() } return rs } */ // used in services long runSqlCountConf(CharSequence from, CharSequence where, Map confMap) { StringBuilder sqlSb = new StringBuilder("SELECT COUNT(*) FROM ").append(from).append(" WHERE ").append(where) XAConnection xacon = null Connection con = null Statement stmt = null ResultSet rs = null try { xacon = getConfConnection(confMap) con = xacon.getConnection() stmt = con.createStatement() rs = stmt.executeQuery(sqlSb.toString()) if (rs.next()) return rs.getLong(1) return 0 } finally { if (stmt != null) stmt.close() if (rs != null) rs.close() if (con != null) con.close() if (xacon != null) xacon.close() } } /* ========================= */ /* Interface Implementations */ /* ========================= */ @Override EntityDatasourceFactory getDatasourceFactory(String groupName) { EntityDatasourceFactory edf = (EntityDatasourceFactory) datasourceFactoryByGroupMap.get(groupName) if (edf == null) edf = (EntityDatasourceFactory) datasourceFactoryByGroupMap.get(defaultGroupName) if (edf == null) throw new EntityException("Could not find EntityDatasourceFactory for entity group ${groupName}") return edf } List> getDataSourcesInfo() { List> dsiList = new LinkedList<>() for (String groupName in datasourceFactoryByGroupMap.keySet()) { EntityDatasourceFactory edf = datasourceFactoryByGroupMap.get(groupName) if (edf instanceof EntityDatasourceFactoryImpl) { EntityDatasourceFactoryImpl edfi = (EntityDatasourceFactoryImpl) edf DatasourceInfo dsi = edfi.dsi dsiList.add([group:groupName, uniqueName:dsi.uniqueName, database:dsi.database.attribute('name'), detail:dsi.dsDetails] as Map) } else { dsiList.add([group:groupName] as Map) } } return dsiList } String getDatasourceCloneName(String groupName) { String baseGroupName = groupName == null || groupName.isEmpty() ? defaultGroupName : groupName String groupPrefix = baseGroupName.concat('#') ArrayList cloneGroupNames = new ArrayList<>(5) for (String curGroup in datasourceFactoryByGroupMap.keySet()) if (curGroup.startsWith(groupPrefix)) cloneGroupNames.add(curGroup) int cloneNamesSize = cloneGroupNames.size() if (cloneNamesSize == 0) { return baseGroupName } else if (cloneNamesSize == 1) { // logger.warn("Using DB clone ${cloneGroupNames.get(0)} instead of ${groupName}") return cloneGroupNames.get(0) } else { return cloneGroupNames.get(ThreadLocalRandom.current().nextInt(cloneNamesSize)) } } @Override EntityConditionFactory getConditionFactory() { return this.entityConditionFactory } EntityConditionFactoryImpl getConditionFactoryImpl() { return this.entityConditionFactory } @Override EntityValue makeValue(String entityName) { // don't check entityName empty, getEntityDefinition() does it EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) throw new EntityException("No entity found with name ${entityName}") return ed.makeEntityValue() } @Override EntityFind find(String entityName) { // don't check entityName empty, getEntityDefinition() does it EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) throw new EntityException("No entity found with name ${entityName}") if (ed.isDynamicView && entityName.startsWith("DataDocument.")) { // see if it happens to be a DataDocument and if so make a special find that has its conditions too // TODO: consider addition condition methods to EntityDynamicView and handling this lower level instead of here return entityDataDocument.makeDataDocumentFind(entityName.substring(entityName.indexOf(".") + 1)) } return ed.makeEntityFind() } @Override EntityFind find(MNode node) { String entityName = node.attribute("entity-name") if (entityName != null && entityName.contains("\${")) entityName = ecfi.resourceFacade.expand(entityName, null) // don't check entityName empty, getEntityDefinition() does it EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) throw new EntityException("No entity found with name ${entityName}") EntityFind ef if (ed.isDynamicView && entityName.startsWith("DataDocument.")) { // see if it happens to be a DataDocument and if so make a special find that has its conditions too // TODO: consider addition condition methods to EntityDynamicView and handling this lower level instead of here ef = entityDataDocument.makeDataDocumentFind(entityName.substring(entityName.indexOf(".") + 1)) } else { ef = ed.makeEntityFind() } String cache = node.attribute("cache") if (cache != null && !cache.isEmpty()) { ef.useCache("true".equals(cache)) } String forUpdate = node.attribute("for-update") if (forUpdate != null && !forUpdate.isEmpty()) ef.forUpdate("true".equals(forUpdate)) String distinct = node.attribute("distinct") if (distinct != null && !distinct.isEmpty()) ef.distinct("true".equals(distinct)) String useClone = node.attribute("use-clone") if (useClone != null && !useClone.isEmpty()) ef.useClone("true".equals(useClone)) String offset = node.attribute("offset") if (offset != null && !offset.isEmpty()) ef.offset(Integer.valueOf(offset)) String limit = node.attribute("limit") if (limit != null && !limit.isEmpty()) ef.limit(Integer.valueOf(limit)) for (MNode sf in node.children("select-field")) { String fieldToSelect = sf.attribute("field-name") if (fieldToSelect == null || fieldToSelect.isEmpty()) continue if (fieldToSelect.contains('${')) fieldToSelect = ecfi.resourceFacade.expandNoL10n(fieldToSelect, null) ef.selectField(fieldToSelect) } for (MNode ob in node.children("order-by")) ef.orderBy(ob.attribute("field-name")) if (node.hasChild("search-form-inputs")) { MNode sfiNode = node.first("search-form-inputs") String requireParameters = ecfi.resourceFacade.expand(sfiNode.attribute("require-parameters"), null) if ("true".equals(requireParameters)) ef.requireSearchFormParameters(true) boolean paginate = !"false".equals(sfiNode.attribute("paginate")) MNode defaultParametersNode = sfiNode.first("default-parameters") String inputFieldsMapName = sfiNode.attribute("input-fields-map") Map inf = inputFieldsMapName ? (Map) ecfi.resourceFacade.expression(inputFieldsMapName, "") : ecfi.getEci().context ef.searchFormMap(inf, defaultParametersNode?.attributes?.collectEntries {[it.key, ecfi.resourceFacade.expandNoL10n(it.value, "")]} as Map, sfiNode.attribute("skip-fields"), sfiNode.attribute("default-order-by"), paginate) } // logger.warn("=== shouldCache ${this.entityName} ${shouldCache()}, limit=${this.limit}, offset=${this.offset}, useCache=${this.useCache}, getEntityDef().getUseCache()=${this.getEntityDef().getUseCache()}") EntityCondition mainCond = getConditionFactoryImpl().makeActionConditions(node, ef.shouldCache()) if (mainCond != null) ef.condition(mainCond) if (node.hasChild("having-econditions")) { for (MNode havingCond in node.children("having-econditions")) ef.havingCondition(getConditionFactoryImpl().makeActionConditions(havingCond, ef.shouldCache())) } return ef } /** Simple, fast find by primary key; doesn't filter find based on authz; doesn't use TransactionCache * For cached queries this is about 50% faster (6M/s vs 4M/s) for non-cached queries only about 10% faster (500K vs 450K) */ @Override EntityValue fastFindOne(String entityName, Boolean useCache, boolean disableAuthz, Object... values) { ExecutionContextImpl ec = ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false try { EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) throw new EntityException("Entity not found with name ${entityName}") EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo FieldInfo[] pkFieldInfoArray = entityInfo.pkFieldInfoArray if (ed.isViewEntity || !entityInfo.isEntityDatasourceFactoryImpl) { if (logger.infoEnabled) logger.info("fastFindOne used with entity ${entityName} which is view entity (${ed.isViewEntity}) or not from EntityDatasourceFactoryImpl (${entityInfo.isEntityDatasourceFactoryImpl})") EntityFind ef = find(entityName) if (useCache) ef.useCache(true) if (disableAuthz) ef.disableAuthz() for (int i = 0; i < pkFieldInfoArray.length; i++) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[i] Object fieldValue = values[i] ef.condition(fi.name, fieldValue) } return ef.one() } ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "one") // really worth the overhead? if so change to handle singleCondField: .setParameters(simpleAndMap) aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { boolean doCache = useCache != null ? (useCache.booleanValue() ? !entityInfo.neverCache : false) : "true".equals(entityInfo.useCache) boolean hasEmptyPk = false int pkSize = pkFieldInfoArray.length if (values.length != pkSize) throw new EntityException("Cannot do fastFindOne for entity ${entityName} with ${pkSize} primary key fields and ${values.length} values") EntityConditionImplBase whereCondition = (EntityConditionImplBase) null if (pkSize == 1) { Object fieldValue = values[0] if (ObjectUtilities.isEmpty(fieldValue)) { hasEmptyPk = true } else if (doCache) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[0] whereCondition = new FieldValueCondition(fi.conditionField, EntityCondition.EQUALS, fieldValue) } } else { ListCondition listCond = doCache ? new ListCondition(null, EntityCondition.AND) : (ListCondition) null for (int i = 0; i < pkSize; i++) { Object fieldValue = values[i] if (ObjectUtilities.isEmpty(fieldValue)) { hasEmptyPk = true break } if (doCache) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[i] listCond.addCondition(new FieldValueCondition(fi.conditionField, EntityCondition.EQUALS, fieldValue)) } } if (doCache) whereCondition = listCond } // if any PK fields are null, for whatever reason in calling code, the result is null so no need to send to DB or cache or anything if (hasEmptyPk) return (EntityValue) null Cache entityOneCache = doCache ? ed.getCacheOne(entityCache) : (Cache) null EntityValueBase cacheHit = doCache ? (EntityValueBase) entityOneCache.get(whereCondition) : (EntityValueBase) null EntityValueBase newEntityValue if (cacheHit != null) { if (cacheHit instanceof EntityCache.EmptyRecord) newEntityValue = (EntityValueBase) null else newEntityValue = cacheHit } else { newEntityValue = fastFindOneExtended(ed, values) // put it in whether null or not (already know cacheHit is null) if (doCache) entityCache.putInOneCache(ed, whereCondition, newEntityValue, entityOneCache) } return newEntityValue } finally { // pop the ArtifactExecutionInfo aefi.pop(aei) } } finally { if (enableAuthz) aefi.enableAuthz() } } public EntityValueBase fastFindOneExtended(EntityDefinition ed, Object... values) throws EntityException { // table doesn't exist, just return null if (!ed.tableExistsDbMetaOnly()) return null FieldInfo[] fieldInfoArray = ed.entityInfo.allFieldInfoArray FieldInfo[] pkFieldInfoArray = ed.entityInfo.pkFieldInfoArray int pkSize = pkFieldInfoArray.length final StringBuilder sqlTopLevel = new StringBuilder(500) sqlTopLevel.append("SELECT ").append(ed.entityInfo.allFieldsSqlSelect) // FROM Clause sqlTopLevel.append(" FROM ") sqlTopLevel.append(ed.getFullTableName()) // WHERE clause; whereCondition will always be FieldValueCondition or ListCondition with FieldValueCondition sqlTopLevel.append(" WHERE ") for (int i = 0; i < pkSize; i++) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[i] // Object fieldValue = values[i] if (i > 0) sqlTopLevel.append(" AND ") sqlTopLevel.append(fi.getFullColumnName()).append(" = ?") } String finalSql = sqlTopLevel.toString() // run the SQL now that it is built EntityValueBase newEntityValue = (EntityValueBase) null Connection connection = (Connection) null PreparedStatement ps = (PreparedStatement) null ResultSet rs = (ResultSet) null try { connection = getConnection(ed.getEntityGroupName()) ps = connection.prepareStatement(finalSql, ResultSet.TYPE_FORWARD_ONLY, ResultSet.CONCUR_READ_ONLY) for (int i = 0; i < pkSize; i++) { FieldInfo fi = (FieldInfo) pkFieldInfoArray[i] Object fieldValue = values[i] fi.setPreparedStatementValue(ps, i + 1, fieldValue, ed, this); } boolean queryStats = getQueryStats() long beforeQuery = queryStats ? System.nanoTime() : 0 rs = ps.executeQuery() if (queryStats) saveQueryStats(ed, finalSql, System.nanoTime() - beforeQuery, false) if (rs.next()) { newEntityValue = new EntityValueImpl(ed, this) LiteStringMap valueMap = newEntityValue.getValueMap() int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; fi.getResultSetValue(rs, i + 1, valueMap, this) } } } catch (SQLException e) { throw new EntityException("Error finding value", e); } finally { try { if (ps != null) ps.close() if (rs != null) rs.close() if (connection != null) connection.close(); } catch (SQLException sqle) { throw new EntityException("Error finding value", sqle); } } return newEntityValue; } @Override void createBulk(List valueList) { if (valueList == null || valueList.isEmpty()) return EntityValue firstEv = (EntityValue) valueList.get(0) String groupName = getEntityGroupName(firstEv.resolveEntityName()) EntityDatasourceFactory datasourceFactory = getDatasourceFactory(groupName) if (datasourceFactory == null) throw new EntityException("Datasource Factory not found for group " + groupName) datasourceFactory.createBulk(valueList) } final static Map operationByMethod = [get:'find', post:'create', put:'store', patch:'update', delete:'delete'] @Override Object rest(String operation, List entityPath, Map parameters, boolean masterNameInPath) { if (operation == null || operation.length() == 0) throw new EntityException("Operation (method) must be specified") operation = operationByMethod.get(operation.toLowerCase()) ?: operation if (!(operation in ['find', 'create', 'store', 'update', 'delete'])) throw new EntityException("Operation [${operation}] not supported, must be one of: get, post, put, patch, or delete for HTTP request methods or find, create, store, update, or delete for direct entity operations") if (entityPath == null || entityPath.size() == 0) throw new EntityException("No entity name or alias specified in path") boolean dependents = (parameters.dependents == 'true' || parameters.dependents == 'Y') int dependentLevels = (parameters.dependentLevels ?: (dependents ? '2' : '0')) as int String masterName = parameters.master List localPath = new ArrayList(entityPath) String firstEntityName = localPath.remove(0) EntityDefinition firstEd = getEntityDefinition(firstEntityName) // this exception will be thrown at lower levels, but just in case check it again here if (firstEd == null) throw new EntityNotFoundException("No entity found with name or alias [${firstEntityName}]") // look for a master definition name as the next path element if (masterNameInPath) { if (masterName == null || masterName.length() == 0) { if (localPath.size() > 0 && firstEd.getMasterDefinition(localPath.get(0)) != null) { masterName = localPath.remove(0) } else { masterName = "default" } } if (firstEd.getMasterDefinition(masterName) == null) throw new EntityException("Master definition not found for entity [${firstEd.getFullEntityName()}], tried master name [${masterName}]") } // if there are more path elements use one for each PK field of the entity if (localPath.size() > 0) { for (String pkFieldName in firstEd.getPkFieldNames()) { String pkValue = localPath.remove(0) if (!ObjectUtilities.isEmpty(pkValue)) parameters.put(pkFieldName, pkValue) if (localPath.size() == 0) break } } EntityDefinition lastEd = firstEd // if there is still more in the path the next should be a relationship name or alias while (localPath) { String relationshipName = localPath.remove(0) RelationshipInfo relInfo = lastEd.getRelationshipInfoMap().get(relationshipName) if (relInfo == null) throw new EntityNotFoundException("No relationship found with name or alias [${relationshipName}] on entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}]") String relEntityName = relInfo.relatedEntityName EntityDefinition relEd = relInfo.relatedEd if (relEd == null) throw new EntityNotFoundException("No entity found with name [${relEntityName}], related to entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] by relationship [${relationshipName}]") // TODO: How to handle more exotic relationships where they are not a dependent record, ie join on a field // TODO: other than a PK field? Should we lookup interim records to get field values to lookup the final // TODO: one? This would assume that all records exist along the path... need any variation for different // TODO: operations? // if there are more path elements use one for each PK field of the entity if (localPath.size() > 0) { for (String pkFieldName in relEd.getPkFieldNames()) { // do we already have a value for this PK field? if so skip it... if (parameters.containsKey(pkFieldName)) continue String pkValue = localPath.remove(0) if (!ObjectUtilities.isEmpty(pkValue)) parameters.put(pkFieldName, pkValue) if (localPath.size() == 0) break } } lastEd = relEd } // at this point we should have the entity we actually want to operate on, and all PK field values from the path if (operation == 'find') { if (lastEd.containsPrimaryKey(parameters)) { // if we have a full PK lookup by PK and return the single value Map pkValues = [:] lastEd.entityInfo.setFields(parameters, pkValues, false, null, true) if (masterName != null && masterName.length() > 0) { Map resultMap = find(lastEd.getFullEntityName()).condition(pkValues).oneMaster(masterName) if (resultMap == null) throw new EntityValueNotFoundException("No value found for entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] with key ${pkValues}") return resultMap } else { EntityValueBase evb = (EntityValueBase) find(lastEd.getFullEntityName()).condition(pkValues).one() if (evb == null) throw new EntityValueNotFoundException("No value found for entity [${lastEd.getShortAlias()?:''}:${lastEd.getFullEntityName()}] with key ${pkValues}") Map resultMap = evb.getPlainValueMap(dependentLevels) return resultMap } } else { // otherwise do a list find EntityFind ef = find(lastEd.fullEntityName).searchFormMap(parameters, null, null, null, false) // we don't want to go overboard with these requests, never do an unlimited find, if no limit use 100 if (!ef.getLimit()) ef.limit(100) // support pagination, at least "X-Total-Count" header if find is paginated long count = ef.count() long pageIndex = ef.getPageIndex() long pageSize = ef.getPageSize() long pageMaxIndex = ((count - 1) as BigDecimal).divide(pageSize as BigDecimal, 0, RoundingMode.DOWN).longValue() long pageRangeLow = pageIndex * pageSize + 1 long pageRangeHigh = (pageIndex * pageSize) + pageSize if (pageRangeHigh > count) pageRangeHigh = count parameters.put('xTotalCount', count) parameters.put('xPageIndex', pageIndex) parameters.put('xPageSize', pageSize) parameters.put('xPageMaxIndex', pageMaxIndex) parameters.put('xPageRangeLow', pageRangeLow) parameters.put('xPageRangeHigh', pageRangeHigh) if (masterName != null && masterName.length() > 0) { List resultList = ef.listMaster(masterName) return resultList } else { EntityList el = ef.list() List resultList = el.getPlainValueList(dependentLevels) return resultList } } } else { // use the entity auto service runner for other operations (create, store, update, delete) Map result = ecfi.serviceFacade.sync().name(operation, lastEd.fullEntityName).parameters(parameters).call() return result } } EntityList getValueListFromPlainMap(Map value, String entityName) { if (entityName == null || entityName.length() == 0) entityName = value."_entity" if (entityName == null || entityName.length() == 0) throw new EntityException("No entityName passed and no _entity field in value Map") EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) throw new EntityNotFoundException("Not entity found with name ${entityName}") EntityList valueList = new EntityListImpl(this) addValuesFromPlainMapRecursive(ed, value, valueList, null) return valueList } void addValuesFromPlainMapRecursive(EntityDefinition ed, Map value, EntityList valueList, Map parentPks) { // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to // allow them to be left out of related records if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!value.containsKey(entry.key)) value.put(entry.key, entry.value) } EntityValue newEntityValue = makeValue(ed.getFullEntityName()) newEntityValue.setFields(value, true, null, null) valueList.add(newEntityValue) Map sharedPkMap = newEntityValue.getPrimaryKeys() if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value) } // check parameters Map for relationships and other entities Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(value, null) for (Map.Entry entry in nonFieldEntries.entrySet()) { Object relParmObj = entry.getValue() if (relParmObj == null) continue // if the entry is not a Map or List ignore it, we're only looking for those if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue String entryName = (String) entry.getKey() if (parentPks != null && parentPks.containsKey(entryName)) continue if (EntityAutoServiceRunner.otherFieldsToSkip.contains(entryName)) continue EntityDefinition subEd = null Map pkMap = null RelationshipInfo relInfo = ed.getRelationshipInfo(entryName) if (relInfo != null) { if (!relInfo.mutable) continue subEd = relInfo.relatedEd // this is a relationship so add mapped key fields to the parentPks if any field names are different pkMap = new HashMap<>(sharedPkMap) pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap)) } else if (isEntityDefined(entryName)) { subEd = getEntityDefinition(entryName) pkMap = sharedPkMap } if (subEd == null) continue boolean isEntityValue = relParmObj instanceof EntityValue if (relParmObj instanceof Map && !isEntityValue) { addValuesFromPlainMapRecursive(subEd, (Map) relParmObj, valueList, pkMap) } else if (relParmObj instanceof List) { for (Object relParmEntry in relParmObj) { if (relParmEntry instanceof Map) { addValuesFromPlainMapRecursive(subEd, (Map) relParmEntry, valueList, pkMap) } else { logger.warn("In entity values from plain map for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}") } } } else { if (isEntityValue) { if (logger.isTraceEnabled()) logger.trace("In entity values from plain map for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } else { logger.warn("In entity values from plain map for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } } } } @Override EntityListIterator sqlFind(String sql, List sqlParameterList, String entityName, List fieldList) { EntityDefinition ed = this.getEntityDefinition(entityName) this.entityDbMeta.checkTableRuntime(ed) Connection con = getConnection(getEntityGroupName(entityName)) PreparedStatement ps try { FieldInfo[] fiArray if (fieldList != null) { fiArray = new FieldInfo[fieldList.size()] int fiArrayIndex = 0 for (String fieldName in fieldList) { FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) throw new BaseArtifactException("Field ${fieldName} not found for entity ${entityName}") fiArray[fiArrayIndex] = fi fiArrayIndex++ } } else { fiArray = ed.entityInfo.allFieldInfoArray } // create the PreparedStatement ps = con.prepareStatement(sql, ResultSet.TYPE_SCROLL_INSENSITIVE, ResultSet.CONCUR_READ_ONLY) // set the parameter values if (sqlParameterList != null) { int paramIndex = 1 for (Object parameterValue in sqlParameterList) { FieldInfo fi = (FieldInfo) fiArray[paramIndex - 1] fi.setPreparedStatementValue(ps, paramIndex, parameterValue, ed, this) paramIndex++ } } // do the actual query long timeBefore = System.currentTimeMillis() ResultSet rs = ps.executeQuery() if (logger.traceEnabled) logger.trace("Executed query with SQL [${sql}] and parameters [${sqlParameterList}] in [${(System.currentTimeMillis()-timeBefore)/1000}] seconds") // make and return the eli EntityListIterator eli = new EntityListIteratorImpl(con, rs, ed, fiArray, this, null, null, null) return eli } catch (SQLException e) { throw new EntityException("SQL Exception with statement:" + sql + "; " + e.toString(), e) } } @Override ArrayList getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) { return entityDataDocument.getDataDocuments(dataDocumentId, condition, fromUpdateStamp, thruUpdatedStamp) } @Override ArrayList getDataFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp) { return entityDataFeed.getFeedDocuments(dataFeedId, fromUpdateStamp, thruUpdatedStamp) } void tempSetSequencedIdPrimary(String seqName, long nextSeqNum, long bankSize) { long[] bank = new long[2] bank[0] = nextSeqNum bank[1] = nextSeqNum + bankSize entitySequenceBankCache.put(seqName, bank) } void tempResetSequencedIdPrimary(String seqName) { entitySequenceBankCache.put(seqName, null) } @Override String sequencedIdPrimary(String seqName, Long staggerMax, Long bankSize) { try { // is the seqName an entityName? if (isEntityDefined(seqName)) { EntityDefinition ed = getEntityDefinition(seqName) if (ed.entityInfo.sequencePrimaryUseUuid) return UUID.randomUUID().toString() } } catch (EntityException e) { // do nothing, just means seqName is not an entity name if (isTraceEnabled) logger.trace("Ignoring exception for entity not found: ${e.toString()}") } // fall through to default to the db sequenced ID long staggerMaxPrim = staggerMax != null ? staggerMax.longValue() : 0L long bankSizePrim = (bankSize != null && bankSize.longValue() > 0) ? bankSize.longValue() : defaultBankSize return dbSequencedIdPrimary(seqName, staggerMaxPrim, bankSizePrim) } String sequencedIdPrimaryEd(EntityDefinition ed) { EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo try { // is the seqName an entityName? if (entityInfo.sequencePrimaryUseUuid) return UUID.randomUUID().toString() } catch (EntityException e) { // do nothing, just means seqName is not an entity name if (isTraceEnabled) logger.trace("Ignoring exception for entity not found: ${e.toString()}") } // fall through to default to the db sequenced ID return dbSequencedIdPrimary(ed.getFullEntityName(), entityInfo.sequencePrimaryStagger, entityInfo.sequenceBankSize) } protected final static long defaultBankSize = 50L protected Lock getDbSequenceLock(String seqName) { Lock oldLock, dbSequenceLock = dbSequenceLocks.get(seqName) if (dbSequenceLock == null) { dbSequenceLock = new ReentrantLock() oldLock = dbSequenceLocks.putIfAbsent(seqName, dbSequenceLock) if (oldLock != null) return oldLock } return dbSequenceLock } protected String dbSequencedIdPrimary(String seqName, long staggerMax, long bankSize) { // TODO: find some way to get this running non-synchronized for performance reasons (right now if not // TODO: synchronized the forUpdate won't help if the record doesn't exist yet, causing errors in high // TODO: traffic creates; is it creates only?) Lock dbSequenceLock = getDbSequenceLock(seqName) dbSequenceLock.lock() // NOTE: simple approach with forUpdate, not using the update/select "ethernet" approach used in OFBiz; consider // that in the future if there are issues with this approach try { // first get a bank if we don't have one already long[] bank = (long[]) entitySequenceBankCache.get(seqName) if (bank == null || bank[0] > bank[1]) { if (bank == null) { bank = new long[2] bank[0] = 0 bank[1] = -1 entitySequenceBankCache.put(seqName, bank) } ecfi.transactionFacade.runRequireNew(null, "Error getting primary sequenced ID", true, true, { ArtifactExecutionFacadeImpl aefi = ecfi.getEci().artifactExecutionFacade boolean enableAuthz = !aefi.disableAuthz() try { EntityValue svi = find("moqui.entity.SequenceValueItem").condition("seqName", seqName) .useCache(false).forUpdate(true).one() if (svi == null) { svi = makeValue("moqui.entity.SequenceValueItem") svi.set("seqName", seqName) // a new tradition: start sequenced values at one hundred thousand instead of ten thousand bank[0] = 100000L bank[1] = bank[0] + bankSize svi.set("seqNum", bank[1]) svi.create() } else { Long lastSeqNum = svi.getLong("seqNum") bank[0] = (lastSeqNum > bank[0] ? lastSeqNum + 1L : bank[0]) bank[1] = bank[0] + bankSize svi.set("seqNum", bank[1]) svi.update() } } finally { if (enableAuthz) aefi.enableAuthz() } }) } long seqNum = bank[0] if (staggerMax > 1L) { long stagger = Math.round(Math.random() * staggerMax) bank[0] = seqNum + stagger // NOTE: if bank[0] > bank[1] because of this just leave it and the next time we try to get a sequence // value we'll get one from a new bank } else { bank[0] = seqNum + 1L } return sequencedIdPrefix != null ? sequencedIdPrefix + seqNum : seqNum } finally { dbSequenceLock.unlock() } } Set getAllEntityNamesInGroup(String groupName) { Set groupEntityNames = new TreeSet() for (String entityName in getAllEntityNames()) { // use the entity/group cache handled by getEntityGroupName() if (getEntityGroupName(entityName) == groupName) groupEntityNames.add(entityName) } return groupEntityNames } @Override String getEntityGroupName(String entityName) { String entityGroupName = (String) entityGroupNameMap.get(entityName) if (entityGroupName != null) return entityGroupName EntityDefinition ed // for entity group name just ignore EntityException on getEntityDefinition try { ed = getEntityDefinition(entityName) } catch (EntityException e) { return null } // may happen if all entity names includes a DB view entity or other that doesn't really exist if (ed == null) return null // always intern the group name so it can be used with an identity compare entityGroupName = ed.getEntityGroupName()?.intern() entityGroupNameMap.put(entityName, entityGroupName) return entityGroupName } @Override Connection getConnection(String groupName) { return getConnection(groupName, false) } @Override Connection getConnection(String groupName, boolean useClone) { TransactionFacadeImpl tfi = ecfi.transactionFacade if (!tfi.isTransactionOperable()) throw new EntityException("Cannot get connection, transaction not in operable status (${tfi.getStatusString()})") String groupToUse = useClone ? getDatasourceCloneName(groupName) : groupName Connection stashed = tfi.getTxConnection(groupToUse) if (stashed != null) return stashed EntityDatasourceFactory edf = getDatasourceFactory(groupToUse) DataSource ds = edf.getDataSource() if (ds == null) throw new EntityException("Cannot get JDBC Connection for group-name [${groupToUse}] because it has no DataSource") Connection newCon if (ds instanceof XADataSource) { newCon = tfi.enlistConnection(((XADataSource) ds).getXAConnection()) } else { newCon = ds.getConnection() } if (newCon != null) newCon = tfi.stashTxConnection(groupToUse, newCon) return newCon } @Override EntityDataLoader makeDataLoader() { return new EntityDataLoaderImpl(this) } @Override EntityDataWriter makeDataWriter() { return new EntityDataWriterImpl(this) } @Override SimpleEtl.Loader makeEtlLoader() { return new EtlLoader(this) } static class EtlLoader implements SimpleEtl.Loader { private boolean beganTransaction = false private EntityFacadeImpl efi private boolean useTryInsert = false, dummyFks = false EtlLoader(EntityFacadeImpl efi) { this.efi = efi } EtlLoader useTryInsert() { useTryInsert = true; return this } EtlLoader dummyFks() { dummyFks = true; return this } @Override void init(Integer timeout) { if (!efi.ecfi.transactionFacade.isTransactionActive()) beganTransaction = efi.ecfi.transactionFacade.begin(timeout) } @Override void load(SimpleEtl.Entry entry) throws Exception { String entityName = entry.getEtlType() if (!efi.isEntityDefined(entityName)) { logger.info("Tried to load ETL entry with invalid entity name " + entityName) return } EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed == null) throw new BaseArtifactException("Could not find entity ${entityName}") // NOTE: the following uses the same pattern as EntityDataLoaderImpl.LoadValueHandler if (dummyFks || useTryInsert) { EntityValue curValue = ed.makeEntityValue() curValue.setAll(entry.getEtlValues()) if (useTryInsert) { try { curValue.create() } catch (EntityException ce) { if (logger.isTraceEnabled()) logger.trace("Insert failed, trying update (${ce.toString()})") boolean noFksMissing = true if (dummyFks) noFksMissing = curValue.checkFks(true) // retry, then if this fails we have a real error so let the exception fall through // if there were no FKs missing then just do an update, if there were that may have been the error so createOrUpdate if (noFksMissing) { try { curValue.update() } catch (EntityException ue) { logger.error("Error in update after attempt to create (tryInsert), here is the create error: ", ce) throw ue } } else { curValue.createOrUpdate() } } } else { if (dummyFks) curValue.checkFks(true) curValue.createOrUpdate() } } else { Map results = new HashMap() EntityAutoServiceRunner.storeEntity(efi.ecfi.getEci(), ed, entry.getEtlValues(), results, null) if (results.size() > 0) entry.getEtlValues().putAll(results) } } @Override void complete(SimpleEtl etl) { if (etl.hasError()) { efi.ecfi.transactionFacade.rollback(beganTransaction, "Error in ETL load", etl.getSingleErrorCause()) } else if (beganTransaction) { efi.ecfi.transactionFacade.commit() } } } @Override EntityValue makeValue(Element element) { if (!element) return null String entityName = element.getTagName() if (entityName.indexOf('-') > 0) entityName = entityName.substring(entityName.indexOf('-') + 1) if (entityName.indexOf(':') > 0) entityName = entityName.substring(entityName.indexOf(':') + 1) EntityValueImpl newValue = (EntityValueImpl) makeValue(entityName) EntityDefinition ed = newValue.getEntityDefinition() for (String fieldName in ed.getAllFieldNames()) { String attrValue = element.getAttribute(fieldName) if (attrValue) { newValue.setString(fieldName, attrValue) } else { org.w3c.dom.NodeList seList = element.getElementsByTagName(fieldName) Element subElement = seList.getLength() > 0 ? (Element) seList.item(0) : null if (subElement) newValue.setString(fieldName, StringUtilities.elementValue(subElement)) } } return newValue } /* =============== */ /* Utility Methods */ /* =============== */ protected Map> javaTypeByGroup = [:] String getFieldJavaType(String fieldType, EntityDefinition ed) { String groupName = ed.getEntityGroupName() Map javaTypeMap = javaTypeByGroup.get(groupName) if (javaTypeMap != null) { String ft = javaTypeMap.get(fieldType) if (ft != null) return ft } return getFieldJavaTypeFromDbNode(groupName, fieldType, ed) } protected getFieldJavaTypeFromDbNode(String groupName, String fieldType, EntityDefinition ed) { Map javaTypeMap = javaTypeByGroup.get(groupName) if (javaTypeMap == null) { javaTypeMap = new HashMap() javaTypeByGroup.put(groupName, javaTypeMap) } MNode databaseNode = this.getDatabaseNode(groupName) MNode databaseTypeNode = databaseNode ? databaseNode.first({ MNode it -> it.name == "database-type" && it.attribute('type') == fieldType }) : null String javaType = databaseTypeNode?.attribute("java-type") if (!javaType) { MNode databaseListNode = ecfi.confXmlRoot.first("database-list") MNode dictionaryTypeNode = databaseListNode.first({ MNode it -> it.name == "dictionary-type" && it.attribute('type') == fieldType }) javaType = dictionaryTypeNode?.attribute("java-type") if (!javaType) throw new EntityException("Could not find Java type for field type [${fieldType}] on entity [${ed.getFullEntityName()}]") } javaTypeMap.put(fieldType, javaType) return javaType } protected Map> sqlTypeByGroup = [:] protected String getFieldSqlType(String fieldType, EntityDefinition ed) { String groupName = ed.getEntityGroupName() Map sqlTypeMap = (Map) sqlTypeByGroup.get(groupName) if (sqlTypeMap != null) { String st = (String) sqlTypeMap.get(fieldType) if (st != null) return st } return getFieldSqlTypeFromDbNode(groupName, fieldType, ed) } protected getFieldSqlTypeFromDbNode(String groupName, String fieldType, EntityDefinition ed) { Map sqlTypeMap = sqlTypeByGroup.get(groupName) if (sqlTypeMap == null) { sqlTypeMap = new HashMap() sqlTypeByGroup.put(groupName, sqlTypeMap) } MNode databaseNode = this.getDatabaseNode(groupName) MNode databaseTypeNode = databaseNode ? databaseNode.first({ MNode it -> it.name == "database-type" && it.attribute('type') == fieldType }) : null String sqlType = databaseTypeNode?.attribute("sql-type") if (!sqlType) { MNode databaseListNode = ecfi.confXmlRoot.first("database-list") MNode dictionaryTypeNode = databaseListNode .first({ MNode it -> it.name == "dictionary-type" && it.attribute('type') == fieldType }) sqlType = dictionaryTypeNode?.attribute("default-sql-type") if (!sqlType) throw new EntityException("Could not find SQL type for field type [${fieldType}] on entity [${ed.getFullEntityName()}]") } sqlTypeMap.put(fieldType, sqlType) return sqlType } /** For pretty-print of field values based on field type */ String formatFieldString(String entityName, String fieldName, String value) { if (value == null || value.isEmpty()) return "" EntityDefinition ed = getEntityDefinition(entityName) if (ed == null) return value FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) return value String outVal = value if (fi.typeValue == 2) { if (value.matches("\\d*")) { // date-time with only digits, ms since epoch value outVal = ecfi.l10n.format(new Timestamp(Long.parseLong(value)), null) } } else if (fi.type.startsWith("currency-")) { outVal = ecfi.l10n.format(new BigDecimal(value), "#,##0.00#") } // logger.warn("formatFieldString ${entityName}:${fieldName} value ${value} outVal ${outVal}") return outVal } // Entity Field Java Types public static final int ENTITY_STRING = 1 public static final int ENTITY_TIMESTAMP = 2 public static final int ENTITY_TIME = 3 public static final int ENTITY_DATE = 4 public static final int ENTITY_INTEGER = 5 public static final int ENTITY_LONG = 6 public static final int ENTITY_FLOAT = 7 public static final int ENTITY_DOUBLE = 8 public static final int ENTITY_BIG_DECIMAL = 9 public static final int ENTITY_BOOLEAN = 10 public static final int ENTITY_OBJECT = 11 public static final int ENTITY_BLOB = 12 public static final int ENTITY_CLOB = 13 public static final int ENTITY_UTIL_DATE = 14 public static final int ENTITY_COLLECTION = 15 protected static final Map fieldTypeIntMap = [ "id":ENTITY_STRING, "id-long":ENTITY_STRING, "text-indicator":ENTITY_STRING, "text-short":ENTITY_STRING, "text-medium":ENTITY_STRING, "text-intermediate":ENTITY_STRING, "text-long":ENTITY_STRING, "text-very-long":ENTITY_STRING, "date-time":ENTITY_TIMESTAMP, "time":ENTITY_TIME, "date":ENTITY_DATE, "number-integer":ENTITY_LONG, "number-float":ENTITY_DOUBLE, "number-decimal":ENTITY_BIG_DECIMAL, "currency-amount":ENTITY_BIG_DECIMAL, "currency-precise":ENTITY_BIG_DECIMAL, "binary-very-long":ENTITY_BLOB ] protected static final Map fieldTypeJavaMap = [ "id":"java.lang.String", "id-long":"java.lang.String", "text-indicator":"java.lang.String", "text-short":"java.lang.String", "text-medium":"java.lang.String", "text-intermediate":"java.lang.String", "text-long":"java.lang.String", "text-very-long":"java.lang.String", "date-time":"java.sql.Timestamp", "time":"java.sql.Time", "date":"java.sql.Date", "number-integer":"java.lang.Long", "number-float":"java.lang.Double", "number-decimal":"java.math.BigDecimal", "currency-amount":"java.math.BigDecimal", "currency-precise":"java.math.BigDecimal", "binary-very-long":"java.sql.Blob" ] protected static final Map javaIntTypeMap = [ "java.lang.String":ENTITY_STRING, "String":ENTITY_STRING, "org.codehaus.groovy.runtime.GStringImpl":ENTITY_STRING, "char[]":ENTITY_STRING, "java.sql.Timestamp":ENTITY_TIMESTAMP, "Timestamp":ENTITY_TIMESTAMP, "java.sql.Time":ENTITY_TIME, "Time":ENTITY_TIME, "java.sql.Date":ENTITY_DATE, "Date":ENTITY_DATE, "java.lang.Integer":ENTITY_INTEGER, "Integer":ENTITY_INTEGER, "java.lang.Long":ENTITY_LONG,"Long":ENTITY_LONG, "java.lang.Float":ENTITY_FLOAT, "Float":ENTITY_FLOAT, "java.lang.Double":ENTITY_DOUBLE, "Double":ENTITY_DOUBLE, "java.math.BigDecimal":ENTITY_BIG_DECIMAL, "BigDecimal":ENTITY_BIG_DECIMAL, "java.lang.Boolean":ENTITY_BOOLEAN, "Boolean":ENTITY_BOOLEAN, "java.lang.Object":ENTITY_OBJECT, "Object":ENTITY_OBJECT, "java.sql.Blob":ENTITY_BLOB, "Blob":ENTITY_BLOB, "byte[]":ENTITY_BLOB, "java.nio.ByteBuffer":ENTITY_BLOB, "java.nio.HeapByteBuffer":ENTITY_BLOB, "java.sql.Clob":ENTITY_CLOB, "Clob":ENTITY_CLOB, "java.util.Date":ENTITY_UTIL_DATE, "java.util.ArrayList":ENTITY_COLLECTION, "java.util.HashSet":ENTITY_COLLECTION, "java.util.LinkedHashSet":ENTITY_COLLECTION, "java.util.LinkedList":ENTITY_COLLECTION] static int getJavaTypeInt(String javaType) { Integer typeInt = (Integer) javaIntTypeMap.get(javaType) if (typeInt == null) throw new EntityException("Java type " + javaType + " not supported for entity fields") return typeInt } final Map queryStatsInfoMap = new HashMap<>() void saveQueryStats(EntityDefinition ed, String sql, long queryTime, boolean isError) { EntityJavaUtil.QueryStatsInfo qsi = queryStatsInfoMap.get(sql) if (qsi == null) { qsi = new EntityJavaUtil.QueryStatsInfo(ed.getFullEntityName(), sql) queryStatsInfoMap.put(sql, qsi) } qsi.countHit(this, queryTime, isError) } ArrayList> getQueryStatsList(String orderByField, String entityFilter, String sqlFilter) { ArrayList> qsl = new ArrayList<>(queryStatsInfoMap.size()) boolean hasEntityFilter = entityFilter != null && entityFilter.length() > 0 boolean hasSqlFilter = sqlFilter != null && sqlFilter.length() > 0 for (EntityJavaUtil.QueryStatsInfo qsi in queryStatsInfoMap.values()) { if (hasEntityFilter && !qsi.entityName.matches("(?i).*" + entityFilter + ".*")) continue if (hasSqlFilter && !qsi.sql.matches("(?i).*" + sqlFilter + ".*")) continue qsl.add(qsi.makeDisplayMap()) } if (orderByField) CollectionUtilities.orderMapList(qsl, [orderByField]) return qsl } void clearQueryStats() { queryStatsInfoMap.clear() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityFindBase.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.ArtifactAuthorizationException import org.moqui.context.ArtifactExecutionInfo import org.moqui.entity.* import org.moqui.etl.SimpleEtl import org.moqui.etl.SimpleEtl.StopException import org.moqui.impl.context.ArtifactExecutionFacadeImpl import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ContextJavaUtil import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.TransactionCache import org.moqui.impl.context.TransactionFacadeImpl import org.moqui.impl.entity.condition.* import org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions import org.moqui.util.CollectionUtilities import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache import java.sql.ResultSet import java.sql.SQLException import java.sql.Timestamp @CompileStatic abstract class EntityFindBase implements EntityFind { protected final static Logger logger = LoggerFactory.getLogger(EntityFindBase.class) // these error strings are here for convenience for LocalizedMessage records // NOTE: don't change these unless there is a really good reason, will break localization private static final String ONE_ERROR = 'Error finding one ${entityName} by ${condition}' private static final String LIST_ERROR = 'Error finding list of ${entityName} by ${condition}' private static final String COUNT_ERROR = 'Error finding count of ${entityName} by ${condition}' final static int defaultResultSetType = ResultSet.TYPE_FORWARD_ONLY public final EntityFacadeImpl efi public final TransactionCache txCache protected String entityName protected EntityDefinition entityDef = (EntityDefinition) null protected EntityDynamicViewImpl dynamicView = (EntityDynamicViewImpl) null protected String singleCondField = (String) null protected Object singleCondValue = null protected Map simpleAndMap = (Map) null protected Boolean tempHasFullPk = (Boolean) null protected EntityConditionImplBase whereEntityCondition = (EntityConditionImplBase) null protected EntityConditionImplBase havingEntityCondition = (EntityConditionImplBase) null protected ArrayList fieldsToSelect = (ArrayList) null protected ArrayList orderByFields = (ArrayList) null protected Boolean useCache = (Boolean) null protected boolean distinct = false protected Integer offset = (Integer) null protected Integer limit = (Integer) null protected boolean forUpdate = false protected boolean useClone = false protected int resultSetType = defaultResultSetType protected int resultSetConcurrency = ResultSet.CONCUR_READ_ONLY protected Integer fetchSize = (Integer) null protected Integer maxRows = (Integer) null protected boolean disableAuthz = false protected boolean requireSearchFormParameters = false protected boolean hasSearchFormParameters = false protected ArrayList queryTextList = new ArrayList<>() EntityFindBase(EntityFacadeImpl efi, String entityName) { this.efi = efi this.entityName = entityName TransactionFacadeImpl tfi = efi.ecfi.transactionFacade txCache = tfi.getTransactionCache() // if (!tfi.isTransactionInPlace()) logger.warn("No transaction in place, creating find for entity ${entityName}") } EntityFindBase(EntityFacadeImpl efi, EntityDefinition ed) { this.efi = efi entityName = ed.fullEntityName entityDef = ed TransactionFacadeImpl tfi = efi.ecfi.transactionFacade txCache = tfi.getTransactionCache() } @Override EntityFind entity(String name) { entityName = name; return this } @Override String getEntity() { return entityName } // ======================== Conditions (Where and Having) ================= @Override EntityFind condition(String fieldName, Object value) { boolean noSam = (simpleAndMap == null) boolean noScf = (singleCondField == null) if (noSam && noScf) { singleCondField = fieldName singleCondValue = value } else { if (noSam) simpleAndMap = new LinkedHashMap() if (!noScf) { simpleAndMap.put(singleCondField, singleCondValue) singleCondField = (String) null singleCondValue = null } simpleAndMap.put(fieldName, value) } return this } @Override EntityFind condition(String fieldName, EntityCondition.ComparisonOperator operator, Object value) { EntityDefinition ed = getEntityDef() FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) throw new EntityException("Field ${fieldName} not found on entity ${entityName}, cannot add condition") if (operator == null) operator = EntityCondition.EQUALS if (ed.isViewEntity && fi.fieldNode.attribute("function")) { return havingCondition(new FieldValueCondition(fi.conditionField, operator, value)) } else { if (EntityCondition.EQUALS.is(operator)) return condition(fieldName, value) return condition(new FieldValueCondition(fi.conditionField, operator, value)) } } @Override EntityFind condition(String fieldName, String operator, Object value) { EntityCondition.ComparisonOperator opObj = operator == null || operator.isEmpty() ? EntityCondition.EQUALS : EntityConditionFactoryImpl.stringComparisonOperatorMap.get(operator) if (opObj == null) throw new EntityException("Operator [${operator}] is not a valid field comparison operator") return condition(fieldName, opObj, value) } @Override EntityFind conditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName) { return condition(efi.entityConditionFactory.makeConditionToField(fieldName, operator, toFieldName)) } @Override EntityFind condition(Map fields) { if (fields == null) return this if (fields instanceof EntityValueBase) fields = ((EntityValueBase) fields).getValueMap() int fieldsSize = fields.size() if (fieldsSize == 0) return this boolean noSam = simpleAndMap == null boolean noScf = singleCondField == null if (fieldsSize == 1 && noSam && noScf) { // just set the singleCondField Map.Entry onlyEntry = fields.entrySet().iterator().next() singleCondField = (String) onlyEntry.key singleCondValue = onlyEntry.value } else { if (noSam) simpleAndMap = new LinkedHashMap() if (!noScf) { simpleAndMap.put(singleCondField, singleCondValue) singleCondField = (String) null singleCondValue = null } getEntityDef().entityInfo.setFields(fields, simpleAndMap, true, null, null) } return this } @Override EntityFind condition(EntityCondition condition) { if (condition == null) return this Class condClass = condition.getClass() if (condClass == FieldValueCondition.class) { // if this is a basic field/value EQUALS condition, just add to simpleAndMap FieldValueCondition fvc = (FieldValueCondition) condition if (EntityCondition.EQUALS.is(fvc.getOperator()) && !fvc.getIgnoreCase()) { this.condition(fvc.getFieldName(), fvc.getValue()) return this } } else if (condClass == ListCondition.class) { ListCondition lc = (ListCondition) condition ArrayList condList = lc.getConditionList() // if empty list add nothing if (condList.size() == 0) return this // if this is an AND list condition, just unroll it and add each one; could end up as another list, but may add to simpleAndMap if (EntityCondition.AND.is(lc.getOperator())) { for (int i = 0; i < condList.size(); i++) this.condition(condList.get(i)) return this } } else if (condClass == BasicJoinCondition.class) { BasicJoinCondition basicCond = (BasicJoinCondition) condition if (EntityCondition.AND.is(basicCond.getOperator())) { if (basicCond.getLhs() != null) this.condition(basicCond.getLhs()) if (basicCond.getRhs() != null) this.condition(basicCond.getRhs()) return this } } if (whereEntityCondition != null) { // use ListCondition instead of ANDing two at a time to avoid a bunch of nested ANDs if (whereEntityCondition instanceof ListCondition && ((ListCondition) whereEntityCondition).getOperator() == EntityCondition.AND) { ((ListCondition) whereEntityCondition).addCondition((EntityConditionImplBase) condition) } else { ArrayList condList = new ArrayList() condList.add(whereEntityCondition) condList.add((EntityConditionImplBase) condition) whereEntityCondition = new ListCondition(condList, EntityCondition.AND) } } else { whereEntityCondition = (EntityConditionImplBase) condition } return this } @Override EntityFind conditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp) { condition(efi.entityConditionFactory.makeConditionDate(fromFieldName, thruFieldName, compareStamp)) return this } @Override boolean getHasCondition() { if (singleCondField != null) return true if (simpleAndMap != null && simpleAndMap.size() > 0) return true if (whereEntityCondition != null) return true return false } @Override boolean getHasHavingCondition() { havingEntityCondition != null } @Override EntityFind havingCondition(EntityCondition condition) { if (condition == null) return this if (havingEntityCondition != null) { // use ListCondition instead of ANDing two at a time to avoid a bunch of nested ANDs if (havingEntityCondition instanceof ListCondition) { ((ListCondition) havingEntityCondition).addCondition((EntityConditionImplBase) condition) } else { ArrayList condList = new ArrayList() condList.add(havingEntityCondition) condList.add((EntityConditionImplBase) condition) havingEntityCondition = new ListCondition(condList, EntityCondition.AND) } } else { havingEntityCondition = (EntityConditionImplBase) condition } return this } @Override EntityCondition getWhereEntityCondition() { return getWhereEntityConditionInternal(getEntityDef()) } EntityConditionImplBase getWhereEntityConditionInternal(EntityDefinition localEd) { boolean wecNull = (whereEntityCondition == null) int samSize = simpleAndMap != null ? simpleAndMap.size() : 0 EntityConditionImplBase singleCond = (EntityConditionImplBase) null if (singleCondField != null) { if (samSize > 0) logger.warn("simpleAndMap size ${samSize} and singleCondField not null!") ConditionField cf if (localEd != null) { FieldInfo fi = localEd.getFieldInfo(singleCondField) if (fi == null) throw new EntityException("Error in find, field ${singleCondField} does not exist in entity ${localEd.getFullEntityName()}") cf = fi.conditionField } else { cf = new ConditionField(singleCondField) } singleCond = new FieldValueCondition(cf, EntityCondition.EQUALS, singleCondValue) } // special case, frequent operation: find by single key if (singleCond != null && wecNull && samSize == 0) return singleCond // see if we need to combine singleCond, simpleAndMap, and whereEntityCondition ArrayList condList = new ArrayList() if (singleCond != null) condList.add(singleCond) if (samSize > 0) { // create a ListCondition from the Map to allow for combination (simplification) with other conditions for (Map.Entry samEntry in simpleAndMap.entrySet()) { ConditionField cf if (localEd != null) { FieldInfo fi = localEd.getFieldInfo((String) samEntry.getKey()) if (fi == null) throw new EntityException("Error in find, field ${samEntry.getKey()} does not exist in entity ${localEd.getFullEntityName()}") cf = fi.conditionField } else { cf = new ConditionField((String) samEntry.key) } condList.add(new FieldValueCondition(cf, EntityCondition.EQUALS, samEntry.value)) } } if (condList.size() > 0) { if (!wecNull) { Class whereEntCondClass = whereEntityCondition.getClass() if (whereEntCondClass == ListCondition.class) { ListCondition listCond = (ListCondition) this.whereEntityCondition if (EntityCondition.AND.is(listCond.getOperator())) { condList.addAll(listCond.getConditionList()) return new ListCondition(condList, EntityCondition.AND) } else { condList.add(listCond) return new ListCondition(condList, EntityCondition.AND) } } else if (whereEntCondClass == FieldValueCondition.class || whereEntCondClass == DateCondition.class || whereEntCondClass == FieldToFieldCondition.class) { condList.add(whereEntityCondition) return new ListCondition(condList, EntityCondition.AND) } else if (whereEntCondClass == BasicJoinCondition.class) { BasicJoinCondition basicCond = (BasicJoinCondition) this.whereEntityCondition if (EntityCondition.AND.is(basicCond.getOperator())) { condList.add(basicCond.getLhs()) condList.add(basicCond.getRhs()) return new ListCondition(condList, EntityCondition.AND) } else { condList.add(basicCond) return new ListCondition(condList, EntityCondition.AND) } } else { condList.add(whereEntityCondition) return new ListCondition(condList, EntityCondition.AND) } } else { // no whereEntityCondition, just create a ListConditio for the simpleAndMap return new ListCondition(condList, EntityCondition.AND) } } else { return whereEntityCondition } } /** Used by TransactionCache */ Map getSimpleMapPrimaryKeys() { int samSize = simpleAndMap != null ? simpleAndMap.size() : 0 boolean scfNull = (singleCondField == null) if (samSize > 0 && !scfNull) logger.warn("simpleAndMap size ${samSize} and singleCondField not null!") Map pks = new HashMap<>() ArrayList pkFieldNames = getEntityDef().getPkFieldNames() int pkFieldNamesSize = pkFieldNames.size() for (int i = 0; i < pkFieldNamesSize; i++) { // only include PK fields which has a non-empty value, leave others out of the Map String fieldName = (String) pkFieldNames.get(i) Object value = null if (samSize > 0) value = simpleAndMap.get(fieldName) if (value == null && !scfNull && singleCondField.equals(fieldName)) value = singleCondValue // if any fields have no value we don't have a full PK so bye bye if (ObjectUtilities.isEmpty(value)) return null pks.put(fieldName, value) } return pks } @Override EntityCondition getHavingEntityCondition() { return havingEntityCondition } @Override EntityFind searchFormInputs(String inputFieldsMapName, String defaultOrderBy, boolean alwaysPaginate) { return searchFormInputs(inputFieldsMapName, null, null, defaultOrderBy, alwaysPaginate) } EntityFind searchFormInputs(String inputFieldsMapName, Map defaultParameters, String skipFields, String defaultOrderBy, boolean alwaysPaginate) { ExecutionContextImpl ec = efi.ecfi.getEci() Map inf = inputFieldsMapName ? (Map) ec.resource.expression(inputFieldsMapName, "") : ec.context return searchFormMap(inf, defaultParameters, skipFields, defaultOrderBy, alwaysPaginate) } @Override EntityFind searchFormMap(Map inputFieldsMap, Map defaultParameters, String skipFields, String defaultOrderBy, boolean alwaysPaginate) { ExecutionContextImpl ec = efi.ecfi.getEci() // to avoid issues with entities that have cache=true, if no cache value is specified for this set it to false (avoids pagination errors, etc) if (useCache == null) useCache(false) Set skipFieldSet = new HashSet<>() if (skipFields != null && !skipFields.isEmpty()) { String[] skipFieldArray = skipFields.split(",") for (int i = 0; i < skipFieldArray.length; i++) { String skipField = skipFieldArray[i].trim() if (skipField.length() > 0) skipFieldSet.add(skipField) } } boolean addedConditions = false if (inputFieldsMap != null && inputFieldsMap.size() > 0) addedConditions = processInputFields(inputFieldsMap, skipFieldSet, ec) hasSearchFormParameters = addedConditions if (!addedConditions && defaultParameters != null && defaultParameters.size() > 0) { processInputFields(defaultParameters, skipFieldSet, ec) for (Map.Entry dpEntry in defaultParameters.entrySet()) ec.contextStack.put(dpEntry.key, dpEntry.value) } // always look for an orderByField parameter too String orderByString = inputFieldsMap?.get("orderByField") ?: defaultOrderBy if (orderByString != null && orderByString.length() > 0) { ec.contextStack.put("orderByField", orderByString) this.orderBy(orderByString) } // look for the pageIndex and optional pageSize parameters; don't set these if should cache as will disable the cached query if ((alwaysPaginate || inputFieldsMap?.get("pageIndex") || inputFieldsMap?.get("pageSize")) && !shouldCache()) { int pageIndex = (inputFieldsMap?.get("pageIndex") ?: 0) as int int pageSize = (inputFieldsMap?.get("pageSize") ?: (this.limit ?: 20)) as int offset(pageIndex, pageSize) limit(pageSize) } // if there is a pageNoLimit clear out the limit regardless of other settings if ("true".equals(inputFieldsMap?.get("pageNoLimit")) || inputFieldsMap?.get("pageNoLimit") == true) { offset = null limit = null } return this } protected boolean processInputFields(Map inputFieldsMap, Set skipFieldSet, ExecutionContextImpl ec) { EntityDefinition ed = getEntityDef() boolean addedConditions = false for (FieldInfo fi in ed.allFieldInfoList) { String fn = fi.name if (skipFieldSet.contains(fn)) continue // NOTE: do we need to do type conversion here? // this will handle text-find if (inputFieldsMap.containsKey(fn) || inputFieldsMap.containsKey(fn + "_op")) { Object value = inputFieldsMap.get(fn) boolean valueEmpty = ObjectUtilities.isEmpty(value) String op = inputFieldsMap.get(fn + "_op") ?: "equals" boolean not = (inputFieldsMap.get(fn + "_not") == "Y" || inputFieldsMap.get(fn + "_not") == "true") boolean ic = (inputFieldsMap.get(fn + "_ic") == "Y" || inputFieldsMap.get(fn + "_ic") == "true") EntityCondition cond = null switch (op) { case "equals": if (!valueEmpty) { Object convertedValue = value instanceof String ? ed.convertFieldString(fn, (String) value, ec) : value cond = efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, convertedValue, not) if (ic) cond.ignoreCase() } break case "like": if (!valueEmpty) { cond = efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, value) if (ic) cond.ignoreCase() } break case "contains": if (!valueEmpty) { cond = efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, "%${value}%") if (ic) cond.ignoreCase() } break case "begins": if (!valueEmpty) { cond = efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_LIKE : EntityCondition.LIKE, "${value}%") if (ic) cond.ignoreCase() } break case "empty": cond = efi.entityConditionFactory.makeCondition( efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, null), not ? EntityCondition.JoinOperator.AND : EntityCondition.JoinOperator.OR, efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_EQUAL : EntityCondition.EQUALS, "")) break case "in": if (!valueEmpty) { Collection valueList = null if (value instanceof CharSequence) { valueList = Arrays.asList(value.toString().split(",")) } else if (value instanceof Collection) { valueList = (Collection) value } if (valueList) { cond = efi.entityConditionFactory.makeCondition(fn, not ? EntityCondition.NOT_IN : EntityCondition.IN, valueList, not) } } break } if (cond != null) { if (fi.hasAggregateFunction) { this.havingCondition(cond) } else { this.condition(cond) } addedConditions = true } } else if (inputFieldsMap.get(fn + "_period")) { List range = ec.user.getPeriodRange((String) inputFieldsMap.get(fn + "_period"), (String) inputFieldsMap.get(fn + "_poffset"), (String) inputFieldsMap.get(fn + "_pdate")) EntityCondition fromCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.GREATER_THAN_EQUAL_TO, range.get(0)) EntityCondition thruCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.LESS_THAN, range.get(1)) if (fi.hasAggregateFunction) { this.havingCondition(fromCond); this.havingCondition(thruCond) } else { this.condition(fromCond); this.condition(thruCond) } addedConditions = true } else { // these will handle range-find and date-find Object fromValue = inputFieldsMap.get(fn + "_from") if (fromValue && fromValue instanceof CharSequence) { if (fi.typeValue == 2 && fromValue.length() < 12) fromValue = ec.l10nFacade.parseTimestamp(fromValue.toString() + " 00:00:00.000", "yyyy-MM-dd HH:mm:ss.SSS") else fromValue = ed.convertFieldString(fn, fromValue.toString(), ec) } Object thruValue = inputFieldsMap.get(fn + "_thru") if (thruValue && thruValue instanceof CharSequence) { if (fi.typeValue == 2 && thruValue.length() < 12) thruValue = ec.l10nFacade.parseTimestamp(thruValue.toString() + " 23:59:59.999", "yyyy-MM-dd HH:mm:ss.SSS") else thruValue = ed.convertFieldString(fn, thruValue.toString(), ec) } if (!ObjectUtilities.isEmpty(fromValue)) { EntityCondition fromCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.GREATER_THAN_EQUAL_TO, fromValue) if (fi.hasAggregateFunction) { this.havingCondition(fromCond) } else { this.condition(fromCond) } addedConditions = true } if (!ObjectUtilities.isEmpty(thruValue)) { EntityCondition thruCond = efi.entityConditionFactory.makeCondition(fn, EntityCondition.LESS_THAN_EQUAL_TO, thruValue) if (fi.hasAggregateFunction) { this.havingCondition(thruCond) } else { this.condition(thruCond) } addedConditions = true } } } return addedConditions } // ======================== General/Common Options ======================== @Override EntityFind selectField(String fieldToSelect) { if (fieldToSelect == null || fieldToSelect.length() == 0) return this if (fieldsToSelect == null) fieldsToSelect = new ArrayList<>() if (fieldToSelect.contains(",")) { for (String ftsPart in fieldToSelect.split(",")) { String selectName = ftsPart.trim() if (getEntityDef().isField(selectName) && !fieldsToSelect.contains(selectName)) fieldsToSelect.add(selectName) } } else { if (getEntityDef().isField(fieldToSelect) && !fieldsToSelect.contains(fieldToSelect)) fieldsToSelect.add(fieldToSelect) } return this } @Override EntityFind selectFields(Collection selectFields) { if (!selectFields) return this for (String fieldToSelect in selectFields) selectField(fieldToSelect) return this } @Override List getSelectFields() { return fieldsToSelect } @Override EntityFind orderBy(String orderByFieldName) { if (orderByFieldName == null || orderByFieldName.length() == 0) return this if (this.orderByFields == null) this.orderByFields = new ArrayList<>() if (orderByFieldName.contains(",")) { for (String obsPart in orderByFieldName.split(",")) { String orderByName = obsPart.trim() FieldOrderOptions foo = new FieldOrderOptions(orderByName) if (getEntityDef().isField(foo.fieldName) && !this.orderByFields.contains(orderByName)) this.orderByFields.add(orderByName) } } else { FieldOrderOptions foo = new FieldOrderOptions(orderByFieldName) if (getEntityDef().isField(foo.fieldName) && !this.orderByFields.contains(orderByFieldName)) this.orderByFields.add(orderByFieldName) } return this } @Override EntityFind orderBy(List orderByFieldNames) { if (orderByFieldNames == null || orderByFieldNames.size() == 0) return this if (orderByFieldNames instanceof RandomAccess) { // avoid creating an iterator if possible int listSize = orderByFieldNames.size() for (int i = 0; i < listSize; i++) orderBy((String) orderByFieldNames.get(i)) } else { for (String orderByFieldName in orderByFieldNames) orderBy(orderByFieldName) } return this } @Override List getOrderBy() { return orderByFields != null ? Collections.unmodifiableList(orderByFields) : null } ArrayList getOrderByFields() { return orderByFields } @Override EntityFind useCache(Boolean useCache) { this.useCache = useCache; return this } @Override boolean getUseCache() { return this.useCache } @Override EntityFind useClone(boolean uc) { useClone = uc; return this } // ======================== Advanced Options ============================== @Override EntityFind distinct(boolean distinct) { this.distinct = distinct; return this } @Override boolean getDistinct() { return distinct } @Override EntityFind offset(Integer offset) { this.offset = offset; return this } @Override EntityFind offset(int pageIndex, int pageSize) { offset(pageIndex * pageSize) } @Override Integer getOffset() { return offset } @Override EntityFind limit(Integer limit) { this.limit = limit; return this } @Override Integer getLimit() { return limit } @Override int getPageIndex() { return offset == null ? 0 : (offset/getPageSize()).intValue() } @Override int getPageSize() { return limit != null ? limit : 20 } @Override EntityFind forUpdate(boolean forUpdate) { this.forUpdate = forUpdate this.resultSetType = forUpdate ? ResultSet.TYPE_SCROLL_SENSITIVE : defaultResultSetType return this } @Override boolean getForUpdate() { return this.forUpdate } // ======================== JDBC Options ============================== @Override EntityFind resultSetType(int resultSetType) { this.resultSetType = resultSetType; return this } @Override int getResultSetType() { return this.resultSetType } @Override EntityFind resultSetConcurrency(int rsc) { resultSetConcurrency = rsc; return this } @Override int getResultSetConcurrency() { return this.resultSetConcurrency } @Override EntityFind fetchSize(Integer fetchSize) { this.fetchSize = fetchSize; return this } @Override Integer getFetchSize() { return this.fetchSize } @Override EntityFind maxRows(Integer maxRows) { this.maxRows = maxRows; return this } @Override Integer getMaxRows() { return this.maxRows } // ======================== Misc Methods ======================== EntityDefinition getEntityDef() { if (entityDef != null) return entityDef if (dynamicView != null) { entityDef = dynamicView.makeEntityDefinition() } else { entityDef = efi.getEntityDefinition(entityName) } return entityDef } @Override EntityFind disableAuthz() { disableAuthz = true; return this } @Override EntityFind requireSearchFormParameters(boolean req) { this.requireSearchFormParameters = req; return this } @Override boolean shouldCache() { if (dynamicView != null) return false if (havingEntityCondition != null) return false if (limit != null || offset != null) return false if (forUpdate) return false if (useCache != null) { boolean useCacheLocal = useCache.booleanValue() if (!useCacheLocal) return false return !getEntityDef().entityInfo.neverCache } else { return "true".equals(getEntityDef().entityInfo.useCache) } } @Override String toString() { return "Find: ${entityName} WHERE [${singleCondField?:''}:${singleCondValue?:''}] [${simpleAndMap}] [${whereEntityCondition}] HAVING [${havingEntityCondition}] " + "SELECT [${fieldsToSelect}] ORDER BY [${orderByFields}] CACHE [${useCache}] DISTINCT [${distinct}] " + "OFFSET [${offset}] LIMIT [${limit}] FOR UPDATE [${forUpdate}]" } private static String makeErrorMsg(String baseMsg, String expandMsg, EntityConditionImplBase cond, EntityDefinition ed, ExecutionContextImpl ec) { Map errorContext = new HashMap<>() errorContext.put("entityName", ed.getEntityName()); errorContext.put("condition", cond) String errorMessage = null // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only) if (false && !"LocalizedMessage".equals(ed.getEntityName())) { try { errorMessage = ec.resourceFacade.expand(expandMsg, null, errorContext) } catch (Throwable t) { logger.trace("Error expanding error message", t) } } if (errorMessage == null) errorMessage = baseMsg + " " + ed.getEntityName() + " by " + cond return errorMessage } private void registerForUpdateLock(Map fieldValues) { if (fieldValues == null || fieldValues.size() == 0) return if (!forUpdate) return final TransactionFacadeImpl tfi = efi.ecfi.transactionFacade if (!tfi.getUseLockTrack()) return EntityDefinition ed = getEntityDef() ArrayList stackArray = efi.ecfi.getEci().artifactExecutionFacade.getStackArray() tfi.registerRecordLock(new ContextJavaUtil.EntityRecordLock(ed.getFullEntityName(), ed.getPrimaryKeysString(fieldValues), stackArray)) } // ======================== Find and Abstract Methods ======================== abstract EntityDynamicView makeEntityDynamicView() @Override EntityValue one() throws EntityException { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "one") // really worth the overhead? if so change to handle singleCondField: .setParameters(simpleAndMap) aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { return oneInternal(ec, ed) } finally { // pop the ArtifactExecutionInfo aefi.pop(aei) } } finally { if (enableAuthz) aefi.enableAuthz() } } @Override Map oneMaster(String name) { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "one") aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { EntityValue ev = oneInternal(ec, ed) if (ev == null) return null return ev.getMasterValueMap(name) } finally { // pop the ArtifactExecutionInfo aefi.pop(aei) } } finally { if (enableAuthz) aefi.enableAuthz() } } protected EntityValue oneInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException { if (this.dynamicView != null) throw new EntityException("Dynamic View not supported for 'one' find.") boolean isViewEntity = ed.isViewEntity EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo if (entityInfo.isInvalidViewEntity) throw new EntityException("Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.") // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-one", true) boolean hasEmptyPk = false boolean hasFullPk = true if (singleCondField != null && ed.isPkField(singleCondField) && ObjectUtilities.isEmpty(singleCondValue)) { hasEmptyPk = true; hasFullPk = false } ArrayList pkNameList = ed.getPkFieldNames() int pkSize = pkNameList.size() int samSize = simpleAndMap != null ? simpleAndMap.size() : 0 if (hasFullPk && samSize > 1) { for (int i = 0; i < pkSize; i++) { String fieldName = (String) pkNameList.get(i) Object fieldValue = simpleAndMap.get(fieldName) if (ObjectUtilities.isEmpty(fieldValue)) { if (simpleAndMap.containsKey(fieldName)) hasEmptyPk = true hasFullPk = false break } } } // if over-constrained (anything in addition to a full PK), just use the full PK if (hasFullPk && samSize > 1) { Map pks = new HashMap<>() if (singleCondField != null) { // this shouldn't generally happen, added to simpleAndMap internally on the fly when needed, but just in case pks.put(singleCondField, singleCondValue) singleCondField = (String) null; singleCondValue = null } for (int i = 0; i < pkSize; i++) { String fieldName = (String) pkNameList.get(i) pks.put(fieldName, simpleAndMap.get(fieldName)) } simpleAndMap = pks } // if any PK fields are null, for whatever reason in calling code, the result is null so no need to send to DB or cache or anything if (hasEmptyPk) return (EntityValue) null boolean doCache = shouldCache() // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity if (doCache) { // don't cache if there are any applicable filter conditions ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null) if (findFilterList != null && findFilterList.size() > 0) doCache = false } EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed) // no condition means no condition/parameter set, so return null for find.one() if (whereCondition == null) return (EntityValue) null // try the TX cache before the entity cache, should be more up-to-date EntityValueBase txcValue = (EntityValueBase) null if (txCache != null) { txcValue = txCache.oneGet(this) // NOTE: don't do this, opt to get latest from tx cache instead of from DB instead of trying to merge, lock // only query done below; tx cache causes issues when for update used after non for update query if // latest values from DB are needed! // if we got a value from txCache and we're doing a for update and it was not created in this tx cache then // don't use it, we want the latest value from the DB (may have been queried without for update in this tx) // if (txcValue != null && forUpdate && !txCache.isTxCreate(txcValue)) txcValue = (EntityValueBase) null } // if (txcValue != null && ed.getEntityName() == "foo") logger.warn("========= TX cache one value: ${txcValue}") Cache entityOneCache = doCache ? ed.getCacheOne(efi.getEntityCache()) : (Cache) null EntityValueBase cacheHit = (EntityValueBase) null if (doCache && txcValue == null && !forUpdate) cacheHit = (EntityValueBase) entityOneCache.get(whereCondition) // we always want fieldInfoArray populated so that we know the order of the results coming back int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0 FieldInfo[] fieldInfoArray FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null if (ftsSize == 0 || (txCache != null && txcValue == null) || (doCache && cacheHit == null)) { fieldInfoArray = entityInfo.allFieldInfoArray } else { fieldInfoArray = new FieldInfo[ftsSize] fieldOptionsArray = new FieldOrderOptions[ftsSize] boolean hasFieldOptions = false int fieldInfoArrayIndex = 0 for (int i = 0; i < ftsSize; i++) { String fieldName = (String) fieldsToSelect.get(i) FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) { FieldOrderOptions foo = new FieldOrderOptions(fieldName) fi = ed.getFieldInfo(foo.fieldName) if (fi == null) throw new EntityException("Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}") fieldInfoArray[fieldInfoArrayIndex] = fi fieldOptionsArray[fieldInfoArrayIndex] = foo fieldInfoArrayIndex++ hasFieldOptions = true } else { fieldInfoArray[fieldInfoArrayIndex] = fi fieldInfoArrayIndex++ } } if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length) fieldInfoArray = entityInfo.allFieldInfoArray } // if (ed.getEntityName() == "Asset") logger.warn("=========== find one of Asset ${this.simpleAndMap.get('assetId')}", new Exception("Location")) // call the abstract method EntityValueBase newEntityValue = (EntityValueBase) null if (txcValue != null) { if (txcValue instanceof EntityValueBase.DeletedEntityValue) { // is deleted value, so leave newEntityValue as null // put in cache as null since this was deleted if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, null, entityOneCache) } else { // if forUpdate unless this was a TX CREATE it'll be in the DB and should be locked, so do the query // anyway, but ignore the result unless it's a read only tx cache if (forUpdate && !txCache.isKnownLocked(txcValue) && !txCache.isTxCreate(txcValue)) { EntityValueBase fuDbValue EntityConditionImplBase cond = isViewEntity ? getConditionForQuery(ed, whereCondition) : whereCondition // register lock before if we have a full pk, otherwise after if (hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack()) registerForUpdateLock(simpleAndMap != null ? simpleAndMap : [(singleCondField):singleCondValue]) try { fuDbValue = oneExtended(cond, fieldInfoArray, fieldOptionsArray) } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding one", ONE_ERROR, cond, ed, ec), e) } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding one", ONE_ERROR, cond, ed, ec), e) } // register lock before if we have a full pk, otherwise after; this particular one doesn't make sense, shouldn't happen, so just in case if (!hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack()) registerForUpdateLock(fuDbValue) if (txCache.isReadOnly()) { // is read only tx cache so use the value from the DB newEntityValue = fuDbValue // tell the tx cache about the new value txCache.update(fuDbValue) } else { // we could try to merge the TX cache value and the latest DB value, but for now opt for the // TX cache value over the DB value // if txcValue has been modified (fields in dbValueMap) see if those match what is coming from DB Map txDbValueMap = txcValue.getDbValueMap() Map fuDbValueMap = fuDbValue.getValueMap() if (txDbValueMap != null && txDbValueMap.size() > 0 && !CollectionUtilities.mapMatchesFields(fuDbValueMap, txDbValueMap)) { StringBuilder fieldDiffBuilder = new StringBuilder() for (Map.Entry entry in txDbValueMap.entrySet()) { Object compareObj = txDbValueMap.get(entry.getKey()) Object baseObj = fuDbValueMap.get(entry.getKey()) if (compareObj != baseObj) fieldDiffBuilder.append("- ").append(entry.key).append(": ") .append(compareObj).append(" (txc) != ").append(baseObj).append(" (db)\n") } logger.warn("Did for update query on ${ed.getFullEntityName()} and result did not match value in transaction cache: \n${fieldDiffBuilder}", new BaseException("location")) } newEntityValue = txcValue } } else { newEntityValue = txcValue } // put it in whether null or not (already know cacheHit is null) if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, newEntityValue, entityOneCache) } } else if (cacheHit != null) { if (cacheHit instanceof EntityCache.EmptyRecord) newEntityValue = (EntityValueBase) null else newEntityValue = cacheHit } else { // for find one we'll always use the basic result set type and concurrency: this.resultSetType = ResultSet.TYPE_FORWARD_ONLY this.resultSetConcurrency = ResultSet.CONCUR_READ_ONLY EntityConditionImplBase cond = isViewEntity ? getConditionForQuery(ed, whereCondition) : whereCondition // register lock before if we have a full pk, otherwise after if (forUpdate && hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack()) registerForUpdateLock(simpleAndMap != null ? simpleAndMap : [(singleCondField):singleCondValue]) try { tempHasFullPk = hasFullPk newEntityValue = oneExtended(cond, fieldInfoArray, fieldOptionsArray) } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding one", ONE_ERROR, cond, ed, ec), e) } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding one", ONE_ERROR, cond, ed, ec), e) } finally { tempHasFullPk = null } // register lock before if we have a full pk, otherwise after if (forUpdate && !hasFullPk && efi.ecfi.transactionFacade.getUseLockTrack()) registerForUpdateLock(newEntityValue) // it didn't come from the txCache so put it there if (txCache != null) txCache.onePut(newEntityValue, forUpdate) // put it in whether null or not (already know cacheHit is null) if (doCache) efi.getEntityCache().putInOneCache(ed, whereCondition, newEntityValue, entityOneCache) } // if (logger.traceEnabled) logger.trace("Find one on entity [${ed.fullEntityName}] with condition [${whereCondition}] found value [${newEntityValue}]") // final ECA trigger // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), newEntityValue, "find-one", false) return newEntityValue } EntityConditionImplBase getConditionForQuery(EntityDefinition ed, EntityConditionImplBase whereCondition) { // NOTE: do actual query condition as a separate condition because this will always be added on and isn't a // part of the original where to use for the cache EntityConditionImplBase conditionForQuery EntityConditionImplBase viewWhere = ed.makeViewWhereCondition() if (viewWhere != null) { if (whereCondition != null) conditionForQuery = (EntityConditionImplBase) efi.getConditionFactory() .makeCondition(whereCondition, EntityCondition.JoinOperator.AND, viewWhere) else conditionForQuery = viewWhere } else { conditionForQuery = whereCondition } return conditionForQuery } /** The abstract oneExtended method to implement */ abstract EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException @Override EntityList list() throws EntityException { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "list") aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { return listInternal(ec, ed) } finally { aefi.pop(aei) } } finally { if (enableAuthz) aefi.enableAuthz() } } @Override List> listMaster(String name) { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !aefi.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "list") aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { EntityList el = listInternal(ec, ed) return el.getMasterValueList(name) } finally { // pop the ArtifactExecutionInfo aefi.pop(aei) } } finally { if (enableAuthz) aefi.enableAuthz() } } protected EntityList listInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException { if (requireSearchFormParameters && !hasSearchFormParameters) { ec.contextStack.getSharedMap().put("_entityListNoSearchParms", true) logger.info("No parameters for list find on ${ed.fullEntityName}, not doing search") return new EntityListImpl(efi) } EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo boolean isViewEntity = entityInfo.isView if (entityInfo.isInvalidViewEntity) throw new EntityException("Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.") // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-list", true) ArrayList orderByExpanded = new ArrayList() // add the manually specified ones, then the ones in the view entity's entity-condition if (orderByFields != null) orderByExpanded.addAll(orderByFields) if (isViewEntity) { MNode entityConditionNode = ed.entityConditionNode if (entityConditionNode != null) { ArrayList ecObList = entityConditionNode.children("order-by") if (ecObList != null) for (int i = 0; i < ecObList.size(); i++) { MNode orderBy = (MNode) ecObList.get(i) String fieldName = orderBy.attribute("field-name") if(!orderByExpanded.contains(fieldName)) orderByExpanded.add(fieldName) } if ("true".equals(entityConditionNode.attribute("distinct"))) this.distinct(true) } } boolean doEntityCache = shouldCache() // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity if (doEntityCache) { // don't cache if there are any applicable filter conditions ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null) if (findFilterList != null && findFilterList.size() > 0) doEntityCache = false } EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed) // don't cache if no whereCondition if (whereCondition == null) doEntityCache = false // try the txCache first, more recent than general cache (and for update general cache entries will be cleared anyway) EntityListImpl txcEli = txCache != null ? txCache.listGet(ed, whereCondition, orderByExpanded) : (EntityListImpl) null // NOTE: don't cache if there is a having condition, for now just support where // NOTE: could avoid caching lists if it is a filtered find, but mostly by org so reusable: && !filteredFind Cache entityListCache = doEntityCache ? ed.getCacheList(efi.getEntityCache()) : (Cache) null EntityListImpl cacheList = (EntityListImpl) null if (doEntityCache && txcEli == null && !forUpdate) cacheList = efi.getEntityCache().getFromListCache(ed, whereCondition, orderByExpanded, entityListCache) EntityListImpl el if (txcEli != null) { el = txcEli // if (ed.getFullEntityName().contains("OrderItem")) logger.warn("======== Got OrderItem from txCache ${el.size()} results where: ${whereCondition}") } else if (cacheList != null) { el = cacheList } else { // order by fields need to be selected (at least on some databases, Derby is one of them) int orderByExpandedSize = orderByExpanded.size() if (getDistinct() && fieldsToSelect != null && fieldsToSelect.size() > 0 && orderByExpandedSize > 0) { for (int i = 0; i < orderByExpandedSize; i++) { String orderByField = (String) orderByExpanded.get(i) FieldOrderOptions foo = new FieldOrderOptions(orderByField) if (!fieldsToSelect.contains(foo.fieldName)) fieldsToSelect.add(foo.fieldName) } } // we always want fieldInfoArray populated so that we know the order of the results coming back int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0 FieldInfo[] fieldInfoArray FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null if (ftsSize == 0 || doEntityCache) { fieldInfoArray = entityInfo.allFieldInfoArray } else { fieldInfoArray = new FieldInfo[ftsSize] fieldOptionsArray = new FieldOrderOptions[ftsSize] boolean hasFieldOptions = false int fieldInfoArrayIndex = 0 for (int i = 0; i < ftsSize; i++) { String fieldName = (String) fieldsToSelect.get(i) FieldInfo fi = (FieldInfo) ed.getFieldInfo(fieldName) if (fi == null) { FieldOrderOptions foo = new FieldOrderOptions(fieldName) fi = ed.getFieldInfo(foo.fieldName) if (fi == null) throw new EntityException("Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}") fieldInfoArray[fieldInfoArrayIndex] = fi fieldOptionsArray[fieldInfoArrayIndex] = foo fieldInfoArrayIndex++ hasFieldOptions = true } else { fieldInfoArray[fieldInfoArrayIndex] = fi fieldInfoArrayIndex++ } } if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length) fieldInfoArray = entityInfo.allFieldInfoArray } EntityConditionImplBase queryWhereCondition = whereCondition EntityConditionImplBase havingCondition = havingEntityCondition if (isViewEntity) { EntityConditionImplBase viewWhere = ed.makeViewWhereCondition() queryWhereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere) havingCondition = havingEntityCondition EntityConditionImplBase viewHaving = ed.makeViewHavingCondition() havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving) } // call the abstract method try (EntityListIterator eli = iteratorExtended(queryWhereCondition, havingCondition, orderByExpanded, fieldInfoArray, fieldOptionsArray)) { MNode databaseNode = this.efi.getDatabaseNode(ed.getEntityGroupName()) if (limit != null && databaseNode != null && "cursor".equals(databaseNode.attribute("offset-style"))) { el = (EntityListImpl) eli.getPartialList(offset != null ? offset : 0, limit, false) } else { el = (EntityListImpl) eli.getCompleteList(false); } } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding list of", LIST_ERROR, queryWhereCondition, ed, ec), e) } catch (ArtifactAuthorizationException e) { throw e } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding list of", LIST_ERROR, queryWhereCondition, ed, ec), e) } // register lock after because we can't before, don't know which records will be returned if (forUpdate && !isViewEntity && efi.ecfi.transactionFacade.getUseLockTrack()) { int elSize = el.size() for (int i = 0; i < elSize; i++) { EntityValue ev = (EntityValue) el.get(i) registerForUpdateLock(ev) } } // don't put in tx cache if it is going in list cache if (txCache != null && !doEntityCache && ftsSize == 0) txCache.listPut(ed, whereCondition, el) if (doEntityCache) efi.getEntityCache().putInListCache(ed, el, whereCondition, entityListCache) // if (ed.getFullEntityName().contains("OrderItem")) logger.warn("======== Got OrderItem from DATABASE ${el.size()} results where: ${whereCondition}") // logger.warn("======== Got ${ed.getFullEntityName()} from DATABASE ${el.size()} results where: ${whereCondition}") } // run the final rules // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-list", false) return el } @Override EntityListIterator iterator() throws EntityException { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !ec.artifactExecutionFacade.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "iterator") aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { return iteratorInternal(ec, ed) } finally { aefi.pop(aei) } } finally { if (enableAuthz) ec.artifactExecutionFacade.enableAuthz() } } protected EntityListIterator iteratorInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException { if (requireSearchFormParameters && !hasSearchFormParameters) return null EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo boolean isViewEntity = entityInfo.isView if (entityInfo.isInvalidViewEntity) throw new EntityException("Cannot do find for view-entity with name ${entityName} because it has no member entities or no aliased fields.") // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-iterator", true) ArrayList orderByExpanded = new ArrayList() // add the manually specified ones, then the ones in the view entity's entity-condition if (this.orderByFields != null) orderByExpanded.addAll(this.orderByFields) if (isViewEntity) { MNode entityConditionNode = ed.entityConditionNode if (entityConditionNode != null) { ArrayList ecObList = entityConditionNode.children("order-by") if (ecObList != null) for (int i = 0; i < ecObList.size(); i++) { MNode orderBy = ecObList.get(i) String fieldName = orderBy.attribute("field-name") if(!orderByExpanded.contains(fieldName)) orderByExpanded.add(fieldName) } if ("true".equals(entityConditionNode.attribute("distinct"))) this.distinct(true) } } // order by fields need to be selected (at least on some databases, Derby is one of them) if (getDistinct() && fieldsToSelect != null && fieldsToSelect.size() > 0 && orderByExpanded.size() > 0) { for (String orderByField in orderByExpanded) { FieldOrderOptions foo = new FieldOrderOptions(orderByField) if (!fieldsToSelect.contains(foo.fieldName)) fieldsToSelect.add(foo.fieldName) } } // we always want fieldInfoArray populated so that we know the order of the results coming back int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0 FieldInfo[] fieldInfoArray FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null if (ftsSize == 0) { fieldInfoArray = entityInfo.allFieldInfoArray } else { fieldInfoArray = new FieldInfo[ftsSize] fieldOptionsArray = new FieldOrderOptions[ftsSize] boolean hasFieldOptions = false int fieldInfoArrayIndex = 0 for (int i = 0; i < ftsSize; i++) { String fieldName = (String) fieldsToSelect.get(i) FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) { FieldOrderOptions foo = new FieldOrderOptions(fieldName) fi = ed.getFieldInfo(foo.fieldName) if (fi == null) throw new EntityException("Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}") fieldInfoArray[fieldInfoArrayIndex] = fi fieldOptionsArray[fieldInfoArrayIndex] = foo fieldInfoArrayIndex++ hasFieldOptions = true } else { fieldInfoArray[fieldInfoArrayIndex] = fi fieldInfoArrayIndex++ } } if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length) fieldInfoArray = entityInfo.allFieldInfoArray } // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed) EntityConditionImplBase havingCondition = havingEntityCondition if (isViewEntity) { EntityConditionImplBase viewWhere = ed.makeViewWhereCondition() whereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere) EntityConditionImplBase viewHaving = ed.makeViewHavingCondition() havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving) } // call the abstract method EntityListIterator eli try { eli = iteratorExtended(whereCondition, havingCondition, orderByExpanded, fieldInfoArray, fieldOptionsArray) } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding list of", LIST_ERROR, whereCondition, ed, ec), e) } catch (ArtifactAuthorizationException e) { throw e } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding list of", LIST_ERROR, whereCondition, ed, ec), e) } // NOTE: if we are doing offset/limit with a cursor no good way to limit results, but we'll at least jump to the offset MNode databaseNode = this.efi.getDatabaseNode(ed.getEntityGroupName()) // NOTE: allow databaseNode to be null because custom (non-JDBC) datasources may not have one if (this.offset != null && databaseNode != null && "cursor".equals(databaseNode.attribute("offset-style"))) { if (!eli.absolute(offset)) { // can't seek to desired offset? not enough results, just go to after last result eli.afterLast() } } // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-iterator", false) return eli } abstract EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, ArrayList orderByExpanded, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException @Override long count() throws EntityException { ExecutionContextImpl ec = efi.ecfi.getEci() ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade boolean enableAuthz = disableAuthz ? !ec.artifactExecutionFacade.disableAuthz() : false try { EntityDefinition ed = getEntityDef() ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(ed.getFullEntityName(), ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "count") aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false) try { return countInternal(ec, ed) } finally { aefi.pop(aei) } } finally { if (enableAuthz) ec.artifactExecutionFacade.enableAuthz() } } protected long countInternal(ExecutionContextImpl ec, EntityDefinition ed) throws EntityException, SQLException { if (requireSearchFormParameters && !hasSearchFormParameters) return 0L EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo boolean isViewEntity = entityInfo.isView // there may not be a simpleAndMap, but that's all we have that can be treated directly by the EECA // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-count", true) boolean doCache = shouldCache() // NOTE: artifactExecutionFacade.filterFindForUser() no longer called here, called in EntityFindBuilder after trimming if needed for view-entity if (doCache) { // don't cache if there are any applicable filter conditions ArrayList findFilterList = ec.artifactExecutionFacade.getFindFiltersForUser(ed, null) if (findFilterList != null && findFilterList.size() > 0) doCache = false } EntityConditionImplBase whereCondition = getWhereEntityConditionInternal(ed) // don't cache if no whereCondition if (whereCondition == null) doCache = false // NOTE: don't cache if there is a having condition, for now just support where Cache entityCountCache = doCache ? ed.getCacheCount(efi.getEntityCache()) : (Cache) null Long cacheCount = (Long) null if (doCache) cacheCount = (Long) entityCountCache.get(whereCondition) long count if (cacheCount != null) { count = cacheCount } else { // select all pk and nonpk fields to match what list() or iterator() would do int ftsSize = fieldsToSelect != null ? fieldsToSelect.size() : 0 FieldInfo[] fieldInfoArray FieldOrderOptions[] fieldOptionsArray = (FieldOrderOptions[]) null if (ftsSize == 0) { fieldInfoArray = entityInfo.allFieldInfoArray } else { fieldInfoArray = new FieldInfo[ftsSize] fieldOptionsArray = new FieldOrderOptions[ftsSize] boolean hasFieldOptions = false int fieldInfoArrayIndex = 0 for (int i = 0; i < ftsSize; i++) { String fieldName = (String) fieldsToSelect.get(i) FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) { FieldOrderOptions foo = new FieldOrderOptions(fieldName) fi = ed.getFieldInfo(foo.fieldName) if (fi == null) throw new EntityException("Field to select ${fieldName} not found in entity ${ed.getFullEntityName()}") fieldInfoArray[fieldInfoArrayIndex] = fi fieldOptionsArray[fieldInfoArrayIndex] = foo fieldInfoArrayIndex++ hasFieldOptions = true } else { fieldInfoArray[fieldInfoArrayIndex] = fi fieldInfoArrayIndex++ } } if (!hasFieldOptions) fieldOptionsArray = (FieldOrderOptions[]) null if (fieldOptionsArray == null && ftsSize == entityInfo.allFieldInfoArray.length) fieldInfoArray = entityInfo.allFieldInfoArray } // logger.warn("fieldsToSelect: ${fieldsToSelect} fieldInfoArray: ${fieldInfoArray}") if (isViewEntity) { MNode entityConditionNode = ed.entityConditionNode if (entityConditionNode != null && "true".equals(entityConditionNode.attribute("distinct"))) this.distinct(true) } EntityConditionImplBase queryWhereCondition = whereCondition EntityConditionImplBase havingCondition = havingEntityCondition if (isViewEntity) { EntityConditionImplBase viewWhere = ed.makeViewWhereCondition() queryWhereCondition = EntityConditionFactoryImpl.makeConditionImpl(whereCondition, EntityCondition.AND, viewWhere) havingCondition = havingEntityCondition EntityConditionImplBase viewHaving = ed.makeViewHavingCondition() havingCondition = EntityConditionFactoryImpl.makeConditionImpl(havingCondition, EntityCondition.AND, viewHaving) } // call the abstract method try { count = countExtended(queryWhereCondition, havingCondition, fieldInfoArray, fieldOptionsArray) } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding count of", COUNT_ERROR, queryWhereCondition, ed, ec), e) } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding count of", COUNT_ERROR, queryWhereCondition, ed, ec), e) } if (doCache) entityCountCache.put(whereCondition, count) } // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(ed.getFullEntityName(), simpleAndMap, "find-count", false) return count } abstract long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException @Override long updateAll(Map fieldsToSet) { boolean enableAuthz = disableAuthz ? !efi.ecfi.getEci().artifactExecutionFacade.disableAuthz() : false try { return updateAllInternal(fieldsToSet) } finally { if (enableAuthz) efi.ecfi.getEci().artifactExecutionFacade.enableAuthz() } } protected long updateAllInternal(Map fieldsToSet) { // NOTE: this code isn't very efficient, but will do the trick and cause all EECAs to be fired // NOTE: consider expanding this to do a bulk update in the DB if there are no EECAs for the entity EntityDefinition ed = getEntityDef() if (ed.entityInfo.createOnly) throw new EntityException("Entity ${ed.getFullEntityName()} is create-only (immutable), cannot be updated.") this.useCache(false) long totalUpdated = 0 iterator().withCloseable ({eli -> EntityValue value while ((value = eli.next()) != null) { value.putAll(fieldsToSet) if (value.isModified()) { // NOTE: consider implement and use the eli.set(value) method to update within a ResultSet value.update() totalUpdated++ } } }) return totalUpdated } @Override long deleteAll() { boolean enableAuthz = disableAuthz ? !efi.ecfi.getEci().artifactExecutionFacade.disableAuthz() : false try { return deleteAllInternal() } finally { if (enableAuthz) efi.ecfi.getEci().artifactExecutionFacade.enableAuthz() } } protected long deleteAllInternal() { // NOTE: this code isn't very efficient (though eli.remove() is a little bit more), but will do the trick and cause all EECAs to be fired EntityDefinition ed = getEntityDef() if (ed.entityInfo.createOnly) throw new EntityException("Entity ${ed.getFullEntityName()} is create-only (immutable), cannot be deleted.") // if there are no EECAs for the entity OR there is a TransactionCache in place just call ev.delete() on each // NOTE DEJ 20200716 always use EV delete, not all JDBC drivers support ResultSet.deleteRow()... like MySQL Connector/J 8.0.20 // boolean useEvDelete = txCache != null || efi.hasEecaRules(ed.getFullEntityName()) boolean useEvDelete = true this.useCache(false) long totalDeleted = 0 if (useEvDelete) { // TODO: use EntityListIterator to avoid OutOfMemoryError EntityList el = list() int elSize = el.size() for (int i = 0; i < elSize; i++) { EntityValue ev = (EntityValue) el.get(i) ev.delete() totalDeleted++ } } else { this.resultSetConcurrency(ResultSet.CONCUR_UPDATABLE) iterator().withCloseable ({eli-> while (eli.next() != null) { // no longer need to clear cache, eli.remove() does that eli.remove() totalDeleted++ } }) } return totalDeleted } @Override void extract(SimpleEtl etl) { try (EntityListIterator eli = iterator()) { EntityValue ev while ((ev = eli.next()) != null) { etl.processEntry(ev) } } catch (StopException e) { logger.warn("EntityFind extract stopped on: " + (e.getCause()?.toString() ?: e.toString())) } } @Override ArrayList getQueryTextList() { return queryTextList } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityFindBuilder.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.BaseArtifactException; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityException; import org.moqui.impl.entity.condition.EntityConditionImplBase; import org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions; import org.moqui.util.MNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.PreparedStatement; import java.sql.SQLException; import java.util.ArrayList; import java.util.HashSet; import java.util.Set; import java.util.TreeSet; public class EntityFindBuilder extends EntityQueryBuilder { private static final Logger logger = LoggerFactory.getLogger(EntityFindBuilder.class); private static final boolean isDebugEnabled = logger.isDebugEnabled(); private EntityFindBase entityFindBase; private EntityConditionImplBase whereCondition; private FieldInfo[] fieldInfoArray; public EntityFindBuilder(EntityDefinition entityDefinition, EntityFindBase entityFindBase, EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray) { super(entityDefinition, entityFindBase.efi); this.entityFindBase = entityFindBase; this.whereCondition = whereCondition; this.fieldInfoArray = fieldInfoArray; // this is always going to start with "SELECT ", so just set it here sqlTopLevel.append("SELECT "); } public void makeDistinct() { sqlTopLevel.append("DISTINCT "); } public void makeCountFunction(FieldOrderOptions[] fieldOptionsArray, boolean isDistinct, boolean isGroupBy) { int fiaLength = fieldInfoArray.length; if (isGroupBy || (isDistinct && fiaLength > 0)) { sqlTopLevel.append("COUNT(*) FROM (SELECT "); if (isDistinct) sqlTopLevel.append("DISTINCT "); // NOTE: regardless of DB configuration (database.@add-unique-as) it is always needed across various DBs in this case, including MySQL makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, true); // NOTE: this will be closed by closeCountSubSelect() } else { if (isDistinct) { sqlTopLevel.append("COUNT(DISTINCT *) "); } else { // NOTE: on H2 COUNT(*) is faster than COUNT(1) (and perhaps other databases? docs hint may be faster in MySQL) sqlTopLevel.append("COUNT(*) "); } } } public void closeCountSubSelect(int fiaLength, boolean isDistinct, boolean isGroupBy) { if (isGroupBy || (isDistinct && fiaLength > 0)) sqlTopLevel.append(") TEMP_NAME"); } public void expandJoinFromAlias(final MNode entityNode, final String searchEntityAlias, Set entityAliasUsedSet, Set entityAliasesJoinedInSet) { // first see if it needs expanding if (entityAliasesJoinedInSet.contains(searchEntityAlias)) return; // find the a link back one in the set MNode memberEntityNode = entityNode.first("member-entity", "entity-alias", searchEntityAlias); if (memberEntityNode == null) throw new EntityException("Could not find member-entity with entity-alias " + searchEntityAlias + " in view-entity " + entityNode.attribute("entity-name")); String joinFromAlias = memberEntityNode.attribute("join-from-alias"); if (joinFromAlias == null || joinFromAlias.length() == 0) throw new EntityException("In view-entity " + entityNode.attribute("entity-name") + " the member-entity for entity-alias " + searchEntityAlias + " has no join-from-alias and is not the first member-entity"); if (entityAliasesJoinedInSet.contains(joinFromAlias)) { entityAliasesJoinedInSet.add(searchEntityAlias); entityAliasUsedSet.add(joinFromAlias); entityAliasUsedSet.add(searchEntityAlias); } else { // recurse to find member-entity with joinFromAlias, add in its joinFromAlias until one is found that is already in the set expandJoinFromAlias(entityNode, joinFromAlias, entityAliasUsedSet, entityAliasesJoinedInSet); // if no exception from an alias not found or not joined in then we found a join path back so add in the current search alias entityAliasesJoinedInSet.add(searchEntityAlias); entityAliasUsedSet.add(searchEntityAlias); } } public void makeSqlFromClause() { whereCondition = makeSqlFromClause(mainEntityDefinition, sqlTopLevel, whereCondition, (EntityConditionImplBase) entityFindBase.getHavingEntityCondition(), null); } public EntityConditionImplBase makeSqlFromClause(final EntityDefinition localEntityDefinition, StringBuilder localBuilder, EntityConditionImplBase localWhereCondition, EntityConditionImplBase localHavingCondition, Set additionalFieldsUsed) { localBuilder.append(" FROM "); EntityConditionImplBase outWhereCondition = localWhereCondition; // TODO: bug in Groovy 3.0.10 that somehow flips the isViewEntity boolean; can remove this once resolved with a future version of Groovy // if (localEntityDefinition.fullEntityName.contains("ArtifactTarpitCheckView") || localEntityDefinition.fullEntityName.contains("DataFeedDocumentDetail")) // logger.warn("===== TOREMOVE ===== localEntityDefinition " + localEntityDefinition.fullEntityName + " isViewEntity " + localEntityDefinition.isViewEntity + " " + localEntityDefinition); if (localEntityDefinition.isViewEntity) { final MNode entityNode = localEntityDefinition.getEntityNode(); final MNode databaseNode = efi.getDatabaseNode(localEntityDefinition.getEntityGroupName()); String jsAttr = databaseNode.attribute("join-style"); final String joinStyle = jsAttr != null && jsAttr.length() > 0 ? jsAttr : "ansi"; if (!"ansi".equals(joinStyle) && !"ansi-no-parenthesis".equals(joinStyle)) { throw new BaseArtifactException("The join-style " + joinStyle + " is not supported, found on database " + databaseNode.attribute("name")); } boolean useParenthesis = "ansi".equals(joinStyle); ArrayList memberEntityNodes = entityNode.children("member-entity"); int memberEntityNodesSize = memberEntityNodes.size(); // get a list of all aliased fields selected or ordered by and don't bother joining in a member-entity // that is not selected or ordered by Set entityAliasUsedSet = new HashSet<>(); Set fieldUsedSet = new HashSet<>(); // add aliases used to fields used EntityConditionImplBase viewWhere = localEntityDefinition.makeViewWhereCondition(); if (viewWhere != null) viewWhere.getAllAliases(entityAliasUsedSet, fieldUsedSet); if (localWhereCondition != null) localWhereCondition.getAllAliases(entityAliasUsedSet, fieldUsedSet); if (localHavingCondition != null) localHavingCondition.getAllAliases(entityAliasUsedSet, fieldUsedSet); // logger.warn("SQL from viewWhere " + viewWhere + " localWhereCondition " + localWhereCondition + " localHavingCondition " + localHavingCondition); // logger.warn("SQL from fieldUsedSet " + fieldUsedSet + " additionalFieldsUsed " + additionalFieldsUsed); if (additionalFieldsUsed == null) { // add selected fields for (int i = 0; i < fieldInfoArray.length; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; fieldUsedSet.add(fi.name); } // add order by fields ArrayList orderByFields = entityFindBase.orderByFields; if (orderByFields != null) { int orderByFieldsSize = orderByFields.size(); for (int i = 0; i < orderByFieldsSize; i++) { String orderByField = orderByFields.get(i); EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(orderByField); fieldUsedSet.add(foo.getFieldName()); } } } else { // additional fields to look for, when this is a sub-select for a member-entity that is a view-entity fieldUsedSet.addAll(additionalFieldsUsed); } // get a list of entity aliases used for (String fieldName : fieldUsedSet) { FieldInfo fi = localEntityDefinition.getFieldInfo(fieldName); if (fi == null) throw new EntityException("Could not find field " + fieldName + " in entity " + localEntityDefinition.getFullEntityName()); entityAliasUsedSet.addAll(fi.entityAliasUsedSet); } // if (localEntityDefinition.getFullEntityName().contains("Example")) // logger.warn("============== entityAliasUsedSet=${entityAliasUsedSet} for entity ${localEntityDefinition.entityName}\n fieldUsedSet=${fieldUsedSet}\n fieldInfoList=${fieldInfoList}\n orderByFields=${entityFindBase.orderByFields}") // make sure each entityAlias in the entityAliasUsedSet links back to the main MNode memberEntityNode = null; for (int i = 0; i < memberEntityNodesSize; i++) { MNode curMeNode = memberEntityNodes.get(i); String jfa = curMeNode.attribute("join-from-alias"); if (jfa == null || jfa.length() == 0) { memberEntityNode = curMeNode; break; } } String mainEntityAlias = memberEntityNode != null ? memberEntityNode.attribute("entity-alias") : null; Set entityAliasesJoinedInSet = new HashSet<>(); if (mainEntityAlias != null) entityAliasesJoinedInSet.add(mainEntityAlias); for (String entityAlias : new HashSet<>(entityAliasUsedSet)) { expandJoinFromAlias(entityNode, entityAlias, entityAliasUsedSet, entityAliasesJoinedInSet); } // logger.warn("============== entityAliasUsedSet=${entityAliasUsedSet} for entity ${localEntityDefinition.entityName}\nfieldUsedSet=${fieldUsedSet}\n fieldInfoList=${fieldInfoList}\n orderByFields=${entityFindBase.orderByFields}") // at this point entityAliasUsedSet is finalized so do authz filter if needed ArrayList filterCondList = efi.ecfi.getEci().artifactExecutionFacade.filterFindForUser(localEntityDefinition, entityAliasUsedSet); outWhereCondition = EntityConditionFactoryImpl.addAndListToCondition(outWhereCondition, filterCondList); // keep a set of all aliases in the join so far and if the left entity alias isn't there yet, and this // isn't the first one, throw an exception final Set joinedAliasSet = new TreeSet<>(); // on initial pass only add opening parenthesis since easier than going back and inserting them, then insert the rest boolean isFirst = true; boolean fromEmpty = true; for (int meInd = 0; meInd < memberEntityNodesSize; meInd++) { MNode relatedMemberEntityNode = memberEntityNodes.get(meInd); String entityAlias = relatedMemberEntityNode.attribute("entity-alias"); final String joinFromAlias = relatedMemberEntityNode.attribute("join-from-alias"); // logger.warn("=================== joining member-entity ${relatedMemberEntity}") // if this isn't joined in skip it (should be first one only); the first is handled below if (joinFromAlias == null || joinFromAlias.length() == 0) continue; // if entity alias not used don't join it in if (!entityAliasUsedSet.contains(entityAlias)) continue; if (!entityAliasUsedSet.contains(joinFromAlias)) continue; if (isFirst && useParenthesis) localBuilder.append("("); // adding to from, then it's not empty fromEmpty = false; MNode linkMemberNode = null; for (int i = 0; i < memberEntityNodesSize; i++) { MNode curMeNode = memberEntityNodes.get(i); if (joinFromAlias.equals(curMeNode.attribute("entity-alias"))) { linkMemberNode = curMeNode; break; } } String linkEntityName = linkMemberNode != null ? linkMemberNode.attribute("entity-name") : null; EntityDefinition linkEntityDefinition = efi.getEntityDefinition(linkEntityName); String relatedLinkEntityName = relatedMemberEntityNode.attribute("entity-name"); EntityDefinition relatedLinkEntityDefinition = efi.getEntityDefinition(relatedLinkEntityName); if (isFirst) { // first link, add link entity for this one only, for others add related link entity outWhereCondition = makeSqlViewTableName(linkEntityDefinition, localBuilder, outWhereCondition, localHavingCondition); localBuilder.append(" ").append(joinFromAlias); joinedAliasSet.add(joinFromAlias); } else { // make sure the left entity alias is already in the join... if (!joinedAliasSet.contains(joinFromAlias)) { logger.error("For view-entity [" + localEntityDefinition.fullEntityName + "] found member-entity with @join-from-alias [" + joinFromAlias + "] that is not in the joinedAliasSet: " + joinedAliasSet + "; view-entity Node: " + entityNode); throw new EntityException("Tried to link the " + entityAlias + " alias to the " + joinFromAlias + " alias of the " + localEntityDefinition.fullEntityName + " view-entity, but it is not the first member-entity and has not been joined to a previous member-entity. In other words, the left/main alias isn't connected to the rest of the member-entities yet."); } } // now put the rel (right) entity alias into the set that is in the join joinedAliasSet.add(entityAlias); String fromLateralStyle = "none"; String subSelectAttr = relatedMemberEntityNode.attribute("sub-select"); boolean subSelect = "true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr); if ("true".equals(subSelectAttr)) { fromLateralStyle = databaseNode.attribute("from-lateral-style"); if (fromLateralStyle == null || fromLateralStyle.isEmpty()) fromLateralStyle = "none"; } boolean isLateralStyle = "lateral".equals(fromLateralStyle); boolean isApplyStyle = "apply".equals(fromLateralStyle); if (isApplyStyle) logger.warn("from-lateral-style=apply not yet supported, using non-lateral join for sub-select in " + localEntityDefinition.getFullEntityName()); // TODO: for isApplyStyle need to use CROSS APPLY or OUTER APPLY for join-optional=true INSTEAD of [INNER|OUTER LEFT] JOIN in calling code if ("true".equals(relatedMemberEntityNode.attribute("join-optional"))) { localBuilder.append(" LEFT OUTER JOIN "); } else { localBuilder.append(" INNER JOIN "); } if (subSelect) { makeSqlMemberSubSelect(entityAlias, relatedMemberEntityNode, relatedLinkEntityDefinition, linkEntityDefinition, localBuilder); } else { outWhereCondition = makeSqlViewTableName(relatedLinkEntityDefinition, localBuilder, outWhereCondition, localHavingCondition); } localBuilder.append(" ").append(entityAlias); // TODO: for isApplyStyle skip ON clause completely localBuilder.append(" ON "); if (isLateralStyle) { localBuilder.append("1=1"); } else { appendJoinConditions(relatedMemberEntityNode, entityAlias, localEntityDefinition, linkEntityDefinition, relatedLinkEntityDefinition, localBuilder); } isFirst = false; } if (!fromEmpty && useParenthesis) localBuilder.append(")"); // handle member-entities not referenced in any member-entity.@join-from-alias attribute for (int meInd = 0; meInd < memberEntityNodesSize; meInd++) { MNode memberEntity = memberEntityNodes.get(meInd); String memberEntityAlias = memberEntity.attribute("entity-alias"); // if entity alias not used don't join it in if (!entityAliasUsedSet.contains(memberEntityAlias)) continue; if (joinedAliasSet.contains(memberEntityAlias)) continue; EntityDefinition fromEntityDefinition = efi.getEntityDefinition(memberEntity.attribute("entity-name")); if (fromEmpty) { fromEmpty = false; } else { localBuilder.append(", "); } String subSelectAttr = memberEntity.attribute("sub-select"); if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) { makeSqlMemberSubSelect(memberEntityAlias, memberEntity, fromEntityDefinition, null, localBuilder); } else { outWhereCondition = makeSqlViewTableName(fromEntityDefinition, localBuilder, outWhereCondition, localHavingCondition); } localBuilder.append(" ").append(memberEntityAlias); } } else { // not a view-entity so do authz filter now if needed ArrayList filterCondList = efi.ecfi.getEci().artifactExecutionFacade.filterFindForUser(localEntityDefinition, null); outWhereCondition = EntityConditionFactoryImpl.addAndListToCondition(outWhereCondition, filterCondList); localBuilder.append(localEntityDefinition.getFullTableName()); } return outWhereCondition; } public void appendJoinConditions(MNode relatedMemberEntityNode, String entityAlias, EntityDefinition localEntityDefinition, EntityDefinition linkEntityDefinition, EntityDefinition relatedLinkEntityDefinition, StringBuilder localBuilder) { final String joinFromAlias = relatedMemberEntityNode.attribute("join-from-alias"); String subSelectAttr = relatedMemberEntityNode.attribute("sub-select"); boolean subSelect = "true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr); ArrayList keyMaps = relatedMemberEntityNode.children("key-map"); ArrayList entityConditionList = relatedMemberEntityNode.children("entity-condition"); if ((keyMaps == null || keyMaps.size() == 0) && (entityConditionList == null || entityConditionList.size() == 0)) { throw new EntityException("No member-entity/join key-maps found for the " + joinFromAlias + " and the " + entityAlias + " member-entities of the " + localEntityDefinition.fullEntityName + " view-entity."); } int keyMapsSize = keyMaps != null ? keyMaps.size() : 0; for (int i = 0; i < keyMapsSize; i++) { MNode keyMap = keyMaps.get(i); String joinFromField = keyMap.attribute("field-name"); if (i > 0) localBuilder.append(" AND "); ArrayList aliasNodes = localEntityDefinition.getEntityNode().children("alias"); MNode outerAliasNode = null; for (int ai = 0; ai < aliasNodes.size(); ai++) { MNode curAliasNode = aliasNodes.get(ai); if (joinFromAlias.equals(curAliasNode.attribute("entity-alias"))) { // must match field name String curFieldName = curAliasNode.attribute("field"); if (curFieldName == null || curFieldName.isEmpty()) curFieldName = curAliasNode.attribute("name"); // must not have a function (not valid in JOIN ON clause) String curFunction = curAliasNode.attribute("function"); if (joinFromField.equals(curFieldName) && (curFunction == null || curFunction.isEmpty())) { outerAliasNode = curAliasNode; break; } } } if (outerAliasNode != null) { localBuilder.append(localEntityDefinition.getColumnName(outerAliasNode.attribute("name"))); } else { localBuilder.append(joinFromAlias).append("."); localBuilder.append(linkEntityDefinition.getColumnName(joinFromField)); } localBuilder.append(" = "); final String relatedAttr = keyMap.attribute("related"); String relatedFieldName = relatedAttr != null && !relatedAttr.isEmpty() ? relatedAttr : keyMap.attribute("related-field-name"); if (relatedFieldName == null || relatedFieldName.length() == 0) relatedFieldName = keyMap.attribute("field-name"); if (!relatedLinkEntityDefinition.isField(relatedFieldName) && relatedLinkEntityDefinition.getPkFieldNames().size() == 1 && keyMaps.size() == 1) { relatedFieldName = relatedLinkEntityDefinition.getPkFieldNames().get(0); // if we don't match these constraints and get this default we'll get an error later... } if (entityAlias != null && !entityAlias.isEmpty()) localBuilder.append(entityAlias).append("."); FieldInfo relatedFieldInfo = relatedLinkEntityDefinition.getFieldInfo(relatedFieldName); if (relatedFieldInfo == null) throw new EntityException("Invalid field name " + relatedFieldName + " for entity " + relatedLinkEntityDefinition.fullEntityName); if (subSelect && entityAlias != null && !entityAlias.isEmpty()) { localBuilder.append(EntityJavaUtil.camelCaseToUnderscored(relatedFieldInfo.name)); } else { localBuilder.append(relatedFieldInfo.getFullColumnName()); } // NOTE: sanitizeColumnName here breaks the generated SQL, in the case of a view within a view we want EAO.EAI.COL_NAME... // localBuilder.append(sanitizeColumnName(relatedLinkEntityDefinition.getColumnName(relatedFieldName, false))) } if (entityConditionList != null && entityConditionList.size() > 0) { // add any additional manual conditions for the member-entity view link here MNode entityCondition = entityConditionList.get(0); // logger.warn("======== appendJoinConditions() localEntityDefinition " + localEntityDefinition.fullEntityName + " linkEntityDefinition " + linkEntityDefinition.fullEntityName + " relatedLinkEntityDefinition " + relatedLinkEntityDefinition.fullEntityName); EntityConditionImplBase linkEcib = localEntityDefinition.makeViewListCondition(entityCondition, relatedMemberEntityNode); if (keyMapsSize > 0) localBuilder.append(" AND "); // TODO: does this need to use localBuilder? seems to be working so far... linkEcib.makeSqlWhere(this, null); } } public EntityConditionImplBase makeSqlViewTableName(EntityDefinition localEntityDefinition, StringBuilder localBuilder, EntityConditionImplBase localWhereCondition, EntityConditionImplBase localHavingCondition) { EntityJavaUtil.EntityInfo entityInfo = localEntityDefinition.entityInfo; EntityConditionImplBase outWhereCondition = localWhereCondition; if (entityInfo.isView) { localBuilder.append("(SELECT "); // fields used for group by clause Set localFieldsToSelect = new HashSet<>(); // additional fields to consider when trimming the member-entities to join Set additionalFieldsUsed = new HashSet<>(); ArrayList aliasList = localEntityDefinition.getEntityNode().children("alias"); int aliasListSize = aliasList.size(); for (int i = 0; i < aliasListSize; i++) { MNode aliasNode = aliasList.get(i); String aliasName = aliasNode.attribute("name"); String aliasField = aliasNode.attribute("field"); if (aliasField == null || aliasField.length() == 0) aliasField = aliasName; localFieldsToSelect.add(aliasName); additionalFieldsUsed.add(aliasField); if (i > 0) localBuilder.append(", "); localBuilder.append(localEntityDefinition.getColumnName(aliasName)); // TODO: are the next two lines really needed? have removed AS stuff elsewhere since it is not commonly used and not needed //localBuilder.append(" AS ") //localBuilder.append(sanitizeColumnName(localEntityDefinition.getColumnName(aliasName), false))) } // pass through localWhereCondition in case changed outWhereCondition = makeSqlFromClause(localEntityDefinition, localBuilder, localWhereCondition, localHavingCondition, additionalFieldsUsed); // TODO: refactor this like below to do in the main loop; this is currently unused though (view-entity as member-entity for sub-select) StringBuilder gbClause = new StringBuilder(); if (entityInfo.hasFunctionAlias) { // do a different approach to GROUP BY: add all fields that are selected and don't have a function for (int i = 0; i < aliasListSize; i++) { MNode aliasNode = aliasList.get(i); String nameAttr = aliasNode.attribute("name"); String functionAttr = aliasNode.attribute("function"); String isAggregateAttr = aliasNode.attribute("is-aggregate"); boolean isAggFunction = isAggregateAttr != null ? "true".equalsIgnoreCase(isAggregateAttr) : FieldInfo.aggFunctions.contains(functionAttr); if (localFieldsToSelect.contains(nameAttr) && !isAggFunction) { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(localEntityDefinition.getColumnName(nameAttr)); } } } if (gbClause.length() > 0) { localBuilder.append(" GROUP BY "); localBuilder.append(gbClause.toString()); } localBuilder.append(")"); } else { localBuilder.append(localEntityDefinition.getFullTableName()); } return outWhereCondition; } public void makeSqlMemberSubSelect(String entityAlias, MNode memberEntity, EntityDefinition localEntityDefinition, EntityDefinition linkEntityDefinition, StringBuilder localBuilder) { String fromLateralStyle = "none"; if ("true".equals(memberEntity.attribute("sub-select"))) { final MNode databaseNode = efi.getDatabaseNode(localEntityDefinition.getEntityGroupName()); fromLateralStyle = databaseNode.attribute("from-lateral-style"); if (fromLateralStyle == null || fromLateralStyle.isEmpty()) fromLateralStyle = "none"; } boolean isLateralStyle = "lateral".equals(fromLateralStyle); boolean isApplyStyle = "apply".equals(fromLateralStyle); if (isLateralStyle) localBuilder.append(" LATERAL "); localBuilder.append("(SELECT "); // add any fields needed to join this to another member-entity, even if not in the main set of selected fields TreeSet joinFields = new TreeSet<>(); ArrayList keyMapList = memberEntity.children("key-map"); for (int i = 0; i < keyMapList.size(); i++) { MNode keyMap = keyMapList.get(i); String relFn = keyMap.attribute("related"); if (relFn == null || relFn.isEmpty()) relFn = keyMap.attribute("field-name"); joinFields.add(relFn); } ArrayList entityConditionList = memberEntity.children("entity-condition"); if (entityConditionList != null && entityConditionList.size() > 0) { MNode entCondNode = entityConditionList.get(0); ArrayList econdNodes = entCondNode.descendants("econdition"); for (int i = 0; i < econdNodes.size(); i++) { MNode econd = econdNodes.get(i); if (entityAlias.equals(econd.attribute("entity-alias"))) joinFields.add(econd.attribute("field-name")); if (entityAlias.equals(econd.attribute("to-entity-alias"))) joinFields.add(econd.attribute("to-field-name")); } } EntityConditionImplBase viewCondition = null; ArrayList viewEntityConditionList = localEntityDefinition.getEntityNode().children("entity-condition"); if (viewEntityConditionList != null && viewEntityConditionList.size() > 0) { MNode entCondNode = viewEntityConditionList.get(0); viewCondition = localEntityDefinition.makeViewListCondition(entCondNode, null); } // additional fields to consider when trimming the member-entities to join Set additionalFieldsUsed = new HashSet<>(); boolean hasAggregateFunction = false; boolean hasSelected = false; StringBuilder gbClause = new StringBuilder(); for (int i = 0; i < fieldInfoArray.length; i++) { FieldInfo aliasFi = fieldInfoArray[i]; if (!aliasFi.entityAliasUsedSet.contains(entityAlias)) continue; if (localEntityDefinition.isViewEntity) { // get the outer alias node String outerAliasField = aliasFi.aliasFieldName; // get the local entity (sub-select) field node (may be alias node if sub-select on view-entity) FieldInfo localFi = localEntityDefinition.getFieldInfo(outerAliasField); MNode aliasNode = aliasFi.fieldNode; MNode complexAliasNode = aliasNode.first("complex-alias"); if (complexAliasNode != null) { boolean foundOtherEntityAlias = false; ArrayList complexAliasFields = complexAliasNode.descendants("complex-alias-field"); for (int cafIdx = 0; cafIdx < complexAliasFields.size(); cafIdx++) { MNode cafNode = complexAliasFields.get(cafIdx); if (entityAlias.equals(cafNode.attribute("entity-alias"))) { String cafField = cafNode.attribute("field"); additionalFieldsUsed.add(cafField); joinFields.remove(cafField); } else { foundOtherEntityAlias = true; } } if (!foundOtherEntityAlias) { if (localFi == null) throw new EntityException("Could not find field " + outerAliasField + " on entity " + entityAlias + ":" + localEntityDefinition.fullEntityName); String colName = localFi.getFullColumnName(); if (hasSelected) { localBuilder.append(", "); } else { hasSelected = true; } localBuilder.append(colName).append(" AS ").append(EntityJavaUtil.camelCaseToUnderscored(localFi.name)); if (localFi.hasAggregateFunction) { hasAggregateFunction = true; } else { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(EntityJavaUtil.camelCaseToUnderscored(localFi.name)); } // } else { // if we found another entity alias not all on this sub-select entity (or view-entity) // TODO only select part that is - IFF not already selected to make sure is selected for outer select } } else { if (localFi == null) throw new EntityException("Could not find field " + outerAliasField + " on entity " + entityAlias + ":" + localEntityDefinition.fullEntityName); additionalFieldsUsed.add(localFi.name); joinFields.remove(localFi.name); if (hasSelected) { localBuilder.append(", "); } else { hasSelected = true; } localBuilder.append(localFi.getFullColumnName()).append(" AS ").append(EntityJavaUtil.camelCaseToUnderscored(localFi.name)); if (localFi.hasAggregateFunction) { hasAggregateFunction = true; } else { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(EntityJavaUtil.camelCaseToUnderscored(localFi.name)); } } } else { MNode aliasNode = aliasFi.fieldNode; String aliasName = aliasFi.name; String aliasField = aliasNode.attribute("field"); if (aliasField == null || aliasField.isEmpty()) aliasField = aliasName; additionalFieldsUsed.add(aliasField); joinFields.remove(aliasField); if (hasSelected) { localBuilder.append(", "); } else { hasSelected = true; } // NOTE: this doesn't support various things that EntityDefinition.makeFullColumnName() does like case/when, complex-alias, etc // those are difficult to pick out in nested XML elements where the 'alias' element has no entity-alias, and may not be needed at this level (try to handle at top level) String function = aliasNode.attribute("function"); String isAggregateAttr = aliasNode.attribute("is-aggregate"); boolean isAggFunction = isAggregateAttr != null ? "true".equalsIgnoreCase(isAggregateAttr) : FieldInfo.aggFunctions.contains(function); hasAggregateFunction = hasAggregateFunction || isAggFunction; MNode complexAliasNode = aliasNode.first("complex-alias"); if (complexAliasNode != null) { String colName = mainEntityDefinition.makeFullColumnName(aliasNode, false); localBuilder.append(colName).append(" AS ").append(EntityJavaUtil.camelCaseToUnderscored(aliasName)); if (!isAggFunction) { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(sanitizeColumnName(colName)); } } else if (function != null && !function.isEmpty()) { String colName = EntityDefinition.getFunctionPrefix(function) + localEntityDefinition.getColumnName(aliasField) + ")"; localBuilder.append(colName).append(" AS ").append(EntityJavaUtil.camelCaseToUnderscored(aliasName)); if (!isAggFunction) { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(sanitizeColumnName(colName)); } } else { String colName = localEntityDefinition.getColumnName(aliasField); localBuilder.append(colName); if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(colName); } } } // do the actual add of join field columns to select and group by // TODO: for isApplyStyle also don't do this if (!isLateralStyle) { for (String joinField : joinFields) { if (hasSelected) { localBuilder.append(", "); } else { hasSelected = true; } String asName = EntityJavaUtil.camelCaseToUnderscored(joinField); String colName = localEntityDefinition.getColumnName(joinField); localBuilder.append(colName).append(" AS ").append(asName); if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(colName); if (localEntityDefinition.isViewEntity) additionalFieldsUsed.add(joinField); } } // where condition to use for FROM clause (field filtering) and for sub-select WHERE clause EntityConditionImplBase condition = whereCondition != null ? whereCondition.filter(entityAlias, mainEntityDefinition) : null; condition = EntityConditionFactoryImpl.makeConditionImpl(condition, EntityCondition.AND, viewCondition); // logger.warn("makeSqlMemberSubSelect SQL so far " + localBuilder.toString()); // logger.warn("Calling makeSqlFromClause for " + entityAlias + ":" + localEntityDefinition.getEntityName() + " condition " + condition); // logger.warn("Calling makeSqlFromClause for " + entityAlias + ":" + localEntityDefinition.getEntityName() + " addtl fields " + additionalFieldsUsed); condition = makeSqlFromClause(localEntityDefinition, localBuilder, condition, null, additionalFieldsUsed); // add where clause, just for conditions on aliased fields on this entity-alias // TODO: for isApplyStyle also do this if (condition != null || isLateralStyle) localBuilder.append(" WHERE "); // TODO: for isApplyStyle also do this if (isLateralStyle) { // TODO how to get this... is per field on inner/local view entity? probably should be otherwise all fields that join to outer view must be on first member-entity String joinToIeThisAlias = localEntityDefinition.isViewEntity ? null : localEntityDefinition.getTableName(); appendJoinConditions(memberEntity, joinToIeThisAlias, localEntityDefinition, linkEntityDefinition, localEntityDefinition, localBuilder); if (condition != null) localBuilder.append(" AND "); } if (condition != null) { // TODO: does this need to use localBuilder? seems to be working so far... condition.makeSqlWhere(this, localEntityDefinition); } if (hasAggregateFunction && gbClause.length() > 0) { localBuilder.append(" GROUP BY "); localBuilder.append(gbClause.toString()); } localBuilder.append(")"); } public void makeWhereClause() { if (whereCondition == null) return; EntityConditionImplBase condition = whereCondition; if (mainEntityDefinition.hasSubSelectMembers) { condition = condition.filter(null, mainEntityDefinition); if (condition == null) return; } sqlTopLevel.append(" WHERE "); condition.makeSqlWhere(this, null); } public void makeGroupByClause() { EntityJavaUtil.EntityInfo entityInfo = mainEntityDefinition.entityInfo; if (!entityInfo.isView) return; StringBuilder gbClause = new StringBuilder(); if (entityInfo.hasFunctionAlias) { // do a different approach to GROUP BY: add all fields that are selected and don't have a function or that are in a sub-select for (int j = 0; j < fieldInfoArray.length; j++) { FieldInfo fi = fieldInfoArray[j]; if (fi == null) continue; boolean doGroupBy = !fi.hasAggregateFunction; if (fi.hasAggregateFunction && fi.memberEntityNode != null) { String subSelectAttr = fi.memberEntityNode.attribute("sub-select"); if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) { // TODO we have a sub-select, if it is on a non-view entity we want to group by (on a view-entity would be only if no aggregate in wrapping alias) EntityDefinition fromEntityDefinition = efi.getEntityDefinition(fi.memberEntityNode.attribute("entity-name")); if (!fromEntityDefinition.isViewEntity) doGroupBy = true; } } if (doGroupBy) { if (gbClause.length() > 0) gbClause.append(", "); gbClause.append(fi.getFullColumnName()); } } } if (gbClause.length() > 0) { sqlTopLevel.append(" GROUP BY "); sqlTopLevel.append(gbClause.toString()); } } public void makeHavingClause(EntityConditionImplBase condition) { if (condition == null) return; sqlTopLevel.append(" HAVING "); condition.makeSqlWhere(this, null); } public void makeOrderByClause(ArrayList orderByFieldList, boolean hasLimitOffset) { int obflSize = orderByFieldList.size(); if (obflSize == 0) { if (hasLimitOffset) sqlTopLevel.append(" ORDER BY 1"); return; } MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName()); sqlTopLevel.append(" ORDER BY "); for (int i = 0; i < obflSize; i++) { String fieldName = orderByFieldList.get(i); if (fieldName == null || fieldName.length() == 0) continue; if (i > 0) sqlTopLevel.append(", "); // Parse the fieldName (can have other stuff in it, need to tear down to just the field name) EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(fieldName); fieldName = foo.getFieldName(); FieldInfo fieldInfo = getMainEd().getFieldInfo(fieldName); if (fieldInfo == null) throw new EntityException("Making ORDER BY clause, could not find field " + fieldName + " in entity " + getMainEd().fullEntityName); int typeValue = fieldInfo.typeValue; // now that it's all torn down, build it back up using the column name if (foo.getCaseUpperLower() != null && typeValue == 1) sqlTopLevel.append(foo.getCaseUpperLower() ? "UPPER(" : "LOWER("); sqlTopLevel.append(fieldInfo.getFullColumnName()); if (foo.getCaseUpperLower() != null && typeValue == 1) sqlTopLevel.append(")"); sqlTopLevel.append(foo.getDescending() ? " DESC" : " ASC"); if (!"true".equals(databaseNode.attribute("never-nulls"))) { if (foo.getNullsFirstLast() != null) sqlTopLevel.append(foo.getNullsFirstLast() ? " NULLS FIRST" : " NULLS LAST"); else sqlTopLevel.append(" NULLS LAST"); } } } public void addLimitOffset(Integer limit, Integer offset) { if (limit == null && offset == null) return; MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName()); // if no databaseNode do nothing, means it is not a standard SQL/JDBC database if (databaseNode != null) { String offsetStyle = databaseNode.attribute("offset-style"); if ("limit".equals(offsetStyle)) { // use the LIMIT/OFFSET style sqlTopLevel.append(" LIMIT ").append(limit != null && limit > 0 ? limit : "ALL"); sqlTopLevel.append(" OFFSET ").append(offset != null ? offset : 0); } else if (offsetStyle == null || offsetStyle.length() == 0 || "fetch".equals(offsetStyle)) { // use SQL2008 OFFSET/FETCH style by default sqlTopLevel.append(" OFFSET ").append(offset != null ? offset.toString() : '0').append(" ROWS"); if (limit != null) sqlTopLevel.append(" FETCH FIRST ").append(limit).append(" ROWS ONLY"); } // do nothing here for offset-style=cursor, taken care of in EntityFindImpl } } /** Adds FOR UPDATE, should be added to end of query */ public void makeForUpdate() { MNode databaseNode = efi.getDatabaseNode(mainEntityDefinition.getEntityGroupName()); String forUpdateStr = databaseNode.attribute("for-update"); if (forUpdateStr != null && forUpdateStr.length() > 0) { sqlTopLevel.append(" ").append(forUpdateStr); } else { sqlTopLevel.append(" FOR UPDATE"); } } @Override public PreparedStatement makePreparedStatement() { if (connection == null) throw new IllegalStateException("Cannot make PreparedStatement, no Connection in place"); finalSql = sqlTopLevel.toString(); // if (this.mainEntityDefinition.getEntityName().contains("FooBar")) logger.warn("========= making find PreparedStatement for SQL: " + finalSql + "; parameters: " + parameters); if (isDebugEnabled) logger.debug("making find PreparedStatement for SQL: " + finalSql); try { ps = connection.prepareStatement(finalSql, entityFindBase.getResultSetType(), entityFindBase.getResultSetConcurrency()); Integer maxRows = entityFindBase.getMaxRows(); Integer fetchSize = entityFindBase.getFetchSize(); if (maxRows != null && maxRows > 0) ps.setMaxRows(maxRows); // NOTE: always set a fetch size, without explicit fetch size some JDBC drivers (like MySQL Connector/J) will try to fetch all rows // NOTE: the default here of 1000 is a balance between memory use and network overhead, 100 rows generally being easy to accommodate if (fetchSize != null && fetchSize > 0) { ps.setFetchSize(fetchSize); } else { ps.setFetchSize(100); } } catch (SQLException e) { EntityQueryBuilder.handleSqlException(e, finalSql); } return ps; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityFindImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.entity.EntityDynamicView; import org.moqui.entity.EntityListIterator; import org.moqui.impl.entity.condition.EntityConditionImplBase; import org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions; import org.moqui.util.LiteStringMap; import org.moqui.util.MNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Map; public class EntityFindImpl extends EntityFindBase { protected static final Logger logger = LoggerFactory.getLogger(EntityFindImpl.class); protected static final boolean isTraceEnabled = logger.isTraceEnabled(); public EntityFindImpl(EntityFacadeImpl efi, String entityName) { super(efi, entityName); } public EntityFindImpl(EntityFacadeImpl efi, EntityDefinition ed) { super(efi, ed); } @Override public EntityDynamicView makeEntityDynamicView() { if (this.dynamicView != null) return this.dynamicView; this.entityDef = null; this.dynamicView = new EntityDynamicViewImpl(this); return this.dynamicView; } @Override public EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException { EntityDefinition ed = getEntityDef(); // table doesn't exist, just return null if (!ed.tableExistsDbMetaOnly()) return null; EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray); // flag as a find one, small changes to internal behavior to reduce overhead efb.isFindOne(); // SELECT fields efb.makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, "true".equals(efi.getDatabaseNode(ed.groupName).attribute("add-unique-as"))); // FROM Clause efb.makeSqlFromClause(); // WHERE clause only for one/pk query efb.makeWhereClause(); // GROUP BY clause efb.makeGroupByClause(); // NOTE 20200707 don't do this, databases such as Oracle (error ORA-02014) do not allow use of limit/offset with for update: LIMIT/OFFSET clause - for find one always limit to 1: efb.addLimitOffset(1, 0); // FOR UPDATE if (getForUpdate()) efb.makeForUpdate(); // run the SQL now that it is built EntityValueBase newEntityValue = null; try { // don't check create, above tableExists check is done: // efi.getEntityDbMeta().checkTableRuntime(ed) // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed); efb.makeConnection(useClone); efb.makePreparedStatement(); efb.setPreparedStatementValues(); final String condSql = isTraceEnabled && whereCondition != null ? whereCondition.toString() : null; ResultSet rs = efb.executeQuery(); if (rs.next()) { newEntityValue = new EntityValueImpl(ed, efi); LiteStringMap valueMap = newEntityValue.valueMapInternal; int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; fi.getResultSetValue(rs, i + 1, valueMap, efi); } } else { if (isTraceEnabled) logger.trace("Result set was empty for find on entity " + entityName + " with condition " + condSql); } if (isTraceEnabled && rs.next()) logger.trace("Found more than one result for condition " + condSql + " on entity " + entityName); queryTextList.add(efb.finalSql); } finally { try { efb.closeAll(); } catch (SQLException sqle) { logger.error("Error closing query", sqle); } } return newEntityValue; } @Override public EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, ArrayList orderByExpanded, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException { EntityDefinition ed = this.getEntityDef(); // table doesn't exist, just return empty ELI if (!ed.tableExistsDbMetaOnly()) return new EntityListIteratorWrapper(new ArrayList<>(), ed, efi, null, null); EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray); if (getDistinct()) efb.makeDistinct(); // select fields efb.makeSqlSelectFields(fieldInfoArray, fieldOptionsArray, "true".equals(efi.getDatabaseNode(ed.groupName).attribute("add-unique-as"))); // FROM Clause efb.makeSqlFromClause(); // WHERE clause efb.makeWhereClause(); // GROUP BY clause efb.makeGroupByClause(); // HAVING clause efb.makeHavingClause(havingCondition); boolean hasLimitOffset = limit != null || offset != null; // ORDER BY clause efb.makeOrderByClause(orderByExpanded, hasLimitOffset); // LIMIT/OFFSET clause if (hasLimitOffset) efb.addLimitOffset(limit, offset); // FOR UPDATE if (getForUpdate()) efb.makeForUpdate(); // run the SQL now that it is built EntityListIteratorImpl elii; try { // don't check create, above tableExists check is done: // efi.getEntityDbMeta().checkTableRuntime(ed) // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed); Connection con = efb.makeConnection(useClone); efb.makePreparedStatement(); efb.setPreparedStatementValues(); ResultSet rs = efb.executeQuery(); elii = new EntityListIteratorImpl(con, rs, ed, fieldInfoArray, efi, txCache, whereCondition, orderByExpanded); // ResultSet will be closed in the EntityListIterator efb.releaseAll(); queryTextList.add(efb.finalSql); } catch (Throwable t) { // close the ResultSet/etc on error as there won't be an ELI try { efb.closeAll(); } catch (SQLException sqle) { logger.error("Error closing query", sqle); } throw t; } // no finally block to close ResultSet, etc because contained in EntityListIterator and closed with it return elii; } @Override public long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray) throws SQLException { EntityDefinition ed = getEntityDef(); // table doesn't exist, just return 0 if (!ed.tableExistsDbMetaOnly()) return 0; EntityFindBuilder efb = new EntityFindBuilder(ed, this, whereCondition, fieldInfoArray); ArrayList entityConditionList = ed.internalEntityNode.children("entity-condition"); MNode condNode = entityConditionList != null && entityConditionList.size() > 0 ? entityConditionList.get(0) : null; boolean isDistinct = getDistinct() || (ed.isViewEntity && condNode != null && "true".equals(condNode.attribute("distinct"))); boolean isGroupBy = ed.entityInfo.hasFunctionAlias; // count function instead of select fields efb.makeCountFunction(fieldOptionsArray, isDistinct, isGroupBy); // FROM Clause efb.makeSqlFromClause(); // WHERE clause efb.makeWhereClause(); // GROUP BY clause efb.makeGroupByClause(); // HAVING clause efb.makeHavingClause(havingCondition); efb.closeCountSubSelect(fieldInfoArray.length, isDistinct, isGroupBy); // run the SQL now that it is built long count = 0; try { // don't check create, above tableExists check is done: // efi.getEntityDbMeta().checkTableRuntime(ed) // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed); efb.makeConnection(useClone); efb.makePreparedStatement(); efb.setPreparedStatementValues(); ResultSet rs = efb.executeQuery(); if (rs.next()) count = rs.getLong(1); queryTextList.add(efb.finalSql); } finally { try { efb.closeAll(); } catch (SQLException sqle) { logger.error("Error closing query", sqle); } } return count; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityJavaUtil.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.BaseException; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityDatasourceFactory; import org.moqui.entity.EntityException; import org.moqui.entity.EntityNotFoundException; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.util.MNode; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.crypto.Cipher; import javax.crypto.SecretKey; import javax.crypto.SecretKeyFactory; import javax.crypto.spec.IvParameterSpec; import javax.crypto.spec.PBEKeySpec; import javax.crypto.spec.PBEParameterSpec; import jakarta.xml.bind.DatatypeConverter; import java.math.BigDecimal; import java.security.SecureRandom; import java.util.*; public class EntityJavaUtil { protected final static Logger logger = LoggerFactory.getLogger(EntityJavaUtil.class); protected final static boolean isTraceEnabled = logger.isTraceEnabled(); private static final int saltBytes = 8; static String enDeCrypt(String value, boolean encrypt, EntityFacadeImpl efi) { MNode entityFacadeNode = efi.ecfi.getConfXmlRoot().first("entity-facade"); if (encrypt) { return enDeCrypt(value, true, entityFacadeNode); } else { // decrypt a bit different, use entity-facade as config node and then decrypt-alt until success, or fail with original error try { return enDeCrypt(value, false, entityFacadeNode); } catch (Exception e) { ArrayList decryptAltNodes = entityFacadeNode.children("decrypt-alt"); for (int i = 0; i < decryptAltNodes.size(); i++) { MNode decryptAltNode = decryptAltNodes.get(i); decryptAltNode.setSystemExpandAttributes(true); try { return enDeCrypt(value, false, decryptAltNode); } catch (Exception inner) { // do nothing, ignore exception logger.warn("Error in decrypt-alt " + i); } } // if we got here no luck, throw original exception throw e; } } } static final String CONSTANT_IV = "WeNeedAtLeast32CharactersFor256BitBlockSizeToHaveAConstantIVForQueryByEncryptedValue"; static String enDeCrypt(String value, boolean encrypt, MNode configNode) { String pwStr = configNode.attribute("crypt-pass"); if (pwStr == null || pwStr.length() == 0) throw new EntityException("No entity-facade.@crypt-pass setting found, NOT doing encryption"); String saltStr = configNode.attribute("crypt-salt"); byte[] salt = (saltStr != null && saltStr.length() > 0 ? saltStr : "default1").getBytes(); if (salt.length > saltBytes) { byte[] trimmed = new byte[saltBytes]; System.arraycopy(salt, 0, trimmed, 0, saltBytes); salt = trimmed; } if (salt.length < saltBytes) { byte[] newSalt = new byte[saltBytes]; for (int i = 0; i < saltBytes; i++) { if (i < salt.length) newSalt[i] = salt[i]; else newSalt[i] = 0x45; } salt = newSalt; } String iterStr = configNode.attribute("crypt-iter"); int count = iterStr != null && iterStr.length() > 0 ? Integer.valueOf(iterStr) : 10; char[] pass = pwStr.toCharArray(); String algo = configNode.attribute("crypt-algo"); if (algo == null || algo.length() == 0) algo = "PBEWithHmacSHA256AndAES_128"; // logger.info("TOREMOVE salt [" + salt + "] count [" + count + "] pass [${pass}] algo [" + algo + "][" + configNode.attribute("crypt-algo") + "]"); try { Cipher pbeCipher = Cipher.getInstance(algo); byte[] inBytes; byte[] initVectorBytes = CONSTANT_IV.substring(0, pbeCipher.getBlockSize()).getBytes(); byte[] defaultInitVectorBytes = initVectorBytes; if (encrypt) { inBytes = value.getBytes(); /* more secure for larger multi-block values, but makes find by encrypted value impossible, maybe optionally enable with another field.@encrypt attribute if ever needed initVectorBytes = new byte[pbeCipher.getBlockSize()]; new SecureRandom().nextBytes(initVectorBytes); */ } else { // if contains ':' is the new format: split IV and value then decode using Base64, otherwise decode value as hex // NOTE: URL Base64 is letters, digits, '-', '_' int colonIdx = value.indexOf(":"); if (colonIdx >= 0) { // base64 decode each part as ${IV}:${encrypted} if (colonIdx > 0) initVectorBytes = Base64.getUrlDecoder().decode(value.substring(0, colonIdx)); inBytes = Base64.getUrlDecoder().decode(value.substring(colonIdx + 1)); } else { inBytes = DatatypeConverter.parseHexBinary(value); } } PBEParameterSpec pbeParamSpec = initVectorBytes == null ? new PBEParameterSpec(salt, count) : new PBEParameterSpec(salt, count, new IvParameterSpec(initVectorBytes)); PBEKeySpec pbeKeySpec = new PBEKeySpec(pass); SecretKeyFactory keyFac = SecretKeyFactory.getInstance(algo); SecretKey pbeKey = keyFac.generateSecret(pbeKeySpec); pbeCipher.init(encrypt ? Cipher.ENCRYPT_MODE : Cipher.DECRYPT_MODE, pbeKey, pbeParamSpec); byte[] outBytes = pbeCipher.doFinal(inBytes); // change to Base64 encode always (2/3 size with 6 bits/char base64 vs 4 bits/char hex), always include IV + ':' + encrypted value if (encrypt) { // old hex approach, now supported for decrypt only: return DatatypeConverter.printHexBinary(outBytes); if (defaultInitVectorBytes == initVectorBytes) { return ":" + Base64.getUrlEncoder().encodeToString(outBytes); } else { return Base64.getUrlEncoder().encodeToString(initVectorBytes) + ':' + Base64.getUrlEncoder().encodeToString(outBytes); } } else { return new String(outBytes); } } catch (Exception e) { // logger.warn("crypt-pass " + pwStr + " salt " + saltStr + " algo " + algo + " count " + count); throw new EntityException("Encryption error with algo " + algo, e); } } @SuppressWarnings("unused") public static FieldOrderOptions makeFieldOrderOptions(String orderByName) { return new FieldOrderOptions(orderByName); } public static class FieldOrderOptions { final static char spaceChar = ' '; final static char minusChar = '-'; final static char plusChar = '+'; final static char caretChar = '^'; final static char openParenChar = '('; final static char closeParenChar = ')'; String fieldName = null; Boolean nullsFirstLast = null; boolean descending = false; Boolean caseUpperLower = null; public String getFieldName() { return fieldName; } Boolean getNullsFirstLast() { return nullsFirstLast; } public boolean getDescending() { return descending; } Boolean getCaseUpperLower() { return caseUpperLower; } public FieldOrderOptions(String orderByName) { StringBuilder fnSb = new StringBuilder(40); // simple first parse pass, single run through and as fast as possible boolean containsSpace = false; boolean foundNonSpace = false; boolean containsOpenParen = false; int obnLength = orderByName.length(); char[] obnCharArray = orderByName.toCharArray(); for (int i = 0; i < obnLength; i++) { char curChar = obnCharArray[i]; if (curChar == spaceChar) { if (foundNonSpace) { containsSpace = true; fnSb.append(curChar); } // otherwise ignore the space } else { // leading characters (-,+,^), don't consider them non-spaces so we'll remove spaces after if (curChar == minusChar) { descending = true; } else if (curChar == plusChar) { descending = false; } else if (curChar == caretChar) { caseUpperLower = true; } else { foundNonSpace = true; fnSb.append(curChar); if (curChar == openParenChar) containsOpenParen = true; } } } if (fnSb.length() == 0) return; if (containsSpace) { // trim ending spaces while (fnSb.charAt(fnSb.length() - 1) == spaceChar) fnSb.delete(fnSb.length() - 1, fnSb.length()); String orderByUpper = fnSb.toString().toUpperCase(); int fnSbLength = fnSb.length(); if (orderByUpper.endsWith(" NULLS FIRST")) { nullsFirstLast = true; fnSb.delete(fnSbLength - 12, fnSbLength); // remove from orderByUpper as we'll use it below orderByUpper = orderByUpper.substring(0, orderByName.length() - 12); } else if (orderByUpper.endsWith(" NULLS LAST")) { nullsFirstLast = false; fnSb.delete(fnSbLength - 11, fnSbLength); // remove from orderByUpper as we'll use it below orderByUpper = orderByUpper.substring(0, orderByName.length() - 11); } fnSbLength = fnSb.length(); if (orderByUpper.endsWith(" DESC")) { descending = true; fnSb.delete(fnSbLength - 5, fnSbLength); } else if (orderByUpper.endsWith(" ASC")) { descending = false; fnSb.delete(fnSbLength - 4, fnSbLength); } } if (containsOpenParen) { String upperText = fnSb.toString().toUpperCase(); if (upperText.startsWith("UPPER(")) { caseUpperLower = true; fnSb.delete(0, 6); } else if (upperText.startsWith("LOWER(")) { caseUpperLower = false; fnSb.delete(0, 6); } int fnSbLength = fnSb.length(); if (fnSb.charAt(fnSbLength - 1) == closeParenChar) fnSb.delete(fnSbLength - 1, fnSbLength); } fieldName = fnSb.toString(); } } public static class EntityInfo { private final EntityDefinition ed; private final EntityFacadeImpl efi; public final String internalEntityName, fullEntityName, shortAlias, groupName; public final String tableName, tableNameLowerCase, schemaName, fullTableName; public final EntityDatasourceFactory datasourceFactory; public final boolean isEntityDatasourceFactoryImpl; public final boolean isView, isDynamicView, isInvalidViewEntity; final boolean hasFunctionAlias; public final boolean createOnly, createOnlyFields; final boolean optimisticLock, needsAuditLog, needsEncrypt; public final String useCache; public final boolean neverCache; final String sequencePrimaryPrefix; public final long sequencePrimaryStagger, sequenceBankSize; public final boolean sequencePrimaryUseUuid; final boolean hasFieldDefaults; final String authorizeSkipStr; final boolean authorizeSkipTrue; final boolean authorizeSkipCreate; public final boolean authorizeSkipView; public final FieldInfo[] pkFieldInfoArray, nonPkFieldInfoArray, allFieldInfoArray; final FieldInfo lastUpdatedStampInfo; public final String allFieldsSqlSelect; final Map pkFieldDefaults, nonPkFieldDefaults; EntityInfo(EntityDefinition ed, boolean memberNeverCache) { this.ed = ed; this.efi = ed.efi; MNode internalEntityNode = ed.internalEntityNode; EntityFacadeImpl efi = ed.efi; ArrayList allFieldInfoList = ed.allFieldInfoList; internalEntityName = internalEntityNode.attribute("entity-name"); String packageName = internalEntityNode.attribute("package"); if (packageName == null || packageName.isEmpty()) packageName = internalEntityNode.attribute("package-name"); fullEntityName = packageName + "." + internalEntityName; String shortAliasAttr = internalEntityNode.attribute("short-alias"); shortAlias = shortAliasAttr != null && !shortAliasAttr.isEmpty() ? shortAliasAttr : null; isView = ed.isViewEntity; isDynamicView = ed.isDynamicView; createOnly = "true".equals(internalEntityNode.attribute("create-only")); isInvalidViewEntity = isView && (!internalEntityNode.hasChild("member-entity") || !internalEntityNode.hasChild("alias")); groupName = ed.groupName; datasourceFactory = efi.getDatasourceFactory(groupName); isEntityDatasourceFactoryImpl = datasourceFactory instanceof EntityDatasourceFactoryImpl; MNode datasourceNode = efi.getDatasourceNode(groupName); MNode databaseNode = efi.getDatabaseNode(groupName); String tableNameAttr = internalEntityNode.attribute("table-name"); if (tableNameAttr == null || tableNameAttr.isEmpty()) tableNameAttr = EntityJavaUtil.camelCaseToUnderscored(internalEntityName); tableName = tableNameAttr; tableNameLowerCase = tableName.toLowerCase(); String schemaNameAttr = datasourceNode != null ? datasourceNode.attribute("schema-name") : null; if (schemaNameAttr != null && schemaNameAttr.length() == 0) schemaNameAttr = null; schemaName = schemaNameAttr; if (databaseNode == null || !"false".equals(databaseNode.attribute("use-schemas"))) { fullTableName = schemaName != null ? schemaName + "." + tableNameAttr : tableNameAttr; } else { fullTableName = tableNameAttr; } String sppAttr = internalEntityNode.attribute("sequence-primary-prefix"); if (sppAttr == null) sppAttr = ""; sequencePrimaryPrefix = sppAttr; String spsAttr = internalEntityNode.attribute("sequence-primary-stagger"); if (spsAttr != null && !spsAttr.isEmpty()) sequencePrimaryStagger = Long.parseLong(spsAttr); else sequencePrimaryStagger = 1; String sbsAttr = internalEntityNode.attribute("sequence-bank-size"); if (sbsAttr != null && !sbsAttr.isEmpty()) sequenceBankSize = Long.parseLong(sbsAttr); else sequenceBankSize = EntityFacadeImpl.defaultBankSize; sequencePrimaryUseUuid = "true".equals(internalEntityNode.attribute("sequence-primary-use-uuid")) || (datasourceNode != null && "true".equals(datasourceNode.attribute("sequence-primary-use-uuid"))); optimisticLock = "true".equals(internalEntityNode.attribute("optimistic-lock")); authorizeSkipStr = internalEntityNode.attribute("authorize-skip"); authorizeSkipTrue = "true".equals(authorizeSkipStr); authorizeSkipCreate = authorizeSkipTrue || (authorizeSkipStr != null && authorizeSkipStr.contains("create")); authorizeSkipView = authorizeSkipTrue || (authorizeSkipStr != null && authorizeSkipStr.contains("view")); // NOTE: see code in initFields that may set this to never if any member-entity is set to cache=never if (memberNeverCache) { useCache = "never"; neverCache = true; } else { String cacheAttr = internalEntityNode.attribute("cache"); if (cacheAttr == null || cacheAttr.isEmpty()) cacheAttr = "false"; useCache = cacheAttr; neverCache = "never".equals(useCache); } // init the FieldInfo arrays and see if we have create only fields, etc int allFieldInfoSize = allFieldInfoList.size(); ArrayList pkFieldInfoList = new ArrayList<>(); ArrayList nonPkFieldInfoList = new ArrayList<>(); allFieldInfoArray = new FieldInfo[allFieldInfoSize]; boolean createOnlyFieldsTemp = false; boolean needsAuditLogTemp = false; boolean needsEncryptTemp = false; boolean hasFunctionAliasTemp = false; Map pkFieldDefaultsTemp = new HashMap<>(); Map nonPkFieldDefaultsTemp = new HashMap<>(); FieldInfo lastUpdatedTemp = null; for (int i = 0; i < allFieldInfoSize; i++) { FieldInfo fi = allFieldInfoList.get(i); allFieldInfoArray[i] = fi; if (fi.isPk) pkFieldInfoList.add(fi); else nonPkFieldInfoList.add(fi); if (fi.createOnly) createOnlyFieldsTemp = true; if ("true".equals(fi.enableAuditLog) || "update".equals(fi.enableAuditLog)) needsAuditLogTemp = true; if ("true".equals(fi.fieldNode.attribute("encrypt"))) needsEncryptTemp = true; if (isView && fi.hasAggregateFunction) { MNode memberEntity = fi.memberEntityNode; if (memberEntity == null) { hasFunctionAliasTemp = true; } else { String subSelectAttr = memberEntity.attribute("sub-select"); if (subSelectAttr == null || subSelectAttr.isEmpty() || "false".equals(subSelectAttr)) hasFunctionAliasTemp = true; } } String defaultStr = fi.fieldNode.attribute("default"); if (defaultStr != null && !defaultStr.isEmpty()) { if (fi.isPk) pkFieldDefaultsTemp.put(fi.name, defaultStr); else nonPkFieldDefaultsTemp.put(fi.name, defaultStr); } if ("lastUpdatedStamp".equals(fi.name)) lastUpdatedTemp = fi; } createOnlyFields = createOnlyFieldsTemp; needsAuditLog = needsAuditLogTemp; needsEncrypt = needsEncryptTemp; hasFunctionAlias = hasFunctionAliasTemp; hasFieldDefaults = pkFieldDefaultsTemp.size() > 0 || nonPkFieldDefaultsTemp.size() > 0; pkFieldDefaults = pkFieldDefaultsTemp.size() > 0 ? pkFieldDefaultsTemp : null; nonPkFieldDefaults = nonPkFieldDefaultsTemp.size() > 0 ? nonPkFieldDefaultsTemp : null; lastUpdatedStampInfo = lastUpdatedTemp; pkFieldInfoArray = new FieldInfo[pkFieldInfoList.size()]; pkFieldInfoList.toArray(pkFieldInfoArray); nonPkFieldInfoArray = new FieldInfo[nonPkFieldInfoList.size()]; nonPkFieldInfoList.toArray(nonPkFieldInfoArray); // init allFieldsSqlSelect if (isView) { allFieldsSqlSelect = null; } else { StringBuilder sb = new StringBuilder(); for (int i = 0; i < allFieldInfoList.size(); i++) { FieldInfo fi = allFieldInfoList.get(i); if (i > 0) sb.append(", "); sb.append(fi.fullColumnNameInternal); } allFieldsSqlSelect = sb.toString(); } } void setFields(Map src, Map dest, boolean setIfEmpty, String namePrefix, Boolean pks) { if (src == null || dest == null) return; ExecutionContextImpl eci = efi.ecfi.getEci(); boolean destIsEntityValueBase = dest instanceof EntityValueBase; EntityValueBase destEvb = destIsEntityValueBase ? (EntityValueBase) dest : null; boolean hasNamePrefix = namePrefix != null && namePrefix.length() > 0; boolean srcIsEntityValueBase = src instanceof EntityValueBase; EntityValueBase srcEvb = srcIsEntityValueBase ? (EntityValueBase) src : null; FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray : (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray); // use integer iterator, saves quite a bit of time, improves time for this method by about 20% with this alone int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; String fieldName = fi.name; String srcName; if (hasNamePrefix) { srcName = namePrefix + Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1); } else { srcName = fieldName; } Object value; boolean srcContains; if (srcIsEntityValueBase) { value = hasNamePrefix ? srcEvb.valueMapInternal.get(srcName) : srcEvb.valueMapInternal.getByIString(fi.name, fi.index); srcContains = value != null || (hasNamePrefix ? srcEvb.valueMapInternal.containsKey(srcName) : srcEvb.valueMapInternal.containsKeyIString(fi.name, fi.index)); } else { value = src.get(srcName); srcContains = value != null || src.containsKey(srcName); } if (srcContains) { boolean isCharSequence = false; boolean isEmpty = false; if (value == null) { isEmpty = true; } else if (value instanceof CharSequence) { isCharSequence = true; if (((CharSequence) value).length() == 0) isEmpty = true; } if (!isEmpty) { if (isCharSequence) { try { Object converted = fi.convertFromString(value.toString(), eci.l10nFacade); if (destIsEntityValueBase) destEvb.putKnownField(fi, converted); else dest.put(fieldName, converted); } catch (BaseException be) { eci.messageFacade.addValidationError(null, fieldName, null, be.getMessage(), be); } } else { if (destIsEntityValueBase) destEvb.putKnownField(fi, value); else dest.put(fieldName, value); } } else if (setIfEmpty) { // treat empty String as null, otherwise set as whatever null or empty type it is if (value != null && isCharSequence) { if (destIsEntityValueBase) destEvb.putKnownField(fi, null); else dest.put(fieldName, null); } else { if (destIsEntityValueBase) destEvb.putKnownField(fi, value); else dest.put(fieldName, value); } } } } } void setFieldsEv(Map src, EntityValueBase dest, Boolean pks) { // like above with setIfEmpty=true, namePrefix=null, pks=null if (src == null || dest == null) return; ExecutionContextImpl eci = efi.ecfi.getEci(); boolean srcIsEntityValueBase = src instanceof EntityValueBase; EntityValueBase srcEvb = srcIsEntityValueBase ? (EntityValueBase) src : null; FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray : (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray); // use integer iterator, saves quite a bit of time, improves time for this method by about 20% with this alone int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; String fieldName = fi.name; Object value; boolean srcContains; if (srcIsEntityValueBase) { value = srcEvb.valueMapInternal.getByIString(fi.name, fi.index); srcContains = value != null || srcEvb.valueMapInternal.containsKeyIString(fi.name, fi.index); } else { value = src.get(fieldName); srcContains = value != null || src.containsKey(fieldName); } if (srcContains) { boolean isCharSequence = false; boolean isEmpty = false; if (value == null) { isEmpty = true; } else if (value instanceof CharSequence) { isCharSequence = true; if (((CharSequence) value).length() == 0) isEmpty = true; } if (!isEmpty) { if (isCharSequence) { try { Object converted = fi.convertFromString(value.toString(), eci.l10nFacade); dest.putKnownField(fi, converted); } catch (BaseException be) { eci.messageFacade.addValidationError(null, fieldName, null, be.getMessage(), be); } } else { dest.putKnownField(fi, value); } } else { // treat empty String as null, otherwise set as whatever null or empty type it is dest.putKnownField(fi, null); } } } } public Map cloneMapRemoveFields(Map theMap, Boolean pks) { Map newMap = new HashMap<>(theMap); //ArrayList fieldNameList = (pks != null ? this.getFieldNames(pks, !pks, !pks) : this.getAllFieldNames()) FieldInfo[] fieldInfoArray = pks == null ? allFieldInfoArray : (pks == Boolean.TRUE ? pkFieldInfoArray : nonPkFieldInfoArray); int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; newMap.remove(fi.name); } return newMap; } } public static class RelationshipInfo { public final String type; public final boolean isTypeOne, isFk; public final String title; public final String relatedEntityName; final EntityDefinition fromEd; public final EntityDefinition relatedEd; public final MNode relNode; public final String relationshipName; public final String shortAlias; public final String prettyName; public final Map keyMap, keyValueMap; public final ArrayList keyFieldList, keyFieldValueList; public final boolean dependent, mutable, isAutoReverse; RelationshipInfo(MNode relNode, EntityDefinition fromEd, EntityFacadeImpl efi) { this.relNode = relNode; this.fromEd = fromEd; type = relNode.attribute("type"); isTypeOne = type.startsWith("one"); isFk = "one".equals(type); isAutoReverse = "true".equals(relNode.attribute("is-auto-reverse")); String titleAttr = relNode.attribute("title"); title = titleAttr != null && !titleAttr.isEmpty() ? titleAttr : null; String relatedAttr = relNode.attribute("related"); if (relatedAttr == null || relatedAttr.isEmpty()) relatedAttr = relNode.attribute("related-entity-name"); relatedEd = efi.getEntityDefinition(relatedAttr); if (relatedEd == null) throw new EntityNotFoundException("Invalid entity relationship, " + relatedAttr + " not found in definition for entity " + fromEd.getFullEntityName()); relatedEntityName = relatedEd.getFullEntityName(); relationshipName = (title != null ? title + '#' : "") + relatedEntityName; String shortAliasAttr = relNode.attribute("short-alias"); shortAlias = shortAliasAttr != null && !shortAliasAttr.isEmpty() ? shortAliasAttr : null; prettyName = relatedEd.getPrettyName(title, fromEd.entityInfo.internalEntityName); keyMap = EntityDefinition.getRelationshipExpandedKeyMapInternal(relNode, relatedEd); keyFieldList = new ArrayList<>(keyMap.keySet()); keyValueMap = EntityDefinition.getRelationshipKeyValueMapInternal(relNode); keyFieldValueList = keyValueMap != null ? new ArrayList<>(keyValueMap.keySet()) : null; dependent = hasReverse(); String mutableAttr = relNode.attribute("mutable"); if (mutableAttr != null && !mutableAttr.isEmpty()) { mutable = "true".equals(relNode.attribute("mutable")); } else { // by default type one not mutable, type many are mutable mutable = !isTypeOne; } } // some methods for FTL templates that don't access member fields, just call getters; don't follow getter pattern so groovy code won't pick them up public String riPrettyName() { return prettyName; } public String riRelatedEntityName() { return relatedEntityName; } private boolean hasReverse() { ArrayList relatedRelList = relatedEd.internalEntityNode.children("relationship"); int relatedRelListSize = relatedRelList.size(); for (int i = 0; i < relatedRelListSize; i++) { MNode reverseRelNode = relatedRelList.get(i); String relatedAttr = reverseRelNode.attribute("related"); if (relatedAttr == null || relatedAttr.isEmpty()) relatedAttr = reverseRelNode.attribute("related-entity-name"); String typeAttr = reverseRelNode.attribute("type"); // TODO: instead of checking title check reverse expanded key-map String titleAttr = reverseRelNode.attribute("title"); if ((fromEd.entityInfo.fullEntityName.equals(relatedAttr) || fromEd.entityInfo.internalEntityName.equals(relatedAttr)) && ("one".equals(typeAttr) || "one-nofk".equals(typeAttr)) && (title == null ? titleAttr == null || titleAttr.isEmpty() : title.equals(titleAttr))) { return true; } } return false; } public RelationshipInfo findReverse() { ArrayList relInfoList = relatedEd.getRelationshipsInfo(false); int relInfoListSize = relInfoList.size(); for (int i = 0; i < relInfoListSize; i++) { EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i); // TODO: instead of checking title check reverse expanded key-map if (fromEd.fullEntityName.equals(relInfo.relatedEntityName) && ((title == null && relInfo.title == null) || (title != null && title.equals(relInfo.title)))) { return relInfo; } } return null; } public Map getTargetParameterMap(Map valueSource) { if (valueSource == null || valueSource.isEmpty()) return new LinkedHashMap<>(); Map targetParameterMap = new HashMap<>(); for (Map.Entry keyEntry: keyMap.entrySet()) { Object value = valueSource.get(keyEntry.getKey()); if (!ObjectUtilities.isEmpty(value)) targetParameterMap.put(keyEntry.getValue(), value); } if (keyValueMap != null) { for (Map.Entry keyValueEntry: keyValueMap.entrySet()) targetParameterMap.put(keyValueEntry.getKey(), keyValueEntry.getValue()); } return targetParameterMap; } public String toString() { return relationshipName + (shortAlias != null ? " (" + shortAlias + ")" : "") + ", type " + type + ", one? " + isTypeOne + ", dependent? " + dependent; } } private static Map camelToUnderscoreMap = new HashMap<>(); public static String camelCaseToUnderscored(String camelCase) { if (camelCase == null || camelCase.length() == 0) return ""; String usv = camelToUnderscoreMap.get(camelCase); if (usv != null) return usv; StringBuilder underscored = new StringBuilder(); underscored.append(Character.toUpperCase(camelCase.charAt(0))); int inPos = 1; while (inPos < camelCase.length()) { char curChar = camelCase.charAt(inPos); if (Character.isUpperCase(curChar)) underscored.append('_'); underscored.append(Character.toUpperCase(curChar)); inPos++; } usv = underscored.toString(); camelToUnderscoreMap.put(camelCase, usv); return usv; } public static String underscoredToCamelCase(String underscored, boolean firstUpper) { if (underscored == null || underscored.length() == 0) return ""; StringBuilder camelCased = new StringBuilder(); camelCased.append(firstUpper ? Character.toUpperCase(underscored.charAt(0)) : Character.toLowerCase(underscored.charAt(0))); int inPos = 1; boolean lastUnderscore = false; while (inPos < underscored.length()) { char curChar = underscored.charAt(inPos); if (curChar == '_') { lastUnderscore = true; } else { if (lastUnderscore) { camelCased.append(Character.toUpperCase(curChar)); lastUnderscore = false; } else { camelCased.append(Character.toLowerCase(curChar)); } } inPos++; } return camelCased.toString(); } public static class EntityConditionParameter { protected FieldInfo fieldInfo; protected Object value; protected EntityQueryBuilder eqb; public EntityConditionParameter(FieldInfo fieldInfo, Object value, EntityQueryBuilder eqb) { this.fieldInfo = fieldInfo; this.value = value; this.eqb = eqb; } public FieldInfo getFieldInfo() { return fieldInfo; } public Object getValue() { return value; } void setPreparedStatementValue(int index) throws EntityException { eqb.setPreparedStatementValue(index, value, fieldInfo); } @Override public String toString() { return fieldInfo.name + ':' + value; } } public static class QueryStatsInfo { private String entityName; private String sql; private long hitCount = 0, errorCount = 0; private long minTimeNanos = Long.MAX_VALUE, maxTimeNanos = 0, totalTimeNanos = 0, totalSquaredTime = 0; private Map artifactCounts = new HashMap<>(); public QueryStatsInfo(String entityName, String sql) { this.entityName = entityName; this.sql = sql; } public void countHit(EntityFacadeImpl efi, long runTimeNanos, boolean isError) { hitCount++; if (isError) errorCount++; if (runTimeNanos < minTimeNanos) minTimeNanos = runTimeNanos; if (runTimeNanos > maxTimeNanos) maxTimeNanos = runTimeNanos; totalTimeNanos += runTimeNanos; totalSquaredTime += runTimeNanos * runTimeNanos; // this gets much more expensive, consider commenting in the future ArtifactExecutionInfo aei = efi.ecfi.getEci().artifactExecutionFacade.peek(); if (aei != null) aei = aei.getParent(); if (aei != null) { String artifactName = aei.getName(); Integer artifactCount = artifactCounts.get(artifactName); artifactCounts.put(artifactName, artifactCount != null ? artifactCount + 1 : 1); } } public String getEntityName() { return entityName; } public String getSql() { return sql; } // public long getHitCount() { return hitCount; } // public long getErrorCount() { return errorCount; } // public long getMinTimeNanos() { return minTimeNanos; } // public long getMaxTimeNanos() { return maxTimeNanos; } // public long getTotalTimeNanos() { return totalTimeNanos; } // public long getTotalSquaredTime() { return totalSquaredTime; } double getAverage() { return hitCount > 0 ? totalTimeNanos / hitCount : 0; } double getStdDev() { if (hitCount < 2) return 0; return Math.sqrt(Math.abs(totalSquaredTime - ((totalTimeNanos * totalTimeNanos) / hitCount)) / (hitCount - 1L)); } final static long nanosDivisor = 1000; public Map makeDisplayMap() { Map dm = new HashMap<>(); dm.put("entityName", entityName); dm.put("sql", sql); dm.put("hitCount", hitCount); dm.put("errorCount", errorCount); dm.put("minTime", new BigDecimal(minTimeNanos/nanosDivisor)); dm.put("maxTime", new BigDecimal(maxTimeNanos/nanosDivisor)); dm.put("totalTime", new BigDecimal(totalTimeNanos/nanosDivisor)); dm.put("totalSquaredTime", new BigDecimal(totalSquaredTime/nanosDivisor)); dm.put("average", new BigDecimal(getAverage()/nanosDivisor)); dm.put("stdDev", new BigDecimal(getStdDev()/nanosDivisor)); dm.put("artifactCounts", new HashMap<>(artifactCounts)); return dm; } } public enum WriteMode { CREATE, UPDATE, DELETE } public static class EntityWriteInfo { public WriteMode writeMode; public EntityValueBase evb; Map pkMap; public EntityWriteInfo(EntityValueBase evb, WriteMode writeMode) { // clone value so that create/update/delete stays the same no matter what happens after this.evb = (EntityValueBase) evb.cloneValue(); this.writeMode = writeMode; this.pkMap = evb.getPrimaryKeys(); } } public static class FindAugmentInfo { public final ArrayList valueList; public final int valueListSize; public final Set> foundUpdated; public final EntityCondition econd; public FindAugmentInfo(ArrayList valueList, Set> foundUpdated, EntityCondition econd) { this.valueList = valueList; valueListSize = valueList.size(); this.foundUpdated = foundUpdated; this.econd = econd; } } /* added as a possibility for EntityValueBase.checkAgainstDatabaseInfo() but simpler for interfaces, sorting, etc to use a Map: public static class EntityValueDiffInfo { public String entityName, fieldName; public Map pkValues; public Object checkValue, dbValue; public boolean notFound; public EntityValueDiffInfo(String entityName, Map pkValues) { this.entityName = entityName; this.fieldName = null; this.pkValues = pkValues; this.checkValue = null; this.dbValue = null; this.notFound = true; } public EntityValueDiffInfo(String entityName, Map pkValues, String fieldName, Object checkValue, Object dbValue) { this.entityName = entityName; this.fieldName = fieldName; this.pkValues = pkValues; this.checkValue = checkValue; this.dbValue = dbValue; this.notFound = false; } } */ } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityListImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import groovy.lang.Closure; import org.moqui.Moqui; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityException; import org.moqui.entity.EntityList; import org.moqui.entity.EntityValue; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.util.CollectionUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.annotation.Nonnull; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.io.Writer; import java.sql.Date; import java.sql.Timestamp; import java.util.*; public class EntityListImpl implements EntityList { protected static final Logger logger = LoggerFactory.getLogger(EntityConditionFactoryImpl.class); private transient EntityFacadeImpl efiTransient; private ArrayList valueList; private boolean fromCache = false; protected Integer offset = null; protected Integer limit = null; /** Default constructor for deserialization ONLY. */ public EntityListImpl() { } public EntityListImpl(EntityFacadeImpl efi) { this.efiTransient = efi; valueList = new ArrayList<>(30);// default size, at least enough for common pagination } public EntityListImpl(EntityFacadeImpl efi, int initialCapacity) { this.efiTransient = efi; valueList = new ArrayList<>(initialCapacity); } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(valueList); // don't serialize fromCache, will default back to false which is fine for a copy } @SuppressWarnings("unchecked") @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { valueList = (ArrayList) objectInput.readObject(); } @SuppressWarnings("unchecked") public EntityFacadeImpl getEfi() { if (efiTransient == null) efiTransient = ((ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory()).entityFacade; return efiTransient; } @Override public EntityValue getFirst() { return valueList != null && valueList.size() > 0 ? valueList.get(0) : null; } @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment) { if (fromCache) return this.cloneList().filterByDate(fromDateName, thruDateName, moment); // default to now long momentLong = moment != null ? moment.getTime() : System.currentTimeMillis(); long momentDateLong = new Date(momentLong).getTime(); if (fromDateName == null || fromDateName.length() == 0) fromDateName = "fromDate"; if (thruDateName == null || thruDateName.length() == 0) thruDateName = "thruDate"; int valueIndex = 0; while (valueIndex < valueList.size()) { EntityValue value = valueList.get(valueIndex); Object fromDateObj = value.get(fromDateName); Object thruDateObj = value.get(thruDateName); Long fromDateLong = getDateLong(fromDateObj); Long thruDateLong = getDateLong(thruDateObj); if (fromDateObj instanceof Date || thruDateObj instanceof Date) { if (!((thruDateLong == null || thruDateLong >= momentDateLong) && (fromDateLong == null || fromDateLong <= momentDateLong))) { valueList.remove(valueIndex); } else { valueIndex++; } } else { if (!((thruDateLong == null || thruDateLong >= momentLong) && (fromDateLong == null || fromDateLong <= momentLong))) { valueList.remove(valueIndex); } else { valueIndex++; } } } return this; } private static Long getDateLong(Object dateObj) { if (dateObj instanceof java.util.Date) return ((java.util.Date) dateObj).getTime(); else if (dateObj instanceof Long) return (Long) dateObj; else return null; } @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty) { if (ignoreIfEmpty && moment == null) return this; return filterByDate(fromDateName, thruDateName, moment); } @Override public EntityList filterByAnd(Map fields) { return filterByAnd(fields, true); } @Override public EntityList filterByAnd(Map fields, Boolean include) { if (fromCache) return this.cloneList().filterByAnd(fields, include); // iterate fields once, then use indexes within big loop int fieldsSize = fields.size(); String[] names = new String[fieldsSize]; Object[] values = new Object[fieldsSize]; boolean hasSetValue = false; int fieldIndex = 0; for (Map.Entry entry : fields.entrySet()) { names[fieldIndex] = entry.getKey(); Object val = entry.getValue(); values[fieldIndex] = val; if (val instanceof Collection) { hasSetValue = true; if (!(val instanceof Set)) values[fieldIndex] = new HashSet((Collection) val); } fieldIndex++; } filterInternal(names, values, hasSetValue, include); return this; } private void filterInternal(String[] names, Object[] values, boolean hasSetValue, Boolean include) { if (include == null) include = true; int valueIndex = 0; while (valueIndex < valueList.size()) { EntityValue value = valueList.get(valueIndex); boolean matches = valueMatches(value, names, values, hasSetValue); if ((matches && !include) || (!matches && include)) { valueList.remove(valueIndex); } else { valueIndex++; } } } private boolean valueMatches(EntityValue value, String[] names, Object[] values, boolean hasSetValue) { boolean matches = true; int fieldsSize = names.length; for (int i = 0; i < fieldsSize; i++) { Object curValue = value.getNoCheckSimple(names[i]); Object compValue = values[i]; if (curValue == null) { matches = compValue == null; if (!matches) { matches = false; break; } } else { if (hasSetValue && compValue instanceof Set) { Set valSet = (Set) compValue; if (!valSet.contains(curValue)) { matches = false; break; } } else if (!curValue.equals(compValue)) { matches = false; break; } } } return matches; } @Override public EntityList filterByAnd(Object... namesAndValues) { if (namesAndValues.length == 0) return this; if (namesAndValues.length % 2 != 0) throw new IllegalArgumentException("Must pass an even number of parameters for name/value pairs"); if (fromCache) return this.cloneList().filterByAnd(namesAndValues); int fieldsSize = namesAndValues.length / 2; String[] names = new String[fieldsSize]; Object[] values = new Object[fieldsSize]; boolean hasSetValue = false; for (int i = 0; i < fieldsSize; i++) { int navIdx = i * 2; names[i] = (String) namesAndValues[navIdx]; Object value = namesAndValues[navIdx+1]; if (value instanceof Collection) { hasSetValue = true; if (!(value instanceof Set)) value = new HashSet((Collection) value); } values[i] = value; } filterInternal(names, values, hasSetValue, true); return this; } @Override public EntityList filter(Closure closure, Boolean include) { if (fromCache) return this.cloneList().filter(closure, include); int valueIndex = 0; while (valueIndex < valueList.size()) { EntityValue value = valueList.get(valueIndex); boolean matches = closure.call(value); if ((matches && !include) || (!matches && include)) { valueList.remove(valueIndex); } else { valueIndex++; } } return this; } @Override public EntityValue find(Closure closure) { int valueListSize = valueList.size(); for (int i = 0; i < valueListSize; i++) { EntityValue value = valueList.get(i); boolean matches = closure.call(value); if (matches) return value; } return null; } @Override public EntityValue findByAnd(Map fields) { // iterate fields once, then use indexes within big loop int fieldsSize = fields.size(); String[] names = new String[fieldsSize]; Object[] values = new Object[fieldsSize]; boolean hasSetValue = false; int fieldIndex = 0; for (Map.Entry entry : fields.entrySet()) { names[fieldIndex] = entry.getKey(); Object val = entry.getValue(); values[fieldIndex] = val; if (val instanceof Collection) { hasSetValue = true; if (!(val instanceof Set)) values[fieldIndex] = new HashSet((Collection) val); } fieldIndex++; } int valueListSize = valueList.size(); for (int valueIndex = 0; valueIndex < valueListSize; valueIndex++) { EntityValue value = valueList.get(valueIndex); boolean matches = valueMatches(value, names, values, hasSetValue); if (matches) return value; } return null; } @Override public EntityValue findByAnd(Object... namesAndValues) { if (namesAndValues.length == 0) return getFirst(); if (namesAndValues.length % 2 != 0) throw new IllegalArgumentException("Must pass an even number of parameters for name/value pairs"); int fieldsSize = namesAndValues.length / 2; String[] names = new String[fieldsSize]; Object[] values = new Object[fieldsSize]; boolean hasSetValue = false; for (int i = 0; i < fieldsSize; i++) { int navIdx = i * 2; names[i] = (String) namesAndValues[navIdx]; Object value = namesAndValues[navIdx+1]; if (value instanceof Collection) { hasSetValue = true; if (!(value instanceof Set)) value = new HashSet((Collection) value); } values[i] = value; } int valueListSize = valueList.size(); for (int valueIndex = 0; valueIndex < valueListSize; valueIndex++) { EntityValue value = valueList.get(valueIndex); boolean matches = valueMatches(value, names, values, hasSetValue); if (matches) return value; } return null; } @Override public EntityList findAll(Closure closure) { EntityListImpl newList = new EntityListImpl(getEfi()); int valueListSize = valueList.size(); for (int i = 0; i < valueListSize; i++) { EntityValue value = valueList.get(i); boolean matches = closure.call(value); if (matches) newList.add(value); } return newList; } @Override public EntityList removeByAnd(Map fields) { return filterByAnd(fields, false); } @Override public EntityList filterByCondition(EntityCondition condition, Boolean include) { if (fromCache) return this.cloneList().filterByCondition(condition, include); if (include == null) include = true; int valueIndex = 0; while (valueIndex < valueList.size()) { EntityValue value = valueList.get(valueIndex); boolean matches = condition.mapMatches(value); // logger.warn("TOREMOVE filter value [${value}] with condition [${condition}] include=${include}, matches=${matches}") // matched: if include is not true or false (default exclude) remove it // didn't match, if include is true remove it if ((matches && !include) || (!matches && include)) { valueList.remove(valueIndex); } else { valueIndex++; } } return this; } @Override public EntityList filterByLimit(Integer offset, Integer limit) { if (fromCache) return this.cloneList().filterByLimit(offset, limit); if (offset == null && limit == null) return this; if (offset == null) offset = 0; this.offset = offset; this.limit = limit; int vlSize = valueList.size(); int toIndex = limit != null ? offset + limit : vlSize; if (toIndex > vlSize) toIndex = vlSize; ArrayList newList = new ArrayList<>(limit != null && limit > 0 ? limit : (vlSize - offset)); for (int i = offset; i < toIndex; i++) newList.add(valueList.get(i)); valueList = newList; return this; } @Override public EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate) { if (fromCache) return this.cloneList().filterByLimit(inputFieldsMapName, alwaysPaginate); Map inf = inputFieldsMapName != null && inputFieldsMapName.length() > 0 ? (Map) getEfi().ecfi.getEci().contextStack.get(inputFieldsMapName) : getEfi().ecfi.getEci().contextStack; if (alwaysPaginate || inf.get("pageIndex") != null) { final Object pageIndexObj = inf.get("pageIndex"); int pageIndex; if (pageIndexObj instanceof Number) { pageIndex = ((Number) pageIndexObj).intValue(); } else { pageIndex = Integer.parseInt(pageIndexObj.toString()); } final Object pageSizeObj = inf.get("pageSize"); int pageSize; if (pageSizeObj != null) { if (pageSizeObj instanceof Number) { pageSize = ((Number) pageSizeObj).intValue(); } else { pageSize = Integer.parseInt(pageSizeObj.toString()); } } else { pageSize = 20; } int offset = pageIndex * pageSize; return filterByLimit(offset, pageSize); } else { return this; } } @Override public Integer getOffset() { return this.offset; } @Override public Integer getLimit() { return this.limit; } @Override public int getPageIndex() { return (offset != null ? offset : 0) / getPageSize(); } @Override public int getPageSize() { return limit != null ? limit : 20; } @Override public EntityList orderByFields(List fieldNames) { if (fromCache) return this.cloneList().orderByFields(fieldNames); if (fieldNames != null && fieldNames.size() > 0) valueList.sort(new CollectionUtilities.MapOrderByComparator(fieldNames)); return this; } @Override public void sort(Comparator comparator) { valueList.sort(comparator); } @Override public int indexMatching(Map valueMap) { ListIterator li = valueList.listIterator(); int index = 0; while (li.hasNext()) { EntityValue ev = li.next(); if (ev.mapMatches(valueMap)) return index; index++; } return -1; } @Override public void move(int fromIndex, int toIndex) { if (fromIndex == toIndex) return; EntityValue val = remove(fromIndex); if (toIndex > fromIndex) toIndex--; add(toIndex, val); } @Override public EntityList addIfMissing(EntityValue value) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); if (!valueList.contains(value)) valueList.add(value); return this; } @Override public EntityList addAllIfMissing(EntityList el) { for (EntityValue value : el) addIfMissing(value); return this; } @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { int recordsWritten = 0; for (EntityValue ev : this) recordsWritten += ev.writeXmlText(writer, prefix, dependentLevels); return recordsWritten; } @Override public @Nonnull Iterator iterator() { return new EntityIterator(); } private class EntityIterator implements Iterator { int curIndex = -1; boolean valueRemoved = false; @Override public boolean hasNext() { return (curIndex + 1) < valueList.size(); } @Override public EntityValue next() { if ((curIndex + 1) >= valueList.size()) throw new NoSuchElementException("Next is beyond end of list (index " + (curIndex + 1) + ", size " + valueList.size() + ")"); curIndex++; valueRemoved = false; return valueList.get(curIndex); } @Override public void remove() { if (fromCache) throw new UnsupportedOperationException("Cannot modify EntityList from cache"); if (curIndex == -1) throw new IllegalStateException("Cannot remove, next() has not been called"); if (valueRemoved) throw new IllegalStateException("Cannot remove, next() has not been called since last remove"); valueList.remove(curIndex); curIndex--; valueRemoved = true; } } @Override public List> getPlainValueList(int dependentLevels) { List> plainRelList = new ArrayList<>(valueList.size()); for (EntityValue ev : valueList) plainRelList.add(ev.getPlainValueMap(dependentLevels)); return plainRelList; } @Override public List> getMasterValueList(String name) { List> masterRelList = new ArrayList<>(valueList.size()); for (EntityValue ev : valueList) masterRelList.add(ev.getMasterValueMap(name)); return masterRelList; } @Override public ArrayList> getValueMapList() { int elSize = valueList.size(); ArrayList> al = new ArrayList<>(elSize); for (int i = 0; i < elSize; i++) { EntityValue ev = valueList.get(i); Map evMap = ev.getMap(); CollectionUtilities.removeNullsFromMap(evMap); al.add(evMap); } return al; } @Override public Object clone() { return this.cloneList(); } @Override public EntityList cloneList() { EntityListImpl newObj = new EntityListImpl(this.getEfi(), valueList.size()); newObj.valueList.addAll(valueList); // NOTE: when cloning don't clone the fromCache value (normally when from cache will be cloned before filtering) return newObj; } public EntityListImpl deepCloneList() { EntityListImpl newObj = new EntityListImpl(this.getEfi(), valueList.size()); int valueListSize = valueList.size(); for (int i = 0; i < valueListSize; i++) { EntityValue ev = valueList.get(i); newObj.valueList.add(ev.cloneValue()); } return newObj; } @Override public void setFromCache() { fromCache = true; for (EntityValue ev : valueList) if (ev instanceof EntityValueBase) ((EntityValueBase) ev).setFromCache(); } @Override public boolean isFromCache() { return fromCache; } @Override public int size() { return valueList.size(); } @Override public boolean isEmpty() { return valueList.isEmpty(); } @Override public boolean contains(Object o) { return valueList.contains(o); } @Override public @Nonnull Object[] toArray() { return valueList.toArray(); } @SuppressWarnings("SuspiciousToArrayCall") @Override public @Nonnull T[] toArray(@Nonnull T[] ts) { return valueList.toArray(ts); } @Override public boolean add(EntityValue e) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return e != null && valueList.add(e); } @Override public boolean remove(Object o) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.remove(o); } @Override public boolean containsAll(@Nonnull Collection objects) { return valueList.containsAll(objects); } @Override public boolean addAll(@Nonnull Collection es) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.addAll(es); } @Override public boolean addAll(int i, @Nonnull Collection es) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.addAll(i, es); } @Override public boolean removeAll(@Nonnull Collection objects) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.removeAll(objects); } @Override public boolean retainAll(@Nonnull Collection objects) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.retainAll(objects); } @Override public void clear() { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); valueList.clear(); } @Override public EntityValue get(int i) { return valueList.get(i); } @Override public EntityValue set(int i, EntityValue e) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.set(i, e); } @Override public void add(int i, EntityValue e) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); valueList.add(i, e); } @Override public EntityValue remove(int i) { if (fromCache) throw new EntityException("Cannot modify EntityList from cache"); return valueList.remove(i); } @Override public int indexOf(Object o) { return valueList.indexOf(o); } @Override public int lastIndexOf(Object o) { return valueList.lastIndexOf(o); } @Override public @Nonnull ListIterator listIterator() { return valueList.listIterator(); } @Override public @Nonnull ListIterator listIterator(int i) { return valueList.listIterator(i); } @Override public @Nonnull List subList(int start, int end) { return valueList.subList(start, end); } @Override public String toString() { return valueList.toString(); } @SuppressWarnings("unused") public static class EmptyEntityList implements EntityList { public EmptyEntityList() { } @Override public void writeExternal(ObjectOutput out) throws IOException { } @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { } @Override public EntityValue getFirst() { return null; } @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment) { return this; } @Override public EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty) { return this; } @Override public EntityList filterByAnd(Map fields) { return this; } @Override public EntityList filterByAnd(Map fields, Boolean include) { return this; } @Override public EntityList filterByAnd(Object... namesAndValues) { return this; } @Override public EntityList removeByAnd(Map fields) { return this; } @Override public EntityList filterByCondition(EntityCondition condition, Boolean include) { return this; } @Override public EntityList filter(Closure closure, Boolean include) { return this; } @Override public EntityValue find(Closure closure) { return null; } @Override public EntityValue findByAnd(Map fields) { return null; } @Override public EntityValue findByAnd(Object... namesAndValues) { return null; } @Override public EntityList findAll(Closure closure) { return this; } @Override public EntityList filterByLimit(Integer offset, Integer limit) { this.offset = offset; this.limit = limit; return this; } @Override public EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate) { return this; } @Override public Integer getOffset() { return this.offset; } @Override public Integer getLimit() { return this.limit; } @Override public int getPageIndex() { return (offset != null ? offset : 0) / getPageSize(); } @Override public int getPageSize() { return limit != null ? limit : 20; } @Override public EntityList orderByFields(List fieldNames) { return this; } @Override public int indexMatching(Map valueMap) { return -1; } @Override public void move(int fromIndex, int toIndex) { throw new IllegalArgumentException("EmptyEntityList does not support move"); } @Override public EntityList addIfMissing(EntityValue value) { throw new IllegalArgumentException("EmptyEntityList does not support add"); } @Override public EntityList addAllIfMissing(EntityList el) { throw new IllegalArgumentException("EmptyEntityList does not support add"); } @Override public @Nonnull Iterator iterator() { return emptyIterator; } @Override public Object clone() { return this.cloneList(); } @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { return 0; } @Override public List> getPlainValueList(int dependentLevels) { return new ArrayList<>(); } @Override public List> getMasterValueList(String name) { return new ArrayList<>(); } @Override public ArrayList> getValueMapList() { return new ArrayList<>(); } @Override public EntityList cloneList() { return this; } @Override public void setFromCache() { } @Override public boolean isFromCache() { return false; } @Override public int size() { return 0; } @Override public boolean isEmpty() { return true; } @Override public boolean contains(Object o) { return false; } @SuppressWarnings("unchecked") @Override public @Nonnull Object[] toArray() { return new Object[0]; } @SuppressWarnings("unchecked") @Override public @Nonnull T[] toArray(@Nonnull T[] ts) { return ((T[]) new EntityValue[0]); } @Override public boolean add(EntityValue e) { throw new IllegalArgumentException("EmptyEntityList does not support add"); } @Override public boolean remove(Object o) { return false; } @Override public boolean containsAll(@Nonnull Collection objects) { return false; } @Override public boolean addAll(@Nonnull Collection es) { throw new IllegalArgumentException("EmptyEntityList does not support addAll"); } @Override public boolean addAll(int i, @Nonnull Collection es) { throw new IllegalArgumentException("EmptyEntityList does not support addAll"); } @Override public boolean removeAll(@Nonnull Collection objects) { return false; } @Override public boolean retainAll(@Nonnull Collection objects) { return false; } @Override public void clear() { } @Override public EntityValue get(int i) { return null; } @Override public EntityValue set(int i, EntityValue e) { throw new IllegalArgumentException("EmptyEntityList does not support set"); } @Override public void add(int i, EntityValue e) { throw new IllegalArgumentException("EmptyEntityList does not support add"); } @Override public EntityValue remove(int i) { return null; } @Override public int indexOf(Object o) { return -1; } @Override public int lastIndexOf(Object o) { return -1; } @Override public @Nonnull ListIterator listIterator() { return emptyIterator; } @Override public @Nonnull ListIterator listIterator(int i) { return emptyIterator; } @Override public @Nonnull List subList(int start, int end) { return this; } @Override public String toString() { return "[]"; } public static ListIterator getEmptyIterator() { return emptyIterator; } private static final ListIterator emptyIterator = new LinkedList().listIterator(); protected Integer offset = null; protected Integer limit = null; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityListIteratorImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.BaseArtifactException; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.entity.*; import org.moqui.impl.context.TransactionCache; import org.moqui.impl.entity.EntityJavaUtil.FindAugmentInfo; import org.moqui.impl.entity.condition.EntityConditionImplBase; import org.moqui.util.CollectionUtilities; import org.moqui.util.LiteStringMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Writer; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; public class EntityListIteratorImpl implements EntityListIterator { protected static final Logger logger = LoggerFactory.getLogger(EntityListIteratorImpl.class); protected final EntityFacadeImpl efi; private final TransactionCache txCache; protected final Connection con; private final ResultSet rs; private final FindAugmentInfo findAugmentInfo; private final int txcListSize; private int txcListIndex = -1; private final EntityDefinition entityDefinition; protected final FieldInfo[] fieldInfoArray; private final int fieldInfoListSize; private final EntityConditionImplBase queryCondition; private final CollectionUtilities.MapOrderByComparator orderByComparator; /** This is needed to determine if the ResultSet is empty as cheaply as possible. */ private boolean haveMadeValue = false; protected boolean closed = false; private StackTraceElement[] constructStack = null; private final ArrayList artifactStack; public EntityListIteratorImpl(Connection con, ResultSet rs, EntityDefinition entityDefinition, FieldInfo[] fieldInfoArray, EntityFacadeImpl efi, TransactionCache txCache, EntityConditionImplBase queryCondition, ArrayList obf) { this.efi = efi; this.con = con; this.rs = rs; this.entityDefinition = entityDefinition; fieldInfoListSize = fieldInfoArray.length; this.fieldInfoArray = fieldInfoArray; this.queryCondition = queryCondition; this.txCache = txCache; if (txCache != null && queryCondition != null) { orderByComparator = obf != null && obf.size() > 0 ? new CollectionUtilities.MapOrderByComparator(obf) : null; // add all created values (updated and deleted values will be handled by the next() method findAugmentInfo = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), queryCondition); if (findAugmentInfo.valueListSize > 0) { // update the order if we know the order by field list if (orderByComparator != null) findAugmentInfo.valueList.sort(orderByComparator); txcListSize = findAugmentInfo.valueListSize; } else { txcListSize = 0; } } else { findAugmentInfo = null; txcListSize = 0; orderByComparator = null; } // capture the current artifact stack for finalize not closed debugging, has minimal performance impact (still ~0.0038ms per call compared to numbers below) artifactStack = efi.ecfi.getEci().artifactExecutionFacade.getStackArray(); /* uncomment only if needed temporarily: huge performance impact, ~0.036ms per call with, ~0.0037ms without (~10x difference!) StackTraceElement[] tempStack = Thread.currentThread().getStackTrace(); if (tempStack.length > 20) tempStack = java.util.Arrays.copyOfRange(tempStack, 0, 20); constructStack = tempStack; */ } @Override public void close() { if (this.closed) { logger.warn("EntityListIterator for entity [" + this.entityDefinition.getFullEntityName() + "] is already closed, not closing again"); } else { if (rs != null) { try { rs.close(); } catch (SQLException e) { throw new EntityException("Could not close ResultSet in EntityListIterator", e); } } if (con != null) { try { con.close(); } catch (SQLException e) { throw new EntityException("Could not close Connection in EntityListIterator", e); } } /* leaving commented as might be useful for future con pool debugging: try { def dataSource = efi.getDatasourceFactory(entityDefinition.getEntityGroupName()).getDataSource() logger.warn("=========== elii after close pool available size: ${dataSource.poolAvailableSize()}/${dataSource.poolTotalSize()}; ${dataSource.getMinPoolSize()}-${dataSource.getMaxPoolSize()}") } catch (Throwable t) { logger.warn("========= pool size error ${t.toString()}") } */ this.closed = true; } } @Override public void afterLast() { try { rs.afterLast(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to afterLast", e); } txcListIndex = txcListSize; } @Override public void beforeFirst() { txcListIndex = -1; try { rs.beforeFirst(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to beforeFirst", e); } } @Override public boolean last() { if (txcListSize > 0) { try { rs.afterLast(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to last", e); } txcListIndex = txcListSize - 1; return true; } else { try { return rs.last(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to last", e); } } } @Override public boolean first() { txcListIndex = -1; try { return rs.first(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to first", e); } } @Override public EntityValue currentEntityValue() { return currentEntityValueBase(); } public EntityValueBase currentEntityValueBase() { if (txcListIndex >= 0) { return findAugmentInfo.valueList.get(txcListIndex); } EntityValueImpl newEntityValue = new EntityValueImpl(entityDefinition, efi); LiteStringMap valueMap = newEntityValue.valueMapInternal; for (int i = 0; i < fieldInfoListSize; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; fi.getResultSetValue(rs, i + 1, valueMap, efi); } // if txCache in place always put in cache for future reference (onePut handles any stale from DB issues too) if (txCache != null) txCache.onePut(newEntityValue, false); haveMadeValue = true; return newEntityValue; } @Override public int currentIndex() { try { return rs.getRow() + txcListIndex + 1; } catch (SQLException e) { throw new EntityException("Error getting current index", e); } } @Override public boolean absolute(final int rowNum) { // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go if (txcListSize > 0) throw new EntityException("Cannot go to absolute row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); try { return rs.absolute(rowNum); } catch (SQLException e) { throw new EntityException("Error going to absolute row number " + rowNum, e); } } @Override public boolean relative(final int rows) { // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go if (txcListSize > 0) throw new EntityException("Cannot go to relative row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); try { return rs.relative(rows); } catch (SQLException e) { throw new EntityException("Error moving relative rows " + rows, e); } } @Override public boolean hasNext() { try { if (rs.isLast() || rs.isAfterLast()) { return txcListIndex < (txcListSize - 1); } else { // if not in the first or beforeFirst positions and haven't made any values yet, the result set is empty return !(!haveMadeValue && !rs.isBeforeFirst() && !rs.isFirst()); } } catch (SQLException e) { throw new EntityException("Error while checking to see if there is a next result", e); } } @Override public boolean hasPrevious() { try { if (rs.isFirst() || rs.isBeforeFirst()) { return false; } else { // if not in the last or afterLast positions and we haven't made any values yet, the result set is empty return !(!haveMadeValue && !rs.isAfterLast() && !rs.isLast()); } } catch (SQLException e) { throw new EntityException("Error while checking to see if there is a previous result", e); } } @Override public EntityValue next() { // first try the txcList if we are in it if (txcListIndex >= 0) { if (txcListIndex >= txcListSize) return null; txcListIndex++; if (txcListIndex >= txcListSize) return null; return currentEntityValue(); } // not in txcList, try the DB try { if (rs.next()) { EntityValueBase evb = currentEntityValueBase(); if (txCache != null) { EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo); // if deleted skip this value if (writeMode == EntityJavaUtil.WriteMode.DELETE) return next(); } return evb; } else { if (txcListSize > 0) { // txcListIndex should be -1, but instead of incrementing set to 0 just to make sure txcListIndex = 0; return currentEntityValue(); } else { return null; } } } catch (SQLException e) { throw new EntityException("Error getting next result", e); } } @Override public int nextIndex() { return currentIndex() + 1; } @Override public EntityValue previous() { // first try the txcList if we are in it if (txcListIndex >= 0) { txcListIndex--; if (txcListIndex >= 0) return currentEntityValue(); } try { if (rs.previous()) { EntityValueBase evb = (EntityValueBase) currentEntityValue(); if (txCache != null) { EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo); // if deleted skip this value if (writeMode == EntityJavaUtil.WriteMode.DELETE) return this.previous(); } return evb; } else { return null; } } catch (SQLException e) { throw new EntityException("Error getting previous result", e); } } @Override public int previousIndex() { return currentIndex() - 1; } @Override public void setFetchSize(int rows) { try { rs.setFetchSize(rows); } catch (SQLException e) { throw new EntityException("Error setting fetch size", e); } } @Override public EntityList getCompleteList(boolean closeAfter) { try { // move back to before first if we need to if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst(); EntityList list = new EntityListImpl(efi); EntityValue value; while ((value = next()) != null) list.add(value); if (findAugmentInfo != null) { // all created, updated, and deleted values will be handled by the next() method // update the order if we know the order by field list if (orderByComparator != null) list.sort(orderByComparator); } return list; } catch (SQLException e) { throw new EntityException("Error getting all results", e); } finally { //TODO: Remove closeAfter with respect to try-with-resource implementation if (closeAfter) close(); } } @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) { // TODO: somehow handle txcList after DB list? same issue as absolute() and relative() methods if (txcListSize > 0) throw new EntityException("Cannot get partial list when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); try { EntityList list = new EntityListImpl(this.efi); if (limit == 0) return list; // list is 1 based if (offset == 0) offset = 1; // jump to start index, or just get the first result if (!this.absolute(offset)) { // not that many results, get empty list return list; } // get the first as the current one list.add(this.currentEntityValue()); int numberSoFar = 1; EntityValue nextValue; while (limit > numberSoFar && (nextValue = this.next()) != null) { list.add(nextValue); numberSoFar++; } return list; } finally { if (closeAfter) close(); } } @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { int recordsWritten = 0; try { // move back to before first if we need to if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst(); EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels); } catch (SQLException e) { throw new EntityException("Error writing XML for all results", e); } return recordsWritten; } @Override public int writeXmlTextMaster(Writer writer, String prefix, String masterName) { int recordsWritten = 0; try { // move back to before first if we need to if (haveMadeValue && !rs.isBeforeFirst()) rs.beforeFirst(); EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName); } catch (SQLException e) { throw new EntityException("Error writing XML for all results", e); } return recordsWritten; } @Override public void remove() { // TODO: call EECAs try { efi.getEntityCache().clearCacheForValue((EntityValueBase) currentEntityValue(), false); rs.deleteRow(); } catch (SQLException e) { throw new EntityException("Error removing row", e); } } @Override public void set(EntityValue e) { throw new BaseArtifactException("EntityListIterator.set() not currently supported"); // TODO implement this // TODO: call EECAs // TODO: notify cache clear } @Override public void add(EntityValue e) { throw new BaseArtifactException("EntityListIterator.add() not currently supported"); // TODO implement this } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityListIteratorWrapper.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.BaseArtifactException; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityList; import org.moqui.entity.EntityListIterator; import org.moqui.entity.EntityValue; import org.moqui.impl.context.TransactionCache; import org.moqui.util.CollectionUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Writer; import java.util.ArrayList; import java.util.List; class EntityListIteratorWrapper implements EntityListIterator { protected static final Logger logger = LoggerFactory.getLogger(EntityListIteratorWrapper.class); protected EntityFacadeImpl efi; private List valueList; private int internalIndex = -1; private EntityDefinition entityDefinition; /** This is needed to determine if the ResultSet is empty as cheaply as possible. */ private boolean haveMadeValue = false; protected boolean closed = false; EntityListIteratorWrapper(List valList, EntityDefinition entityDefinition, EntityFacadeImpl efi, EntityCondition queryCondition, ArrayList obf) { valueList = new ArrayList<>(valList); this.efi = efi; this.entityDefinition = entityDefinition; TransactionCache txCache = efi.ecfi.transactionFacade.getTransactionCache(); if (txCache != null && queryCondition != null) { // add all created values (updated and deleted values will be handled by the next() method EntityJavaUtil.FindAugmentInfo tempFai = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), queryCondition); if (tempFai.valueListSize > 0) { // remove update values already in list if (tempFai.foundUpdated.size() > 0) { for (int i = 0; i < valueList.size(); ) { EntityValue ev = valueList.get(i); if (tempFai.foundUpdated.contains(ev.getPrimaryKeys())) { valueList.remove(i); } else { i++; } } } valueList.addAll(tempFai.valueList); // update the order if we know the order by field list if (obf != null && obf.size() > 0) valueList.sort(new CollectionUtilities.MapOrderByComparator(obf)); } } } @Override public void close() { if (this.closed) { logger.warn("EntityListIterator for entity " + entityDefinition.fullEntityName + " is already closed, not closing again"); } else { this.closed = true; } } @Override public void afterLast() { this.internalIndex = valueList.size(); } @Override public void beforeFirst() { internalIndex = -1; } @Override public boolean last() { internalIndex = (valueList.size() - 1); return true; } @Override public boolean first() { internalIndex = 0; return true; } @Override public EntityValue currentEntityValue() { this.haveMadeValue = true; return valueList.get(internalIndex); } @Override public int currentIndex() { return internalIndex; } @Override public boolean absolute(int rowNum) { internalIndex = rowNum; return !(internalIndex < 0 || internalIndex >= valueList.size()); } @Override public boolean relative(int rows) { internalIndex += rows; return !(internalIndex < 0 || internalIndex >= valueList.size()); } @Override public boolean hasNext() { return internalIndex < (valueList.size() - 1); } @Override public boolean hasPrevious() { return internalIndex > 0; } @Override public EntityValue next() { if (internalIndex >= valueList.size()) return null; internalIndex++; if (internalIndex >= valueList.size()) return null; return currentEntityValue(); } @Override public int nextIndex() { return internalIndex + 1; } @Override public EntityValue previous() { if (internalIndex < 0) return null; internalIndex--; if (internalIndex < 0) return null; return currentEntityValue(); } @Override public int previousIndex() { return internalIndex - 1; } @Override public void setFetchSize(int rows) {/* do nothing, just ignore */} @Override public EntityList getCompleteList(boolean closeAfter) { try { EntityList list = new EntityListImpl(efi); EntityValue value; while ((value = this.next()) != null) list.add(value); return list; } finally { if (closeAfter) close(); } } @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) { try { EntityList list = new EntityListImpl(this.efi); if (limit == 0) return list; // jump to start index, or just get the first result if (!this.absolute(offset)) { // not that many results, get empty list return list; } // get the first as the current one list.add(this.currentEntityValue()); int numberSoFar = 1; EntityValue nextValue; while (limit > numberSoFar && (nextValue = this.next()) != null) { list.add(nextValue); numberSoFar++; } return list; } finally { if (closeAfter) close(); } } @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { int recordsWritten = 0; if (haveMadeValue && internalIndex != -1) internalIndex = -1; EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels); return recordsWritten; } @Override public int writeXmlTextMaster(Writer writer, String prefix, String masterName) { int recordsWritten = 0; if (haveMadeValue && internalIndex != -1) internalIndex = -1; EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName); return recordsWritten; } @Override public void remove() { throw new BaseArtifactException("EntityListIteratorWrapper.remove() not currently supported"); // TODO implement this // TODO: call EECAs // TODO: notify cache clear } @Override public void set(EntityValue e) { throw new BaseArtifactException("EntityListIteratorWrapper.set() not currently supported"); // TODO implement this // TODO: call EECAs // TODO: notify cache clear } @Override public void add(EntityValue e) { throw new BaseArtifactException("EntityListIteratorWrapper.add() not currently supported"); // TODO implement this } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityQueryBuilder.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.entity.EntityException; import org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter; import org.moqui.impl.entity.EntityJavaUtil.FieldOrderOptions; import org.moqui.util.LiteStringMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; public class EntityQueryBuilder implements Runnable { protected static final Logger logger = LoggerFactory.getLogger(EntityQueryBuilder.class); static final boolean isDebugEnabled = logger.isDebugEnabled(); public final EntityFacadeImpl efi; public final EntityDefinition mainEntityDefinition; private static final int sqlInitSize = 500; public final StringBuilder sqlTopLevel = new StringBuilder(sqlInitSize); String finalSql = null; private static final int parametersInitSize = 20; public final ArrayList parameters = new ArrayList<>(parametersInitSize); protected PreparedStatement ps = null; private ResultSet rs = null; protected Connection connection = null; private boolean externalConnection = false; private boolean isFindOne = false; boolean execWithTimeout = false; // cur tx timeout set in constructor long execTimeout = 60000; public EntityQueryBuilder(EntityDefinition entityDefinition, EntityFacadeImpl efi) { this.mainEntityDefinition = entityDefinition; this.efi = efi; execWithTimeout = efi.ecfi.transactionFacade.getUseStatementTimeout(); if (execWithTimeout) this.execTimeout = efi.ecfi.transactionFacade.getTxTimeoutRemainingMillis(); } public EntityDefinition getMainEd() { return mainEntityDefinition; } Connection makeConnection(boolean useClone) { connection = efi.getConnection(mainEntityDefinition.getEntityGroupName(), useClone); return connection; } void useConnection(Connection c) { connection = c; externalConnection = true; } public void isFindOne() { isFindOne = true; } protected static void handleSqlException(Exception e, String sql) { throw new EntityException("SQL Exception with statement:" + sql + "; " + e.toString(), e); } public PreparedStatement makePreparedStatement() { if (connection == null) throw new IllegalStateException("Cannot make PreparedStatement, no Connection in place"); finalSql = sqlTopLevel.toString(); // if (this.mainEntityDefinition.getFullEntityName().contains("foo")) logger.warn("========= making crud PreparedStatement for SQL: ${sql}") if (isDebugEnabled) logger.debug("making crud PreparedStatement for SQL: " + finalSql); try { ps = connection.prepareStatement(finalSql); } catch (SQLException sqle) { handleSqlException(sqle, finalSql); } return ps; } // ======== execute methods + Runnable Throwable uncaughtThrowable = null; Boolean execQuery = null; int rowsUpdated = -1; public void run() { if (execQuery == null) { logger.warn("Called run() with no execQuery flag set, ignoring", new Exception("run call location")); return; } try { final long timeBefore = isDebugEnabled ? System.currentTimeMillis() : 0L; if (execQuery) { rs = ps.executeQuery(); if (isDebugEnabled) logger.debug("Executed query with SQL [" + finalSql + "] and parameters [" + parameters + "] in [" + ((System.currentTimeMillis() - timeBefore) / 1000) + "] seconds"); } else { rowsUpdated = ps.executeUpdate(); if (isDebugEnabled) logger.debug("Executed update with SQL [" + finalSql + "] and parameters [" + parameters + "] in [" + ((System.currentTimeMillis() - timeBefore) / 1000) + "] seconds changing [" + rowsUpdated + "] rows"); } } catch (Throwable t) { uncaughtThrowable = t; } } ResultSet executeQuery() throws SQLException { if (ps == null) throw new IllegalStateException("Cannot Execute Query, no PreparedStatement in place"); boolean isError = false; boolean queryStats = !isFindOne && efi.getQueryStats(); long beforeQuery = queryStats ? System.nanoTime() : 0; execQuery = true; if (execWithTimeout) { try { Future execFuture = efi.statementExecutor.submit(this); // if (execTimeout != 60000L) logger.info("statement with timeout " + execTimeout); execFuture.get(execTimeout, TimeUnit.MILLISECONDS); } catch (Exception e) { uncaughtThrowable = e; } } else { run(); } if (rs == null && uncaughtThrowable == null) uncaughtThrowable = new SQLException("JDBC query timed out after " + execTimeout + "ms for SQL " + finalSql); try { if (uncaughtThrowable != null) { isError = true; if (uncaughtThrowable instanceof SQLException) { throw (SQLException) uncaughtThrowable; } else { throw new SQLException("Error in JDBC query for SQL " + finalSql, uncaughtThrowable); } } } finally { if (queryStats) efi.saveQueryStats(mainEntityDefinition, finalSql, System.nanoTime() - beforeQuery, isError); } return rs; } int executeUpdate() throws SQLException { if (ps == null) throw new IllegalStateException("Cannot Execute Update, no PreparedStatement in place"); // NOTE 20200704: removed query stat tracking for updates // boolean isError = false; // boolean queryStats = efi.getQueryStats(); // long beforeQuery = queryStats ? System.nanoTime() : 0; execQuery = false; if (execWithTimeout) { try { Future execFuture = efi.statementExecutor.submit(this); execFuture.get(execTimeout, TimeUnit.MILLISECONDS); } catch (Exception e) { uncaughtThrowable = e; } } else { run(); } if (rowsUpdated == -1 && uncaughtThrowable == null) uncaughtThrowable = new SQLException("JDBC update timed out after " + execTimeout + "ms for SQL " + finalSql); // try { if (uncaughtThrowable != null) { // isError = true; if (uncaughtThrowable instanceof SQLException) { throw (SQLException) uncaughtThrowable; } else { throw new SQLException("Error in JDBC update for SQL " + finalSql, uncaughtThrowable); } } // } finally { // if (queryStats) efi.saveQueryStats(mainEntityDefinition, finalSql, System.nanoTime() - beforeQuery, isError); // } return rowsUpdated; } /** NOTE: this should be called in a finally clause to make sure things are closed */ void closeAll() throws SQLException { if (ps != null) { ps.close(); ps = null; } if (rs != null) { rs.close(); rs = null; } if (connection != null && !externalConnection) { connection.close(); connection = null; } } /** For when closing to be done in other places, like a EntityListIteratorImpl */ void releaseAll() { ps = null; rs = null; connection = null; } public static String sanitizeColumnName(String colName) { StringBuilder interim = new StringBuilder(colName); boolean lastUnderscore = false; for (int i = 0; i < interim.length(); ) { char curChar = interim.charAt(i); if (Character.isLetterOrDigit(curChar)) { i++; lastUnderscore = false; } else { if (lastUnderscore) { interim.deleteCharAt(i); } else { interim.setCharAt(i, '_'); i++; lastUnderscore = true; } } } while (interim.charAt(0) == '_') interim.deleteCharAt(0); while (interim.charAt(interim.length() - 1) == '_') interim.deleteCharAt(interim.length() - 1); int duIdx; while ((duIdx = interim.indexOf("__")) >= 0) interim.deleteCharAt(duIdx); return interim.toString(); } void setPreparedStatementValue(int index, Object value, FieldInfo fieldInfo) throws EntityException { fieldInfo.setPreparedStatementValue(this.ps, index, value, this.mainEntityDefinition, this.efi); } void setPreparedStatementValues() { // set all of the values from the SQL building in efb ArrayList parms = parameters; int size = parms.size(); for (int i = 0; i < size; i++) { EntityConditionParameter entityConditionParam = parms.get(i); entityConditionParam.setPreparedStatementValue(i + 1); } } public void makeSqlSelectFields(FieldInfo[] fieldInfoArray, FieldOrderOptions[] fieldOptionsArray, boolean addUniqueAs) { int size = fieldInfoArray.length; if (size > 0) { if (fieldOptionsArray == null && mainEntityDefinition.entityInfo.allFieldInfoArray.length == size) { String allFieldsSelect = mainEntityDefinition.entityInfo.allFieldsSqlSelect; if (allFieldsSelect != null) { sqlTopLevel.append(mainEntityDefinition.entityInfo.allFieldsSqlSelect); return; } } for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; if (i > 0) sqlTopLevel.append(", "); boolean appendCloseParen = false; if (fieldOptionsArray != null) { FieldOrderOptions foo = fieldOptionsArray[i]; if (foo != null && foo.getCaseUpperLower() != null && fi.typeValue == 1) { sqlTopLevel.append(foo.getCaseUpperLower() ? "UPPER(" : "LOWER("); appendCloseParen = true; } } String fullColName = fi.getFullColumnName(); sqlTopLevel.append(fullColName); if (appendCloseParen) sqlTopLevel.append(")"); // H2 (and perhaps other DBs?) require a unique name for each selected column, even if not used elsewhere; seems like a bug... if (addUniqueAs && fullColName.contains(".")) sqlTopLevel.append(" AS ").append(sanitizeColumnName(fullColName)); } } else { sqlTopLevel.append("*"); } } public void addWhereClause(FieldInfo[] pkFieldArray, LiteStringMap valueMapInternal) { sqlTopLevel.append(" WHERE "); int sizePk = pkFieldArray.length; for (int i = 0; i < sizePk; i++) { FieldInfo fieldInfo = pkFieldArray[i]; if (fieldInfo == null) break; if (i > 0) sqlTopLevel.append(" AND "); sqlTopLevel.append(fieldInfo.getFullColumnName()).append("=?"); parameters.add(new EntityJavaUtil.EntityConditionParameter(fieldInfo, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), this)); } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntitySqlException.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.entity.EntityException import java.sql.SQLException /** Wrap an SqlException for more user friendly error messages */ class EntitySqlException extends EntityException { // NOTE these are the messages to localize with LocalizedMessage // NOTE: don't change these unless there is a really good reason, will break localization private static Map messageBySqlCode = [ '22':'invalid data', // data exception '22001':'text value too long', // VALUE_TOO_LONG, char/varchar/etc (aka right truncation) '22003':'number too big', // NUMERIC_VALUE_OUT_OF_RANGE '22004':'empty value not allowed', // null value not allowed '22018':'text value could not be converted', // DATA_CONVERSION_ERROR, invalid character value for cast '23':'record already exists or related record does not exist', // integrity constraint violation, most likely problems '23502':'empty value not allowed', // NULL_NOT_ALLOWED '23503':'tried to delete record that other records refer to or record specified does not exist', // REFERENTIAL_INTEGRITY_VIOLATED_CHILD_EXISTS (in update or delete would orphan FK) // NOTE: Postgres uses 23503 for parent and child fk violations, other DBs too? use same message for both '23505':'record already exists', // DUPLICATE_KEY '23506':'record specified does not exist', // REFERENTIAL_INTEGRITY_VIOLATED_PARENT_MISSING (in insert or update invalid FK reference) '40':'record lock conflict found', // transaction rollback '40001':'record lock conflict found', // DEADLOCK - serialization failure '40002':'record lock conflict found', // integrity constraint violation '40P01':'record lock conflict found', // postgres deadlock_detected '50200':'timeout waiting for record lock', // LOCK_TIMEOUT H2 '57033':'record lock conflict found', // DB2 deadlock without automatic rollback 'HY':'timeout waiting for database', // lock or other timeout; is this really correct for this 2 letter code? 'HY000':'timeout waiting for record lock', // lock or other timeout 'HYT00':'timeout waiting for record lock', // lock or other timeout (H2) // NOTE MySQL uses HY000 for a LOT of stuff, lock timeout distinguished by error code 1205 ] /* see: https://www.h2database.com/javadoc/org/h2/api/ErrorCode.html https://dev.mysql.com/doc/refman/5.7/en/error-messages-server.html https://www.postgresql.org/docs/current/static/errcodes-appendix.html https://www.ibm.com/support/knowledgecenter/SSEPEK_12.0.0/codes/src/tpc/db2z_sqlstatevalues.html */ private String sqlState = null EntitySqlException(String str, SQLException nested) { super(str, nested) getSQLState(nested) } @Override String getMessage() { String overrideMessage = super.getMessage() if (sqlState != null) { // try full string String msg = messageBySqlCode.get(sqlState) // try first 2 chars if (msg == null && sqlState.length() >= 2) msg = messageBySqlCode.get(sqlState.substring(0,2)) // localize and append if (msg != null) { try { ExecutionContext ec = Moqui.getExecutionContext() // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only) // overrideMessage += ': ' + ec.l10n.localize(msg) overrideMessage += ': ' + msg } catch (Throwable t) { System.out.println("Error localizing override message " + t.toString()) } } } overrideMessage += ' [' + sqlState + ']' return overrideMessage } @Override String toString() { return getMessage() } String getSQLState() { return sqlState } String getSQLState(SQLException ex) { if (sqlState != null) return sqlState sqlState = ex.getSQLState() if (sqlState == null) { SQLException nestedEx = ex.getNextException() if (nestedEx != null) sqlState = nestedEx.getSQLState() } return sqlState } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityValueBase.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.moqui.Moqui; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.context.ExecutionContext; import org.moqui.entity.EntityException; import org.moqui.entity.EntityFind; import org.moqui.entity.EntityList; import org.moqui.entity.EntityValue; import org.moqui.impl.context.*; import org.moqui.impl.context.ContextJavaUtil.EntityRecordLock; import org.moqui.util.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Document; import org.w3c.dom.Element; import javax.annotation.Nonnull; import javax.sql.rowset.serial.SerialBlob; import javax.sql.rowset.serial.SerialException; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.io.Writer; import java.math.BigDecimal; import java.sql.SQLException; import java.sql.Connection; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; import java.util.*; public abstract class EntityValueBase implements EntityValue { private static final long serialVersionUID = -4935076967225824138L; protected static final Logger logger = LoggerFactory.getLogger(EntityValueBase.class); // these error strings are here for convenience for LocalizedMessage records // NOTE: don't change these unless there is a really good reason, will break localization private static final String CREATE_ERROR = "Error creating ${entityName} ${primaryKeys}"; private static final String UPDATE_ERROR = "Error updating ${entityName} ${primaryKeys}"; private static final String DELETE_ERROR = "Error deleting ${entityName} ${primaryKeys}"; private static final String REFRESH_ERROR = "Error finding ${entityName} ${primaryKeys}"; private String entityName; protected final LiteStringMap valueMapInternal; private transient EntityFacadeImpl efiTransient = null; private transient TransactionCache txCacheInternal = null; private transient EntityDefinition entityDefinitionTransient = null; protected transient LiteStringMap dbValueMap = null; protected transient LiteStringMap oldDbValueMap = null; private transient Map> localizedByLocaleByField = null; private transient Set touchedFields = null; private transient boolean modified = false; private transient boolean mutable = true; private transient boolean isFromDb = false; private static final String indentString = " "; /** Default constructor for deserialization ONLY. */ public EntityValueBase() { valueMapInternal = new LiteStringMap<>().useManualIndex(); } public EntityValueBase(EntityDefinition ed, EntityFacadeImpl efip) { efiTransient = efip; entityName = ed.fullEntityName; entityDefinitionTransient = ed; valueMapInternal = new LiteStringMap<>(ed.allFieldNameList.size()).useManualIndex(); } @Override public void writeExternal(ObjectOutput out) throws IOException { // NOTE: found that the serializer in Hazelcast is slow with writeUTF(), uses String.charAt() in a for loop // NOTE2: in Groovy this results in castToType() overhead anyway, so for now use writeUTF/readUTF as other serialization might be more efficient out.writeUTF(entityName); out.writeObject(valueMapInternal); } @SuppressWarnings("unchecked") @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { entityName = objectInput.readUTF(); LiteStringMap lsm; try { lsm = (LiteStringMap) objectInput.readObject(); } catch (Throwable t) { logger.error("Error deserializing fields Map for entity " + entityName, t); throw t; } FieldInfo[] fieldInfos = getEntityDefinition().entityInfo.allFieldInfoArray; valueMapInternal.ensureCapacity(fieldInfos.length); for (int i = 0; i < fieldInfos.length; i++) { FieldInfo fieldInfo = fieldInfos[i]; int oldIndex = lsm.findIndexIString(fieldInfo.name); if (oldIndex == -1) continue; valueMapInternal.putByIString(fieldInfo.name, lsm.getValue(oldIndex), fieldInfo.index); } } protected EntityFacadeImpl getEntityFacadeImpl() { // handle null after deserialize; this requires a static reference in Moqui.java or we'll get an error if (efiTransient == null) { ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory(); if (ecfi == null) throw new EntityException("No ExecutionContextFactory found, cannot get EntityFacade for new EVB for entity " + entityName); efiTransient = ecfi.entityFacade; } return efiTransient; } private TransactionCache getTxCache(ExecutionContextFactoryImpl ecfi) { if (txCacheInternal == null) txCacheInternal = ecfi.transactionFacade.getTransactionCache(); return txCacheInternal; } public EntityDefinition getEntityDefinition() { if (entityDefinitionTransient == null) entityDefinitionTransient = getEntityFacadeImpl().getEntityDefinition(entityName); return entityDefinitionTransient; } public LiteStringMap getValueMap() { return valueMapInternal; } protected LiteStringMap getDbValueMap() { return dbValueMap; } protected void setDbValueMap(Map map) { FieldInfo[] allFields = getEntityDefinition().entityInfo.allFieldInfoArray; dbValueMap = new LiteStringMap<>(allFields.length).useManualIndex(); // copy all fields, including pk to fix false positives in the old approach of only non-pk fields for (int i = 0; i < allFields.length; i++) { FieldInfo fi = allFields[i]; if (!map.containsKey(fi.name)) continue; Object curValue = map.get(fi.name); dbValueMap.putByIString(fi.name, curValue, fi.index); if (!valueMapInternal.containsKeyIString(fi.name, fi.index)) valueMapInternal.putByIString(fi.name, curValue, fi.index); } isFromDb = true; } public void setSyncedWithDb() { oldDbValueMap = dbValueMap; dbValueMap = null; modified = false; isFromDb = true; } public boolean getIsFromDb() { return isFromDb; } @Override public String resolveEntityName() { return entityName; } @Override public String resolveEntityNamePretty() { return StringUtilities.camelCaseToPretty(getEntityDefinition().getEntityName()); } @Override public boolean isModified() { return modified; } @Override public boolean isFieldModified(String name) { if (name == null) return false; return isFieldModifiedIString(name.intern()); } private boolean isFieldModifiedIString(String name) { int valueMapIdx = valueMapInternal.findIndexIString(name); if (valueMapIdx == -1) return false; if (touchedFields != null && touchedFields.contains(name)) return true; if (dbValueMap == null) return true; int dbIdx = dbValueMap.findIndexIString(name); if (dbIdx == -1) return true; Object valueMapValue = valueMapInternal.getValue(valueMapIdx); Object dbValue = dbValueMap.getValue(dbIdx); return (valueMapValue == null && dbValue != null) || (valueMapValue != null && !valueMapValue.equals(dbValue)); /* if (!valueMapInternal.containsKey(name)) return false; if (dbValueMap == null || !dbValueMap.containsKey(name)) return true; Object valueMapValue = valueMapInternal.get(name); Object dbValue = dbValueMap.get(name); return (valueMapValue == null && dbValue != null) || (valueMapValue != null && !valueMapValue.equals(dbValue)); */ } @Override public EntityValue touchField(String name) { if (!getEntityDefinition().isField(name)) throw new IllegalArgumentException("Cannot touch field name " + name + ", does not exist on entity " + entityName); modified = true; if (touchedFields == null) touchedFields = new HashSet<>(); touchedFields.add(name); return this; } @Override public boolean isFieldSet(String name) { return valueMapInternal.containsKey(name); } @Override public boolean isField(String name) { return getEntityDefinition().isField(name); } @Override public boolean isMutable() { return mutable; } public void setFromCache() { mutable = false; } @Override public Map getMap() { // call get() for each field for localization, etc Map theMap = new LinkedHashMap<>(); EntityDefinition ed = getEntityDefinition(); FieldInfo[] allFieldInfos = ed.entityInfo.allFieldInfoArray; int allFieldInfosSize = allFieldInfos.length; for (int i = 0; i < allFieldInfosSize; i++) { FieldInfo fieldInfo = allFieldInfos[i]; Object fieldValue = getKnownField(fieldInfo); // NOTE DEJ20151117 also put nulls in Map, make more complete, removed: if (fieldValue != null) theMap.put(fieldInfo.name, fieldValue); } if (ed.isViewEntity) { Map pqExpressionNodeMap = ed.getPqExpressionNodeMap(); if (pqExpressionNodeMap != null) for (String fieldName : pqExpressionNodeMap.keySet()) { theMap.put(fieldName, get(fieldName)); } } return theMap; } @Override public Object get(final String name) { EntityDefinition ed = getEntityDefinition(); FieldInfo fieldInfo = ed.getFieldInfo(name); if (fieldInfo != null) return getKnownField(fieldInfo); // if this is not a valid field name but is a valid relationship name, do a getRelated or getRelatedOne to return an EntityList or an EntityValue EntityJavaUtil.RelationshipInfo relInfo = ed.getRelationshipInfo(name); // logger.warn("====== get related relInfo: ${relInfo}") if (relInfo != null) { if (relInfo.isTypeOne) { return this.findRelatedOne(name, null, null); } else { return this.findRelated(name, null, null, null, null); } } // special case, see if this is a alias with a pq-expression, if so evaluate if (ed.isViewEntity) { MNode pqExprNode = ed.getPqExpressionNode(name); if (pqExprNode != null) { String pqExpression = pqExprNode.attribute("pq-expression"); try { EntityFacadeImpl efi = getEntityFacadeImpl(); return efi.ecfi.resourceFacade.expression(pqExpression, null, valueMapInternal); } catch (Throwable t) { throw new EntityException("Error evaluating pq-expression for " + entityName + "." + name, t); } } } // logger.warn("========== relInfo Map keys: ${ed.getRelationshipInfoMap().keySet()}, relInfoList: ${ed.getRelationshipsInfo(false)}") throw new EntityException("The name [" + name + "] is not a valid field name or relationship name for entity " + entityName); } public Object getKnownField(FieldInfo fieldInfo) { EntityDefinition ed = fieldInfo.ed; // if this is a simple field (is field, no l10n, not user field) just get the value right away (vast majority of use) if (fieldInfo.isSimple) return valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index); // if enabled use moqui.basic.LocalizedEntityField for any localized fields if (fieldInfo.enableLocalization) { String name = fieldInfo.name; Locale locale = getEntityFacadeImpl().ecfi.getEci().userFacade.getLocale(); String localeStr = locale != null ? locale.toString() : null; if (localeStr != null) { Object internalValue = valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index); boolean knownNoLocalized = false; if (localizedByLocaleByField == null) { localizedByLocaleByField = new HashMap<>(); } else { Map localizedByLocale = localizedByLocaleByField.get(name); if (localizedByLocale != null) { String cachedLocalized = localizedByLocale.get(localeStr); if (cachedLocalized != null && cachedLocalized.length() > 0) { // logger.warn("======== field ${name}:${internalValue} found cached localized ${cachedLocalized}") return cachedLocalized; } else { // logger.warn("======== field ${name}:${internalValue} known no localized") knownNoLocalized = localizedByLocale.containsKey(localeStr); } } } if (!knownNoLocalized) { List pks; MNode aliasNode = null; String memberEntityName = null; if (ed.isViewEntity && !ed.entityInfo.isDynamicView) { // NOTE: there are issues with dynamic view entities here, may be possible to fix them but for now not running for EntityDynamicView aliasNode = ed.getFieldNode(name); memberEntityName = ed.getMemberEntityName(aliasNode.attribute("entity-alias")); EntityDefinition memberEd = getEntityFacadeImpl().getEntityDefinition(memberEntityName); pks = memberEd.getPkFieldNames(); } else { pks = ed.getPkFieldNames(); } if (pks.size() == 1) { String pk = pks.get(0); if (aliasNode != null) { pk = null; Map pkToAliasMap = ed.getMePkFieldToAliasNameMap(aliasNode.attribute("entity-alias")); Set pkSet = pkToAliasMap.keySet(); if (pkSet.size() == 1) pk = pkToAliasMap.get(pkSet.iterator().next()); } String pkValue = pk != null ? (String) valueMapInternal.get(pk) : null; if (pkValue != null) { // logger.warn("======== field ${name}:${internalValue} finding LocalizedEntityField, localizedByLocaleByField=${localizedByLocaleByField}") String entityName = ed.getFullEntityName(); String fieldName = name; if (aliasNode != null) { entityName = memberEntityName; final String fieldAttr = aliasNode.attribute("field"); fieldName = fieldAttr != null && !fieldAttr.isEmpty() ? fieldAttr : aliasNode.attribute("name"); // logger.warn("localizing field for ViewEntity ${ed.fullEntityName} field ${name}, using entityName: ${entityName}, fieldName: ${fieldName}, pkValue: ${pkValue}, locale: ${localeStr}") } EntityFind lefFind = getEntityFacadeImpl().find("moqui.basic.LocalizedEntityField") .condition("entityName", entityName).condition("fieldName", fieldName) .condition("pkValue", pkValue).condition("locale", localeStr); EntityValue lefValue = lefFind.useCache(true).one(); if (lefValue != null) { String localized = (String) lefValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } // no result found, try with shortened locale if (localeStr.contains("_")) { lefFind.condition("locale", localeStr.substring(0, localeStr.indexOf("_"))); lefValue = lefFind.useCache(true).one(); if (lefValue != null) { String localized = (String) lefValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } } // no result found, try "default" locale lefFind.condition("locale", "default"); lefValue = lefFind.useCache(true).one(); if (lefValue != null) { String localized = (String) lefValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } } } // no luck? try getting a localized value from moqui.basic.LocalizedMessage // logger.warn("======== field ${name}:${internalValue} finding LocalizedMessage") EntityFind lmFind = getEntityFacadeImpl().find("moqui.basic.LocalizedMessage") .condition("original", internalValue).condition("locale", localeStr); EntityValue lmValue = lmFind.useCache(true).one(); if (lmValue != null) { String localized = (String) lmValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } if (localeStr.contains("_")) { lmFind.condition("locale", localeStr.substring(0, localeStr.indexOf("_"))); lmValue = lmFind.useCache(true).one(); if (lmValue != null) { String localized = (String) lmValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } } lmFind.condition("locale", "default"); lmValue = lmFind.useCache(true).one(); if (lmValue != null) { String localized = (String) lmValue.get("localized"); CollectionUtilities.addToMapInMap(name, localeStr, localized, localizedByLocaleByField); return localized; } // we didn't find a localized value, remember that so we don't do the queries again (common case) CollectionUtilities.addToMapInMap(name, localeStr, null, localizedByLocaleByField); // logger.warn("======== field ${name}:${internalValue} remembering no localized, localizedByLocaleByField=${localizedByLocaleByField}") } return internalValue; } } return valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index); } @Override public Object getNoCheckSimple(String name) { return valueMapInternal.get(name); } @Override public Object getOriginalDbValue(String name) { return (dbValueMap != null && dbValueMap.containsKey(name)) ? dbValueMap.get(name) : valueMapInternal.get(name); } protected Object getOldDbValue(String name) { if (oldDbValueMap != null && oldDbValueMap.containsKey(name)) return oldDbValueMap.get(name); return getOriginalDbValue(name); } @Override public boolean containsPrimaryKey() { return this.getEntityDefinition().containsPrimaryKey(valueMapInternal); } @Override public Map getPrimaryKeys() { /* don't use cached internalPkMap, would have to make sure to capture all set, put, setFields, setFieldsEv, etc to invalidate otherwise may be stale * is just as fast to recreate by index gets on valueMapInternal vs cloning the cached LiteStringMap protected transient LiteStringMap internalPkMap = null; if (internalPkMap != null) return new LiteStringMap(internalPkMap); internalPkMap = getEntityDefinition().getPrimaryKeys(this.valueMapInternal); return new LiteStringMap(internalPkMap); */ FieldInfo[] pkFieldInfos = getEntityDefinition().entityInfo.pkFieldInfoArray; LiteStringMap pks = new LiteStringMap<>(pkFieldInfos.length); for (int i = 0; i < pkFieldInfos.length; i++) { FieldInfo fi = pkFieldInfos[i]; pks.putByIString(fi.name, this.valueMapInternal.getByIString(fi.name, fi.index)); } return pks; } @Override public String getPrimaryKeysString() { FieldInfo[] pkFieldInfoArray = getEntityDefinition().entityInfo.pkFieldInfoArray; if (pkFieldInfoArray.length == 1) { FieldInfo fi = pkFieldInfoArray[0]; return ObjectUtilities.toPlainString(this.valueMapInternal.getByIString(fi.name, fi.index)); } else { StringBuilder pkCombinedSb = new StringBuilder(); for (int pki = 0; pki < pkFieldInfoArray.length; pki++) { FieldInfo fi = pkFieldInfoArray[pki]; // NOTE: separator of '::' matches separator used for combined PK String in EntityDefinition.getPrimaryKeysString() and EntityDataDocument.makeDocId() if (pkCombinedSb.length() > 0) pkCombinedSb.append("::"); pkCombinedSb.append(ObjectUtilities.toPlainString(this.valueMapInternal.getByIString(fi.name, fi.index))); } return pkCombinedSb.toString(); } } public boolean primaryKeyMatches(EntityValueBase evb) { if (evb == null) return false; FieldInfo[] pkFieldInfos = getEntityDefinition().entityInfo.pkFieldInfoArray; boolean allMatch = true; for (int i = 0; i < pkFieldInfos.length; i++) { FieldInfo pkFi = pkFieldInfos[i]; Object thisValue = valueMapInternal.getByIString(pkFi.name, pkFi.index); Object thatValue = evb.valueMapInternal.getByIString(pkFi.name, pkFi.index); if (thisValue == null) { if (thatValue != null) { allMatch = false; break; } } else { if (!thisValue.equals(thatValue)) { allMatch = false; break; } } } return allMatch; } @Override public EntityValue set(String name, Object value) { put(name, value); return this; } @Override public EntityValue setAll(Map fields) { if (!mutable) throw new EntityException("Cannot set fields, this entity value is not mutable (it is read-only)"); getEntityDefinition().entityInfo.setFieldsEv(fields, this, null); return this; } @Override public EntityValue setString(String name, String value) { // this will do a field name check ExecutionContextImpl eci = getEntityFacadeImpl().ecfi.getEci(); FieldInfo fi = getEntityDefinition().getFieldInfo(name); Object converted = fi.convertFromString(value, eci.l10nFacade); putKnownField(fi, converted); return this; } @Override public Boolean getBoolean(String name) { return DefaultGroovyMethods.asType(get(name), Boolean.class); } @Override public String getString(String name) { EntityDefinition ed = getEntityDefinition(); FieldInfo fieldInfo = ed.getFieldInfo(name); Object valueObj = getKnownField(fieldInfo); return fieldInfo.convertToString(valueObj); } @Override public Timestamp getTimestamp(String name) { return DefaultGroovyMethods.asType(get(name), Timestamp.class); } @Override public Time getTime(String name) { return DefaultGroovyMethods.asType(this.get(name), Time.class); } @Override public java.sql.Date getDate(String name) { return DefaultGroovyMethods.asType(this.get(name), Date.class); } @Override public Long getLong(String name) { return DefaultGroovyMethods.asType(this.get(name), Long.class); } @Override public Double getDouble(String name) { return DefaultGroovyMethods.asType(this.get(name), Double.class); } @Override public BigDecimal getBigDecimal(String name) { return DefaultGroovyMethods.asType(this.get(name), BigDecimal.class); } @Override public byte[] getBytes(String name) { Object o = this.get(name); if (o == null) return null; if (o instanceof SerialBlob) { try { if (((SerialBlob) o).length() == 0) return new byte[0]; return ((SerialBlob) o).getBytes(1, (int) ((SerialBlob) o).length()); } catch (Exception e) { throw new EntityException("Error getting bytes for field " + name + " in entity " + entityName, e); } } if (o instanceof byte[]) return (byte[]) o; // try groovy... return DefaultGroovyMethods.asType(o, byte[].class); } @Override public EntityValue setBytes(String name, byte[] theBytes) { try { if (theBytes != null) set(name, new SerialBlob(theBytes)); } catch (Exception e) { throw new EntityException("Error setting bytes for field " + name + " in entity " + entityName, e); } return this; } @Override public SerialBlob getSerialBlob(String name) { Object o = this.get(name); if (o == null) return null; if (o instanceof SerialBlob) return (SerialBlob) o; try { if (o instanceof byte[]) return new SerialBlob((byte[]) o); } catch (Exception e) { throw new EntityException("Error getting SerialBlob for field " + name + " in entity " + entityName, e); } // try groovy... return DefaultGroovyMethods.asType(o, SerialBlob.class); } @Override public EntityValue setFields(Map fields, boolean setIfEmpty, String namePrefix, Boolean pks) { if (!setIfEmpty && (namePrefix == null || namePrefix.length() == 0)) { getEntityDefinition().entityInfo.setFields(fields, this, false, namePrefix, pks); } else { getEntityDefinition().entityInfo.setFieldsEv(fields, this, pks); } return this; } @Override public EntityValue setSequencedIdPrimary() { EntityDefinition ed = getEntityDefinition(); EntityFacadeImpl localEfi = getEntityFacadeImpl(); // get the entity-specific prefix, support string expansion for it too String entityPrefix = null; String rawPrefix = ed.entityInfo.sequencePrimaryPrefix; if (rawPrefix != null && rawPrefix.length() > 0) entityPrefix = localEfi.ecfi.resourceFacade.expand(rawPrefix, null, valueMapInternal); String sequenceValue = localEfi.sequencedIdPrimaryEd(ed); putKnownField(ed.entityInfo.pkFieldInfoArray[0], entityPrefix != null ? entityPrefix + sequenceValue : sequenceValue); return this; } @Override public EntityValue setSequencedIdSecondary() { EntityDefinition ed = getEntityDefinition(); List pkFields = ed.getPkFieldNames(); if (pkFields.size() < 2) throw new EntityException("Cannot call setSequencedIdSecondary() on entity " + entityName + ", must have at least 2 primary key fields."); // sequenced field will be the last pk final String seqFieldName = pkFields.get(pkFields.size() - 1); String paddedLengthStr = ed.getEntityNode().attribute("sequence-secondary-padded-length"); int paddedLength = 2; if (paddedLengthStr != null && paddedLengthStr.length() > 0) paddedLength = Integer.valueOf(paddedLengthStr); this.remove(seqFieldName); Map otherPkMap = new LinkedHashMap<>(); getEntityDefinition().entityInfo.setFields(this, otherPkMap, false, null, true); // temporarily disable authz for this, just doing lookup to get next value and to allow for a // authorize-skip="create" with authorize-skip of view too this is necessary EntityFind ef = getEntityFacadeImpl().find(resolveEntityName()).selectField(seqFieldName).condition(otherPkMap); // logger.warn("TOREMOVE in setSequencedIdSecondary ef WHERE=${ef.getWhereEntityCondition()}") EntityList allValues = ef.disableAuthz().list(); Integer highestSeqVal = null; for (EntityValue curValue : allValues) { final String currentSeqId = (String) curValue.getNoCheckSimple(seqFieldName); if (currentSeqId != null && !currentSeqId.isEmpty()) { try { int seqVal = Integer.parseInt(currentSeqId); if (highestSeqVal == null || seqVal > highestSeqVal) highestSeqVal = seqVal; } catch (Exception e) { logger.warn("Error in secondary sequenced ID converting SeqId [" + currentSeqId + "] in field [" + seqFieldName + "] from entity [" + resolveEntityName() + "] to a number: " + e.toString()); } } } int seqValToUse = highestSeqVal != null ? highestSeqVal + 1 : 1; this.set(seqFieldName, StringUtilities.paddedNumber(seqValToUse, paddedLength)); return this; } @Override public int compareTo(EntityValue that) { // nulls go earlier // not needed? IDE says never null: if (that == null) return -1; // first entity names int result = entityName.compareTo(that.resolveEntityName()); if (result != 0) return result; // next compare all fields (will compare PK fields first, generally first in list) ArrayList allFieldNames = getEntityDefinition().getAllFieldNames(); int allFieldNamesSize = allFieldNames.size(); for (int i = 0; i < allFieldNamesSize; i++) { String pkFieldName = allFieldNames.get(i); result = compareFields(that, pkFieldName); if (result != 0) return result; } // all the same, result should be 0 return result; } @SuppressWarnings("unchecked") private int compareFields(EntityValue that, String name) { Comparable thisVal = (Comparable) this.valueMapInternal.getByString(name); Comparable thatVal = (Comparable) that.get(name); // NOTE: nulls go earlier in the list if (thisVal == null) { return thatVal == null ? 0 : 1; } else { return (thatVal == null ? -1 : thisVal.compareTo(thatVal)); } } @Override public boolean mapMatches(Map theMap) { boolean matches = true; for (Entry entry : theMap.entrySet()) { if (!entry.getValue().equals(this.valueMapInternal.getByString(entry.getKey()))) { matches = false; break; } } return matches; } @Override public EntityValue createOrUpdate() { EntityDefinition ed = getEntityDefinition(); boolean pkModified = false; if (isFromDb) { pkModified = (ed.getPrimaryKeys(this.valueMapInternal).equals(ed.getPrimaryKeys(this.dbValueMap))); } else { // make sure PK fields with defaults are filled in BEFORE doing the refresh to see if it exists checkSetFieldDefaults(getEntityDefinition(), getEntityFacadeImpl().ecfi.getEci(), true); } // logger.warn("createOrUpdate isFromDb " + isFromDb + " pkModified " + pkModified); if ((isFromDb && !pkModified) || this.cloneValue().refresh()) { return update(); } else { return create(); } } @Override public EntityValue store() { return createOrUpdate(); } private void handleAuditLog(boolean isUpdate, LiteStringMap oldValues, EntityDefinition ed, ExecutionContextImpl ec) { if ((isUpdate && oldValues == null) || !ed.entityInfo.needsAuditLog || ec.artifactExecutionFacade.entityAuditLogDisabled()) return; Timestamp nowTimestamp = ec.userFacade.getNowTimestamp(); LiteStringMap pksValueMap = new LiteStringMap<>(ed.entityInfo.pkFieldInfoArray.length).useManualIndex(); addThreeFieldPkValues(pksValueMap, ed); FieldInfo[] fieldInfoList = ed.entityInfo.allFieldInfoArray; for (int i = 0; i < fieldInfoList.length; i++) { FieldInfo fieldInfo = fieldInfoList[i]; boolean isLogUpdate = "update".equals(fieldInfo.enableAuditLog); if ((!isLogUpdate && "true".equals(fieldInfo.enableAuditLog)) || (isUpdate && isLogUpdate)) { String fieldName = fieldInfo.name; // is there a new value? if not continue if (!this.valueMapInternal.containsKeyIString(fieldInfo.name, fieldInfo.index)) continue; Object value = getKnownField(fieldInfo); Object oldValue = oldValues != null ? oldValues.getByIString(fieldInfo.name, fieldInfo.index) : null; // if set to log updates and old value is null don't consider it an update (is initial set of value) if (isLogUpdate && oldValue == null) continue; if (isUpdate) { // if isUpdate but old value == new value, then it hasn't been updated, so skip it if (value == null) { if (oldValue == null) continue; } else { if (value instanceof BigDecimal && oldValue instanceof BigDecimal) { // better handling for BigDecimal, perhaps others if (((BigDecimal) value).compareTo((BigDecimal) oldValue) == 0) continue; } else { if (value.equals(oldValue)) continue; } } } else { // if it's a create and there is no value don't log a change if (value == null) continue; } // logger.warn("EntityAuditLog field " + fieldName + " old " + oldValue + " (" + (oldValue != null ? oldValue.getClass().getName() : "null") + ") new " + value + " (" + (value != null ? value.getClass().getName() : "null") + ")"); // don't skip for this, if a field was reset then we want to record that: if (!value) continue // check for a changeReason String changeReason = null; Object changeReasonObj = ec.contextStack.getByString(fieldName.concat("_changeReason")); if (changeReasonObj != null) { changeReason = changeReasonObj.toString(); if (changeReason.isEmpty()) changeReason = null; } String stackNameString = ec.artifactExecutionFacade.getStackNameString(); if (stackNameString.length() > 4000) stackNameString = stackNameString.substring(0, 4000); LinkedHashMap parms = new LinkedHashMap<>(); parms.put("changedEntityName", resolveEntityName()); parms.put("changedFieldName", fieldName); if (changeReason != null) parms.put("changeReason", changeReason); parms.put("changedDate", nowTimestamp); parms.put("changedByUserId", ec.getUser().getUserId()); parms.put("changedInVisitId", ec.getUser().getVisitId()); parms.put("artifactStack", stackNameString); // prep values, encrypt if needed if (value != null) { String newValueText = ObjectUtilities.toPlainString(value); if (fieldInfo.encrypt) newValueText = EntityJavaUtil.enDeCrypt(newValueText, true, ec.getEntityFacade()); if (newValueText.length() > 4000) newValueText = newValueText.substring(0, 4000); parms.put("newValueText", newValueText); } if (oldValue != null) { String oldValueText = ObjectUtilities.toPlainString(oldValue); if (fieldInfo.encrypt) oldValueText = EntityJavaUtil.enDeCrypt(oldValueText, true, ec.getEntityFacade()); if (oldValueText.length() > 4000) oldValueText = oldValueText.substring(0, 4000); parms.put("oldValueText", oldValueText); } // set all pk fields by name to support EntityAuditLog extensions for specific pk fields, will usually all get ignored parms.putAll(pksValueMap); // logger.warn("TOREMOVE: in handleAuditLog for [${ed.entityName}.${fieldName}] value=[${value}], oldValue=[${oldValue}], oldValues=[${oldValues}]", new Exception("AuditLog location")) // NOTE: if this is changed to async the time zone on nowTimestamp gets messed up (user's time zone lost) getEntityFacadeImpl().ecfi.serviceFacade.sync().name("create#moqui.entity.EntityAuditLog") .parameters(parms).disableAuthz().call(); } } } private void addThreeFieldPkValues(Map parms, EntityDefinition ed) { // get pkPrimaryValue, pkSecondaryValue, pkRestCombinedValue (just like the AuditLog stuff) ArrayList pkFieldList = new ArrayList<>(); Collections.addAll(pkFieldList, ed.entityInfo.pkFieldInfoArray); FieldInfo firstPkField = pkFieldList.size() > 0 ? pkFieldList.remove(0) : null; FieldInfo secondPkField = pkFieldList.size() > 0 ? pkFieldList.remove(0) : null; StringBuilder pkTextSb = new StringBuilder(); for (int i = 0; i < pkFieldList.size(); i++) { FieldInfo curFieldInfo = pkFieldList.get(i); if (i > 0) pkTextSb.append(","); pkTextSb.append(curFieldInfo.name).append(":'") .append(EntityDefinition.getFieldStringForFile(curFieldInfo, getKnownField(curFieldInfo))).append("'"); } String pkText = pkTextSb.toString(); if (firstPkField != null) parms.put("pkPrimaryValue", getKnownField(firstPkField)); if (secondPkField != null) parms.put("pkSecondaryValue", getKnownField(secondPkField)); if (!pkText.isEmpty()) parms.put("pkRestCombinedValue", pkText); } @Override public EntityList findRelated(final String relationshipName, Map byAndFields, List orderBy, Boolean useCache, Boolean forUpdate) { EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName); if (relInfo == null) throw new EntityException("Relationship " + relationshipName + " not found in entity " + entityName); return findRelated(relInfo, byAndFields, orderBy, useCache, forUpdate); } private EntityList findRelated(final EntityJavaUtil.RelationshipInfo relInfo, Map byAndFields, List orderBy, Boolean useCache, Boolean forUpdate) { String relatedEntityName = relInfo.relatedEntityName; Map keyMap = relInfo.keyMap; if (keyMap == null || keyMap.size() == 0) throw new EntityException("Relationship " + relInfo.relationshipName + " in entity " + entityName + " has no key-map sub-elements and no default values"); // make a Map where the key is the related entity's field name, and the value is the value from this entity Map condMap = new HashMap<>(); for (Entry entry : keyMap.entrySet()) condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey())); if (relInfo.keyValueMap != null) { for (Map.Entry keyValueEntry: relInfo.keyValueMap.entrySet()) condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue()); } if (byAndFields != null && byAndFields.size() > 0) condMap.putAll(byAndFields); EntityFind find = getEntityFacadeImpl().find(relatedEntityName); return find.condition(condMap).orderBy(orderBy).useCache(useCache).forUpdate(forUpdate != null ? forUpdate : false).list(); } @Override public EntityValue findRelatedOne(final String relationshipName, Boolean useCache, Boolean forUpdate) { EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName); if (relInfo == null) throw new EntityException("Relationship " + relationshipName + " not found in entity " + entityName); return findRelatedOne(relInfo, useCache, forUpdate); } private EntityValue findRelatedOne(final EntityJavaUtil.RelationshipInfo relInfo, Boolean useCache, Boolean forUpdate) { String relatedEntityName = relInfo.relatedEntityName; Map keyMap = relInfo.keyMap; if (keyMap == null || keyMap.size() == 0) throw new EntityException("Relationship " + relInfo.relationshipName + " in entity " + entityName + " has no key-map sub-elements and no default values"); // make a Map where the key is the related entity's field name, and the value is the value from this entity Map condMap = new HashMap<>(); for (Entry entry : keyMap.entrySet()) condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey())); if (relInfo.keyValueMap != null) { for (Map.Entry keyValueEntry: relInfo.keyValueMap.entrySet()) condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue()); } // logger.warn("========== findRelatedOne ${relInfo.relationshipName} keyMap=${keyMap}, condMap=${condMap}") EntityFind find = getEntityFacadeImpl().find(relatedEntityName); return find.condition(condMap).useCache(useCache).forUpdate(forUpdate != null ? forUpdate : false).one(); } @Override public long findRelatedCount(final String relationshipName, Boolean useCache) { EntityJavaUtil.RelationshipInfo relInfo = getEntityDefinition().getRelationshipInfo(relationshipName); if (relInfo == null) throw new EntityException("Relationship " + relationshipName + " not found in entity " + entityName); String relatedEntityName = relInfo.relatedEntityName; Map keyMap = relInfo.keyMap; if (keyMap == null || keyMap.size() == 0) throw new EntityException("Relationship " + relInfo.relationshipName + " in entity " + entityName + " has no key-map sub-elements and no default values"); // make a Map where the key is the related entity's field name, and the value is the value from this entity Map condMap = new HashMap<>(); for (Entry entry : keyMap.entrySet()) condMap.put(entry.getValue(), valueMapInternal.getByString(entry.getKey())); if (relInfo.keyValueMap != null) { for (Map.Entry keyValueEntry: relInfo.keyValueMap.entrySet()) condMap.put(keyValueEntry.getKey(), keyValueEntry.getValue()); } EntityFind find = getEntityFacadeImpl().find(relatedEntityName); return find.condition(condMap).useCache(useCache).count(); } @Override public EntityList findRelatedFk(Set skipEntities) { EntityList relatedList = new EntityListImpl(getEntityFacadeImpl()); ArrayList relInfoList = getEntityDefinition().getRelationshipsInfo(false); int relInfoListSize = relInfoList.size(); for (int i = 0; i < relInfoListSize; i++) { EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i); EntityJavaUtil.RelationshipInfo reverseInfo = relInfo.findReverse(); if (reverseInfo == null || !reverseInfo.isTypeOne || (skipEntities != null && (skipEntities.contains(reverseInfo.fromEd.fullEntityName) || skipEntities.contains(reverseInfo.fromEd.getShortAlias()) || skipEntities.contains(reverseInfo.fromEd.getEntityName())))) continue; EntityList curList = findRelated(relInfo, null, null, null, null); relatedList.addAll(curList); } return relatedList; } @Override public void deleteRelated(String relationshipName) { // NOTE: this does a select for update, may consider not doing that by default EntityList relatedList = findRelated(relationshipName, null, null, false, true); for (EntityValue relatedValue : relatedList) relatedValue.delete(); } @Override public boolean deleteWithRelated(Set relationshipsToDelete) { if (relationshipsToDelete == null) relationshipsToDelete = new HashSet<>(); ArrayList relInfoList = getEntityDefinition().getRelationshipsInfo(false); int relInfoListSize = relInfoList.size(); // look for related records that exist and that we won't delete, if any return true boolean foundNonDeleteRelated = false; for (int i = 0; i < relInfoListSize; i++) { EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i); if (relInfo.isTypeOne) continue; if (relationshipsToDelete.contains(relInfo.shortAlias) || relationshipsToDelete.contains(relInfo.relationshipName)) continue; if (findRelatedCount(relInfo.relationshipName, false) > 0) { if (logger.isInfoEnabled()) logger.info("Not deleting entity " + entityName + " value with PK " + getPrimaryKeys() + ", found record in relationship " + relInfo.relationshipName); foundNonDeleteRelated = true; break; } } if (foundNonDeleteRelated) return false; // delete related records to delete for (String delRelName : relationshipsToDelete) deleteRelated(delRelName); // delete this record delete(); // done, successful delete return true; } @Override public void deleteWithCascade(Set clearRefEntities, Set validateAllowDeleteEntities) { ArrayList relInfoList = getEntityDefinition().getRelationshipsInfo(false); int relInfoListSize = relInfoList.size(); for (int i = 0; i < relInfoListSize; i++) { // find relationships with a type one reverse (relationships for records that depend on this) EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(i); EntityJavaUtil.RelationshipInfo reverseInfo = relInfo.findReverse(); if (reverseInfo == null || !reverseInfo.isTypeOne) continue; // see if we should clear ref fields or delete EntityDefinition relEd = relInfo.relatedEd; boolean clearRef = clearRefEntities != null && (clearRefEntities.contains(relEd.fullEntityName) || clearRefEntities.contains(relEd.getShortAlias()) || clearRefEntities.contains(relEd.getEntityName())); // find records EntityList relList = findRelated(relInfo, null, null, null, null); int relListSize = relList.size(); for (int j = 0; j < relListSize; j++) { EntityValue relVal = relList.get(j); if (clearRef) { for (String fieldName : reverseInfo.keyMap.keySet()) { if (relEd.isPkField(fieldName)) throw new EntityException("In deleteWithCascade on entity " + resolveEntityName() + " related entity " + relEd.fullEntityName + " is in the clear ref set but field " + fieldName + " is a primary key field and cannot be cleared"); relVal.set(fieldName, null); } relVal.update(); } else { // if we should validate entities we are attempting to delete do that now if (validateAllowDeleteEntities != null && !validateAllowDeleteEntities.contains(relEd.fullEntityName)) throw new EntityException("Cannot delete " + resolveEntityNamePretty() + " " + getPrimaryKeys() + ", found " + relVal.resolveEntityNamePretty() + " " + relVal.getPrimaryKeys() + " that depends on it"); // delete with cascade relVal.deleteWithCascade(clearRefEntities, validateAllowDeleteEntities); } } } // delete this record delete(); } @Override public boolean checkFks(boolean insertDummy) { boolean noneMissing = true; ExecutionContextImpl ec = getEntityFacadeImpl().ecfi.getEci(); for (EntityJavaUtil.RelationshipInfo relInfo : getEntityDefinition().getRelationshipsInfo(false)) { if (!"one".equals(relInfo.type)) continue; EntityValue value = findRelatedOne(relInfo, false, false); // if (resolveEntityName().contains("foo")) logger.info("Checking fk " + resolveEntityName() + ':' + relInfo.relationshipName + " value: " + value); if (value == null) { if (insertDummy) { noneMissing = false; EntityValue newValue = relInfo.relatedEd.makeEntityValue(); if (relInfo.relatedEd.entityInfo.hasFieldDefaults && newValue instanceof EntityValueBase) ((EntityValueBase) newValue).checkSetFieldDefaults(relInfo.relatedEd, ec, null); Map keyMap = relInfo.keyMap; if (keyMap == null || keyMap.isEmpty()) throw new EntityException("Relationship " + relInfo.relationshipName + " in entity " + entityName + " has no key-map sub-elements and no default values"); // make a Map where the key is the related entity's field name, and the value is the value from this entity for (Entry entry : keyMap.entrySet()) newValue.set(entry.getValue(), valueMapInternal.getByString(entry.getKey())); if (newValue.containsPrimaryKey()) { newValue.checkFks(true); newValue.create(); logger.warn("Created dummy " + newValue.resolveEntityName() + " PK " + newValue.getPrimaryKeys()); } } else { return false; } } } return noneMissing; } @Override @SuppressWarnings("unchecked") public long checkAgainstDatabaseInfo(List> diffInfoList, List messages, String location) { long fieldsChecked = 0; try { EntityValue dbValue = this.cloneValue(); if (!dbValue.refresh()) { Map diffInfo = new HashMap<>(); diffInfo.put("entity", resolveEntityName()); diffInfo.put("pk", getPrimaryKeys()); diffInfo.put("createValues", getValueMap()); diffInfo.put("notFound", true); diffInfo.put("pkComplete", containsPrimaryKey()); diffInfo.put("location", location); diffInfoList.add(diffInfo); // alternative object based, more efficient but way less convenient: diffInfoList.add(new EntityJavaUtil.EntityValueDiffInfo(resolveEntityName(), getPrimaryKeys())); return 0; } for (String nonpkFieldName : this.getEntityDefinition().getNonPkFieldNames()) { // skip the lastUpdatedStamp field if ("lastUpdatedStamp".equals(nonpkFieldName)) continue; final Object checkFieldValue = this.get(nonpkFieldName); final Object dbFieldValue = dbValue.get(nonpkFieldName); // use compareTo if available, generally more lenient (for BigDecimal ignores scale, etc) if (checkFieldValue != null) { boolean areSame = true; if (checkFieldValue instanceof Comparable && dbFieldValue != null) { Comparable cfComp = (Comparable) checkFieldValue; if (cfComp.compareTo(dbFieldValue) != 0) areSame = false; } else { if (!checkFieldValue.equals(dbFieldValue)) areSame = false; } if (!areSame) { Map diffInfo = new HashMap<>(); diffInfo.put("entity", resolveEntityName()); diffInfo.put("pk", getPrimaryKeys()); diffInfo.put("field", nonpkFieldName); diffInfo.put("value", checkFieldValue); diffInfo.put("dbValue", dbFieldValue); diffInfo.put("notFound", false); diffInfo.put("pkComplete", containsPrimaryKey()); diffInfo.put("location", location); diffInfoList.add(diffInfo); // alternative object based, more efficient but way less convenient: diffInfoList.add(new EntityJavaUtil.EntityValueDiffInfo(resolveEntityName(), getPrimaryKeys(), nonpkFieldName, checkFieldValue, dbFieldValue)); } } fieldsChecked++; } } catch (EntityException e) { throw e; } catch (Throwable t) { String errMsg = "Error checking entity " + resolveEntityName() + " with pk " + getPrimaryKeys() + ": " + t.toString(); if (messages != null) messages.add(errMsg); logger.error(errMsg, t); } return fieldsChecked; } @Override @SuppressWarnings("unchecked") public long checkAgainstDatabase(List messages) { long fieldsChecked = 0; try { EntityValue dbValue = this.cloneValue(); if (!dbValue.refresh()) { messages.add("Entity " + resolveEntityName() + " record not found for primary key " + getPrimaryKeys()); return 0; } for (String nonpkFieldName : this.getEntityDefinition().getNonPkFieldNames()) { // skip the lastUpdatedStamp field if ("lastUpdatedStamp".equals(nonpkFieldName)) continue; final Object checkFieldValue = this.get(nonpkFieldName); final Object dbFieldValue = dbValue.get(nonpkFieldName); // use compareTo if available, generally more lenient (for BigDecimal ignores scale, etc) if (checkFieldValue != null) { boolean areSame = true; if (checkFieldValue instanceof Comparable && dbFieldValue != null) { Comparable cfComp = (Comparable) checkFieldValue; if (cfComp.compareTo(dbFieldValue) != 0) areSame = false; } else { if (!checkFieldValue.equals(dbFieldValue)) areSame = false; } if (!areSame) messages.add("Field " + resolveEntityName() + "." + nonpkFieldName + " did not match; check (file) value [" + checkFieldValue + "], db value [" + dbFieldValue + "] for primary key " + getPrimaryKeys()); } fieldsChecked++; } } catch (EntityException e) { throw e; } catch (Throwable t) { String errMsg = "Error checking entity " + resolveEntityName() + " with pk " + getPrimaryKeys() + ": " + t.toString(); messages.add(errMsg); logger.error(errMsg, t); } return fieldsChecked; } @Override public Element makeXmlElement(Document document, String prefix) { if (prefix == null) prefix = ""; Element element = null; if (document != null) element = document.createElement(prefix + entityName); if (element == null) return null; for (String fieldName : getEntityDefinition().getAllFieldNames()) { String value = getString(fieldName); if (value != null && !value.isEmpty()) { if (value.contains("\n") || value.contains("\r")) { Element childElement = document.createElement(fieldName); element.appendChild(childElement); childElement.appendChild(document.createCDATASection(value)); } else { element.setAttribute(fieldName, value); } } } return element; } @Override public int writeXmlText(Writer pw, String prefix, int dependentLevels) { Map plainMap = getPlainValueMap(dependentLevels); EntityDefinition ed = getEntityDefinition(); try { return plainMapXmlWriter(pw, prefix, ed.getShortOrFullEntityName(), plainMap, 1); } catch (Exception e) { throw new EntityException("Error writing XML test for entity " + entityName + " dependent levels " + dependentLevels); } } @Override public int writeXmlTextMaster(Writer pw, String prefix, String masterName) { Map plainMap = getMasterValueMap(masterName); EntityDefinition ed = getEntityDefinition(); try { return plainMapXmlWriter(pw, prefix, ed.getShortOrFullEntityName(), plainMap, 1); } catch (Exception e) { throw new EntityException("Error writing XML test for entity " + entityName + " master " + masterName); } } @SuppressWarnings("unchecked") private static int plainMapXmlWriter(Writer pw, String prefix, String objectName, Map plainMap, int level) throws IOException, SerialException { if (prefix == null) prefix = ""; // if a CDATA element is needed for a field it goes in this Map to be added at the end Map cdataMap = new LinkedHashMap<>(); Map subPlainMap = new LinkedHashMap<>(); String curEntity = objectName != null && objectName.length() > 0 ? objectName : (String) plainMap.get("_entity"); for (int i = 0; i < level; i++) pw.append(indentString); // mostly for relationship names, see opposite code in the EntityDataLoaderImpl.startElement if (curEntity.contains("#")) curEntity = curEntity.replace("#", "-"); pw.append("<").append(prefix).append(curEntity); int valueCount = 1; for (Entry entry : plainMap.entrySet()) { String fieldName = entry.getKey(); // leave this out, not needed for XML where the element name represents the entity or relationship if ("_entity".equals(fieldName)) continue; Object fieldValue = entry.getValue(); if (fieldValue instanceof Map || fieldValue instanceof List) { subPlainMap.put(fieldName, fieldValue); continue; } else if (fieldValue instanceof byte[]) { cdataMap.put(fieldName, Base64.getEncoder().encodeToString((byte[]) fieldValue)); continue; } else if (fieldValue instanceof SerialBlob) { if (((SerialBlob) fieldValue).length() == 0) continue; byte[] objBytes = ((SerialBlob) fieldValue).getBytes(1, (int) ((SerialBlob) fieldValue).length()); cdataMap.put(fieldName, Base64.getEncoder().encodeToString(objBytes)); continue; } String valueStr = ObjectUtilities.toPlainString(fieldValue); if (valueStr == null || valueStr.isEmpty()) continue; if (valueStr.contains("\n") || valueStr.contains("\r") || valueStr.length() > 255) { cdataMap.put(fieldName, valueStr); continue; } pw.append(" ").append(fieldName).append("=\""); pw.append(StringUtilities.encodeForXmlAttribute(valueStr)).append("\""); } if (cdataMap.size() == 0 && subPlainMap.size() == 0) { // self-close the entity element pw.append("/>\n"); } else { pw.append(">\n"); // CDATA sub-elements for (Entry entry : cdataMap.entrySet()) { pw.append(indentString).append(indentString); pw.append("<").append(entry.getKey()).append(">"); pw.append(""); pw.append("\n"); } // related/dependent sub-elements for (Entry entry : subPlainMap.entrySet()) { final String entryKey = entry.getKey(); Object entryVal = entry.getValue(); if (entryVal instanceof List) { for (Object listEntry : (List) entryVal) { if (listEntry instanceof Map) { valueCount += plainMapXmlWriter(pw, prefix, entryKey, (Map) listEntry, level + 1); } else { logger.warn("In entity auto create for entity " + curEntity + " found list for sub-object " + entryKey + " with a non-Map entry: " + String.valueOf(listEntry)); } } } else if (entryVal instanceof Map) { valueCount += plainMapXmlWriter(pw, prefix, entryKey, (Map) entryVal, level + 1); } } // close the entity element for (int i = 0; i < level; i++) pw.append(indentString); pw.append("\n"); } return valueCount; } @Override public Map getPlainValueMap(int dependentLevels) { return internalPlainValueMap(dependentLevels, null); } private Map internalPlainValueMap(int dependentLevels, Set parentPkFields) { Map vMap = new HashMap<>(valueMapInternal); CollectionUtilities.removeNullsFromMap(vMap); if (parentPkFields != null) for (String pkField : parentPkFields) vMap.remove(pkField); EntityDefinition ed = getEntityDefinition(); vMap.put("_entity", ed.getShortOrFullEntityName()); if (dependentLevels > 0) { Set curPkFields = new HashSet<>(ed.getPkFieldNames()); // keep track of all parent PK field names, even not part of this entity's PK, they will be inherited when read if (parentPkFields != null) curPkFields.addAll(parentPkFields); List relInfoList = getEntityDefinition().getRelationshipsInfo(true); for (EntityJavaUtil.RelationshipInfo relInfo : relInfoList) { String relationshipName = relInfo.relationshipName; final String alias = relInfo.shortAlias; String entryName = alias != null && !alias.isEmpty() ? alias : relationshipName; if (relInfo.isTypeOne) { EntityValue relEv = findRelatedOne(relationshipName, null, false); if (relEv != null) vMap.put(entryName, ((EntityValueBase) relEv).internalPlainValueMap(dependentLevels - 1, curPkFields)); } else { EntityList relList = findRelated(relationshipName, null, null, null, false); if (relList != null && !relList.isEmpty()) { List plainRelList = new ArrayList<>(); for (EntityValue relEv : relList) { plainRelList.add(((EntityValueBase) relEv).internalPlainValueMap(dependentLevels - 1, curPkFields)); } vMap.put(entryName, plainRelList); } } } } return vMap; } @Override public Map getMasterValueMap(final String name) { EntityDefinition.MasterDefinition masterDefinition = getEntityDefinition().getMasterDefinition(name); if (masterDefinition == null) throw new EntityException("No master definition found for name [" + name + "] in entity [" + entityName + "]"); return internalMasterValueMap(masterDefinition.getDetailList(), null, null); } private Map internalMasterValueMap(ArrayList detailList, Set parentPkFields, EntityJavaUtil.RelationshipInfo parentRelInfo) { Map vMap = new HashMap<>(valueMapInternal); CollectionUtilities.removeNullsFromMap(vMap); if (parentPkFields != null) { if (parentRelInfo != null) { // handle cases like the Product toAssocs relationship where ProductAssoc.productId != Product.productId, needs to look at relationship field map for (String pkField : parentPkFields) { String relatedName = parentRelInfo.keyMap.get(pkField); if (pkField.equals(relatedName)) vMap.remove(pkField); } } else { for (String pkField : parentPkFields) vMap.remove(pkField); } } EntityDefinition ed = getEntityDefinition(); vMap.put("_entity", ed.getShortOrFullEntityName()); if (detailList != null && !detailList.isEmpty()) { Set curPkFields = new HashSet<>(ed.getPkFieldNames()); // keep track of all parent PK field names, even not part of this entity's PK, they will be inherited when read if (parentPkFields != null) curPkFields.addAll(parentPkFields); int detailListSize = detailList.size(); for (int i = 0; i < detailListSize; i++) { EntityDefinition.MasterDetail detail = detailList.get(i); EntityJavaUtil.RelationshipInfo relInfo = detail.getRelInfo(); String relationshipName = relInfo.relationshipName; final String relAlias = relInfo.shortAlias; String entryName = relAlias != null && !relAlias.isEmpty() ? relAlias : relationshipName; if (relInfo.isTypeOne) { EntityValue relEv = findRelatedOne(relationshipName, null, false); if (relEv != null) vMap.put(entryName, ((EntityValueBase) relEv).internalMasterValueMap(detail.getDetailList(), curPkFields, relInfo)); } else { EntityList relList = findRelated(relationshipName, null, null, null, false); if (relList != null && !relList.isEmpty()) { List plainRelList = new ArrayList<>(); int relListSize = relList.size(); for (int rlIndex = 0; rlIndex < relListSize; rlIndex++) { EntityValue relEv = relList.get(rlIndex); plainRelList.add(((EntityValueBase) relEv).internalMasterValueMap(detail.getDetailList(), curPkFields, relInfo)); } vMap.put(entryName, plainRelList); } } } } return vMap; } @Override public int size() { return valueMapInternal.size(); } @Override public boolean isEmpty() { return valueMapInternal.isEmpty(); } @Override public boolean containsKey(Object o) { return valueMapInternal.containsKey(o); } @Override public boolean containsValue(Object o) { return values().contains(o); } @Override public Object get(Object o) { if (o instanceof CharSequence) { // This may throw an exception, and let it; the Map interface doesn't provide for EntityException // but it is far more useful than a log message that is likely to be ignored. return this.get(o.toString()); } else { return null; } } @Override public Object put(final String name, Object value) { FieldInfo fieldInfo = getEntityDefinition().getFieldInfo(name); if (fieldInfo == null) throw new EntityException("The field name " + name + " is not valid for entity " + entityName); return putKnownField(fieldInfo, value); } public Object putNoCheck(final String name, Object value) { // NOTE: for performance with LiteStringMap this is no longer useful, and invalid field names not allowed, so just use put() FieldInfo fieldInfo = getEntityDefinition().getFieldInfo(name); if (fieldInfo == null) throw new EntityException("The field name " + name + " is not valid for entity " + entityName); return putKnownField(fieldInfo, value); } protected Object putKnownField(final FieldInfo fieldInfo, Object value) { if (!mutable) throw new EntityException("Cannot set field " + fieldInfo.name + ", this entity value is not mutable (it is read-only)"); Object curValue = null; if (isFromDb) { curValue = valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index); if (curValue == null) { if (value != null) modified = true; } else { if (!curValue.equals(value)) { modified = true; if (dbValueMap == null) dbValueMap = new LiteStringMap<>(getEntityDefinition().allFieldNameList.size()).useManualIndex(); dbValueMap.putByIString(fieldInfo.name, curValue, fieldInfo.index); } } } else { modified = true; } valueMapInternal.putByIString(fieldInfo.name, value, fieldInfo.index); return curValue; } @Override public Object remove(Object o) { if (o instanceof CharSequence) { String name = o.toString(); if (valueMapInternal.containsKey(name)) modified = true; return valueMapInternal.remove(name); } else { return null; } } @Override public void putAll(Map map) { for (Entry entry : map.entrySet()) { String key = (String) entry.getKey(); if (key == null) continue; put(key, entry.getValue()); } } @Override public void clear() { modified = true; valueMapInternal.clear(); } @Override public @Nonnull Set keySet() { return new HashSet<>(getEntityDefinition().getAllFieldNames()); } @Override public @Nonnull Collection values() { // everything needs to go through the get method, so iterate through the fields and get the values List allFieldNames = getEntityDefinition().getAllFieldNames(); List values = new ArrayList<>(allFieldNames.size()); for (String fieldName : allFieldNames) values.add(get(fieldName)); return values; } @Override public @Nonnull Set> entrySet() { // everything needs to go through the get method, so iterate through the fields and get the values FieldInfo[] allFieldInfos = getEntityDefinition().entityInfo.allFieldInfoArray; Set> entries = new HashSet<>(); int allFieldInfosSize = allFieldInfos.length; for (int i = 0; i < allFieldInfosSize; i++) { FieldInfo fi = allFieldInfos[i]; entries.add(new EntityFieldEntry(fi, this)); } return entries; } @Override public boolean equals(Object obj) { if (obj == null || !obj.getClass().equals(this.getClass())) return false; // reuse the compare method return this.compareTo((EntityValue) obj) == 0; } // NOTE: consider caching the hash code in the future for performance @Override public int hashCode() { return entityName.hashCode() + valueMapInternal.hashCode(); } @Override public String toString() { return "[" + entityName + ": " + valueMapInternal.toString() + "]"; } @Override public Object clone() { return cloneValue(); } @Override public abstract EntityValue cloneValue(); public abstract EntityValue cloneDbValue(boolean getOld); private boolean doDataFeed(ExecutionContextImpl ec) { if (ec.artifactExecutionFacade.entityDataFeedDisabled()) return false; // skip ArtifactHitBin, causes funny recursion return !"moqui.server.ArtifactHitBin".equals(entityName); } private void checkSetFieldDefaults(EntityDefinition ed, ExecutionContext ec, Boolean pks) { // allow updating a record without specifying default PK fields, so don't check this: if (isCreate) { Map pkDefaults = ed.entityInfo.pkFieldDefaults; if ((pks == null || pks) && pkDefaults != null && pkDefaults.size() > 0) for (Entry entry : pkDefaults.entrySet()) checkSetDefault(entry.getKey(), entry.getValue(), ec); Map nonPkDefaults = ed.entityInfo.nonPkFieldDefaults; if ((pks == null || !pks) && nonPkDefaults != null && nonPkDefaults.size() > 0) for (Entry entry : nonPkDefaults.entrySet()) checkSetDefault(entry.getKey(), entry.getValue(), ec); } private void checkSetDefault(String fieldName, String defaultStr, ExecutionContext ec) { FieldInfo fi = getEntityDefinition().getFieldInfo(fieldName); Object curVal = null; if (valueMapInternal.containsKeyIString(fi.name, fi.index)) { curVal = valueMapInternal.getByIString(fi.name, fi.index); } else if (dbValueMap != null) { curVal = dbValueMap.getByIString(fi.name, fi.index); } if (ObjectUtilities.isEmpty(curVal)) { if (dbValueMap != null) ec.getContext().push(dbValueMap); ec.getContext().push(valueMapInternal); try { Object newVal = ec.getResource().expression(defaultStr, ""); if (newVal != null) valueMapInternal.putByIString(fi.name, newVal, fi.index); } finally { ec.getContext().pop(); if (dbValueMap != null) ec.getContext().pop(); } } } private String makeErrorMsg(String baseMsg, String expandMsg, EntityDefinition ed, ExecutionContextImpl ec) { Map errorContext = new HashMap<>(); errorContext.put("entityName", ed.getEntityName()); errorContext.put("primaryKeys", getPrimaryKeys()); String errorMessage = null; // TODO: need a different approach for localization, getting from DB may not be reliable after an error and may cause other errors (especially with Postgres and the auto rollback only) if (false && !"LocalizedMessage".equals(ed.getEntityName())) { try { errorMessage = ec.resourceFacade.expand(expandMsg, null, errorContext); } catch (Throwable t) { logger.trace("Error expanding error message", t); } } if (errorMessage == null) errorMessage = baseMsg + " " + ed.getEntityName() + " " + getPrimaryKeys(); return errorMessage; } private void registerMutateLock() { final EntityFacadeImpl efi = getEntityFacadeImpl(); final TransactionFacadeImpl tfi = efi.ecfi.transactionFacade; if (!tfi.getUseLockTrack()) return; final EntityDefinition ed = getEntityDefinition(); final ArtifactExecutionFacadeImpl aefi = efi.ecfi.getEci().artifactExecutionFacade; ArrayList stackArray = aefi.getStackArray(); // add EntityRecordLock for this record tfi.registerRecordLock(new EntityRecordLock(ed.getFullEntityName(), this.getPrimaryKeysString(), stackArray)); // add EntityRecordLock for each type one (with FK) relationship where FK fields not null ArrayList relInfoList = ed.getRelationshipsInfo(false); int relInfoListSize = relInfoList.size(); for (int ri = 0; ri < relInfoListSize; ri++) { EntityJavaUtil.RelationshipInfo relInfo = relInfoList.get(ri); if (!relInfo.isFk) continue; String pkString = null; int keyFieldSize = relInfo.keyFieldList.size(); if (keyFieldSize == 1) { String keyFieldName = relInfo.keyFieldList.get(0); FieldInfo fieldInfo = ed.getFieldInfo(keyFieldName); Object keyValue = this.getKnownField(fieldInfo); if (keyValue != null) pkString = ObjectUtilities.toPlainString(keyValue); } else { boolean hasAllValues = true; Map relFieldValues = new HashMap<>(); for (int ki = 0; ki < keyFieldSize; ki++) { String keyFieldName = relInfo.keyFieldList.get(ki); FieldInfo fieldInfo = ed.getFieldInfo(keyFieldName); Object keyValue = this.getKnownField(fieldInfo); if (keyValue == null) { hasAllValues = false; break; } else { // use relInfo.keyMap to get the field name of the PK field on the related entity relFieldValues.put(relInfo.keyMap.get(keyFieldName), keyValue); } } if (hasAllValues) pkString = relInfo.relatedEd.getPrimaryKeysString(relFieldValues); } if (pkString != null) { tfi.registerRecordLock(new EntityRecordLock(relInfo.relatedEd.getFullEntityName(), pkString, stackArray)); } } } @Override public EntityValue create() { final EntityDefinition ed = getEntityDefinition(); final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo; final EntityFacadeImpl efi = getEntityFacadeImpl(); final ExecutionContextFactoryImpl ecfi = efi.ecfi; final ExecutionContextImpl ec = ecfi.getEci(); final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade; // check/set defaults if (entityInfo.hasFieldDefaults) checkSetFieldDefaults(ed, ec, null); // set lastUpdatedStamp final Long time = ecfi.transactionFacade.getCurrentTransactionStartTime(); Long lastUpdatedLong = time != null && time > 0 ? time : System.currentTimeMillis(); FieldInfo lastUpdatedStampInfo = ed.entityInfo.lastUpdatedStampInfo; if (lastUpdatedStampInfo != null && valueMapInternal.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index) == null) valueMapInternal.putByIString(lastUpdatedStampInfo.name, new Timestamp(lastUpdatedLong), lastUpdatedStampInfo.index); // do the artifact push/authz ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_CREATE, "create").setParameters(valueMapInternal); aefi.pushInternal(aei, !entityInfo.authorizeSkipCreate, false); try { // run EECA before rules efi.runEecaRules(entityName, this, "create", true); // do this before the db change so modified flag isn't cleared if (doDataFeed(ec)) efi.getEntityDataFeed().dataFeedCheckAndRegister(this, false, valueMapInternal, null); // if there is not a txCache or the txCache doesn't handle the create, call the abstract method to create the main record TransactionCache curTxCache = getTxCache(ecfi); if (curTxCache == null || !curTxCache.create(this)) { // NOTE: calls basicCreate() instead of createExtended() directly so don't register lock here this.basicCreate(null); } // NOTE: cache clear is the same for create, update, delete; even on create need to clear one cache because it // might have a null value for a previous query attempt efi.getEntityCache().clearCacheForValue(this, true); // save audit log(s) if applicable handleAuditLog(false, null, ed, ec); // run EECA after rules efi.runEecaRules(entityName, this, "create", false); } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error creating", CREATE_ERROR, ed, ec), e); } catch (Exception e) { throw new EntityException(makeErrorMsg("Error creating", CREATE_ERROR, ed, ec), e); } finally { // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit aefi.pop(aei); } return this; } public void basicCreate(Connection con) throws SQLException { EntityDefinition ed = getEntityDefinition(); FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray; FieldInfo[] fieldArray = new FieldInfo[allFieldArray.length]; int size = allFieldArray.length; int fieldArrayIndex = 0; for (int i = 0; i < size; i++) { FieldInfo fi = allFieldArray[i]; if (valueMapInternal.containsKeyIString(fi.name, fi.index)) { fieldArray[fieldArrayIndex] = fi; fieldArrayIndex++; } } // if enabled register locks before operation registerMutateLock(); createExtended(fieldArray, con); } /** * This method should create a corresponding record in the datasource. NOTE: fieldInfoArray may have null values * after valid ones, the length is not the actual number of fields. */ public abstract void createExtended(FieldInfo[] fieldInfoArray, Connection con) throws SQLException; @Override public EntityValue update() { final EntityDefinition ed = getEntityDefinition(); final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo; final EntityFacadeImpl efi = getEntityFacadeImpl(); final ExecutionContextFactoryImpl ecfi = efi.ecfi; final ExecutionContextImpl ec = ecfi.getEci(); final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade; final TransactionCache curTxCache = getTxCache(ecfi); final boolean optimisticLock = entityInfo.optimisticLock; final boolean hasFieldDefaults = entityInfo.hasFieldDefaults; final boolean needsAuditLog = entityInfo.needsAuditLog; final boolean createOnlyAny = entityInfo.createOnly || entityInfo.createOnlyFields; // check/set defaults for pk fields, do this first to fill in optional pk fields if (hasFieldDefaults) checkSetFieldDefaults(ed, ec, true); // if there is one or more DataFeed configs associated with this entity get info about them boolean curDataFeed = doDataFeed(ec); if (curDataFeed) { ArrayList entityInfoList = efi.getEntityDataFeed().getDataFeedEntityInfoList(entityName); if (entityInfoList.size() == 0) curDataFeed = false; } // need actual DB values for various scenarios? get them here if (needsAuditLog || createOnlyAny || curDataFeed || optimisticLock || hasFieldDefaults) { EntityValueBase refreshedValue = (EntityValueBase) this.cloneValue(); refreshedValue.refresh(); this.setDbValueMap(refreshedValue.getValueMap()); } // check/set defaults for non-pk fields, after getting dbValueMap if (hasFieldDefaults) checkSetFieldDefaults(ed, ec, false); // Save original values before anything is changed for DataFeed and audit log LiteStringMap originalValues = dbValueMap != null && !dbValueMap.isEmpty() ? new LiteStringMap<>(dbValueMap).useManualIndex() : null; // do the artifact push/authz ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_UPDATE, "update").setParameters(valueMapInternal); aefi.pushInternal(aei, !entityInfo.authorizeSkipTrue, false); try { // run EECA before rules efi.runEecaRules(entityName, this, "update", true); FieldInfo[] pkFieldArray = entityInfo.pkFieldInfoArray; FieldInfo[] allNonPkFieldArray = entityInfo.nonPkFieldInfoArray; FieldInfo[] nonPkFieldArray = new FieldInfo[allNonPkFieldArray.length]; ArrayList changedCreateOnlyFields = null; boolean modifiedLastUpdatedStamp = false; int size = allNonPkFieldArray.length; int nonPkFieldArrayIndex = 0; for (int i = 0; i < size; i++) { FieldInfo fieldInfo = allNonPkFieldArray[i]; if (isFieldModifiedIString(fieldInfo.name)) { if (fieldInfo.isLastUpdatedStamp) { // more stringent is modified check for lastUpdatedStamp if (dbValueMap == null || dbValueMap.getByIString(fieldInfo.name, fieldInfo.index) == null) continue; modifiedLastUpdatedStamp = true; } nonPkFieldArray[nonPkFieldArrayIndex] = fieldInfo; nonPkFieldArrayIndex++; if (createOnlyAny && fieldInfo.createOnly) { if (changedCreateOnlyFields == null) changedCreateOnlyFields = new ArrayList<>(); changedCreateOnlyFields.add(fieldInfo.name); } } } // if (ed.getEntityName() == "foo") logger.warn("================ evb.update() ${resolveEntityName()} nonPkFieldList=${nonPkFieldList};\nvalueMap=${valueMap};\noldValues=${oldValues}") if (nonPkFieldArrayIndex == 0 || (nonPkFieldArrayIndex == 1 && modifiedLastUpdatedStamp)) { if (logger.isTraceEnabled()) logger.trace("Not doing update on entity with no changed non-PK fields; value=" + this.toString()); return this; } // do this after the empty nonPkFieldList check so that if nothing has changed then ignore the attempt to update if (changedCreateOnlyFields != null && changedCreateOnlyFields.size() > 0) throw new EntityException("Cannot update create-only (immutable) fields " + changedCreateOnlyFields + " on entity " + resolveEntityName()); // check optimistic lock with lastUpdatedStamp; if optimisticLock() dbValueMap will have latest from DB FieldInfo lastUpdatedStampInfo = ed.entityInfo.lastUpdatedStampInfo; if (optimisticLock) { Object valueLus = valueMapInternal.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index); Object dbLus = dbValueMap.getByIString(lastUpdatedStampInfo.name, lastUpdatedStampInfo.index); if (valueLus != null && dbLus != null && !dbLus.equals(valueLus)) throw new EntityException("This record was updated by someone else at " + dbLus + " which was after the version you loaded at " + valueLus + ". Not updating to avoid overwriting data."); } // set lastUpdatedStamp if (!modifiedLastUpdatedStamp && lastUpdatedStampInfo != null) { final Long time = ecfi.transactionFacade.getCurrentTransactionStartTime(); long lastUpdatedLong = time != null && time > 0 ? time : System.currentTimeMillis(); valueMapInternal.putByIString(lastUpdatedStampInfo.name, new Timestamp(lastUpdatedLong), lastUpdatedStampInfo.index); nonPkFieldArray[nonPkFieldArrayIndex] = lastUpdatedStampInfo; // never gets used after this point, but if ever does will need to: nonPkFieldArrayIndex++ } // do this before the db change so modified flag isn't cleared if (curDataFeed) efi.getEntityDataFeed().dataFeedCheckAndRegister(this, true, valueMapInternal, originalValues); // if there is not a txCache or the txCache doesn't handle the update, call the abstract method to update the main record if (curTxCache == null || !curTxCache.update(this)) { // no TX cache update, etc: ready to do actual update // if enabled register locks before operation registerMutateLock(); updateExtended(pkFieldArray, nonPkFieldArray, null); // if ("OrderHeader".equals(ed.getEntityName()) && "55500".equals(valueMapInternal.get("orderId"))) logger.warn("Called updateExtended order " + this.valueMapInternal.toString()); } // clear the entity cache efi.getEntityCache().clearCacheForValue(this, false); // save audit log(s) if applicable if (needsAuditLog) handleAuditLog(true, originalValues, ed, ec); // run EECA after rules efi.runEecaRules(entityName, this, "update", false); } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error updating", UPDATE_ERROR, ed, ec), e); } catch (Exception e) { throw new EntityException(makeErrorMsg("Error updating", UPDATE_ERROR, ed, ec), e); } finally { // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit aefi.pop(aei); } return this; } public void basicUpdate(Connection con) throws SQLException { EntityDefinition ed = getEntityDefinition(); /* Shouldn't need this any more, was from a weird old issue: boolean dbValueMapFromDb = false // it may be that the oldValues map is full of null values because the EntityValue didn't come from the db if (dbValueMap) for (Object val in dbValueMap.values()) if (val != null) { dbValueMapFromDb = true; break } */ FieldInfo[] pkFieldArray = ed.entityInfo.pkFieldInfoArray; FieldInfo[] allNonPkFieldArray = ed.entityInfo.nonPkFieldInfoArray; FieldInfo[] nonPkFieldArray = new FieldInfo[allNonPkFieldArray.length]; int size = allNonPkFieldArray.length; int nonPkFieldArrayIndex = 0; for (int i = 0; i < size; i++) { FieldInfo fi = allNonPkFieldArray[i]; if (isFieldModifiedIString(fi.name)) { nonPkFieldArray[nonPkFieldArrayIndex] = fi; nonPkFieldArrayIndex++; } } // if enabled register locks before operation registerMutateLock(); updateExtended(pkFieldArray, nonPkFieldArray, con); } /** * This method should update the corresponding record in the datasource. NOTE: fieldInfoArray may have null values * after valid ones, the length is not the actual number of fields. */ public abstract void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) throws SQLException; @Override public EntityValue delete() { final EntityDefinition ed = getEntityDefinition(); final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo; final EntityFacadeImpl efi = getEntityFacadeImpl(); final ExecutionContextFactoryImpl ecfi = efi.ecfi; final ExecutionContextImpl ec = ecfi.getEci(); final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade; // NOTE: this is create-only on the entity, ignores setting on fields (only considered in update) if (entityInfo.createOnly) throw new EntityException("Entity [" + resolveEntityName() + "] is create-only (immutable), cannot be deleted."); // do the artifact push/authz ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_DELETE, "delete").setParameters(valueMapInternal); aefi.pushInternal(aei, !entityInfo.authorizeSkipTrue, false); try { // run EECA before rules efi.runEecaRules(entityName, this, "delete", true); // check DataDocuments to update (if not primary entity) or delete (if primary entity) efi.getEntityDataFeed().dataFeedCheckDelete(this); // if there is not a txCache or the txCache doesn't handle the delete, call the abstract method to delete the main record TransactionCache curTxCache = getTxCache(ecfi); if (curTxCache == null || !curTxCache.delete(this)) { // if enabled register locks before operation registerMutateLock(); this.deleteExtended(null); } // clear the entity cache efi.getEntityCache().clearCacheForValue(this, false); // run EECA after rules efi.runEecaRules(entityName, this, "delete", false); } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error deleting", DELETE_ERROR, ed, ec), e); } catch (Exception e) { throw new EntityException(makeErrorMsg("Error deleting", DELETE_ERROR, ed, ec), e); } finally { // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit aefi.pop(aei); } return this; } public abstract void deleteExtended(Connection con) throws SQLException; @Override public boolean refresh() { final EntityDefinition ed = getEntityDefinition(); final EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo; final EntityFacadeImpl efi = getEntityFacadeImpl(); final ExecutionContextFactoryImpl ecfi = efi.ecfi; final ExecutionContextImpl ec = ecfi.getEci(); final ArtifactExecutionFacadeImpl aefi = ec.artifactExecutionFacade; List pkFieldList = ed.getPkFieldNames(); if (pkFieldList.size() == 0) { // throw new EntityException("Entity ${resolveEntityName()} has no primary key fields, cannot do refresh.") if (logger.isTraceEnabled()) logger.trace("Entity " + resolveEntityName() + " has no primary key fields, cannot do refresh."); return false; } // check/set defaults if (entityInfo.hasFieldDefaults) checkSetFieldDefaults(ed, ec, null); // do the artifact push/authz ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(entityName, ArtifactExecutionInfo.AT_ENTITY, ArtifactExecutionInfo.AUTHZA_VIEW, "refresh").setParameters(valueMapInternal); aefi.pushInternal(aei, !ed.entityInfo.authorizeSkipView, false); boolean retVal = false; try { // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(fullEntityName, this, "find-one", true); // if there is not a txCache or the txCache doesn't handle the refresh, call the abstract method to refresh TransactionCache curTxCache = getTxCache(ecfi); if (curTxCache != null) retVal = curTxCache.refresh(this); // call the abstract method if (!retVal) { retVal = this.refreshExtended(); if (retVal && curTxCache != null) curTxCache.onePut(this, false); } // find EECA rules deprecated, not worth performance hit: efi.runEecaRules(fullEntityName, this, "find-one", false); } catch (SQLException e) { throw new EntitySqlException(makeErrorMsg("Error finding", REFRESH_ERROR, ed, ec), e); } catch (Exception e) { throw new EntityException(makeErrorMsg("Error finding", REFRESH_ERROR, ed, ec), e); } finally { // pop the ArtifactExecutionInfo to clean it up, also counts artifact hit aefi.pop(aei); } return retVal; } public abstract boolean refreshExtended() throws SQLException; @Override public String getEtlType() { return entityName; } @Override public Map getEtlValues() { return valueMapInternal; } private static class EntityFieldEntry implements Entry { protected FieldInfo fi; EntityValueBase evb; private EntityFieldEntry(FieldInfo fi, EntityValueBase evb) { this.fi = fi; this.evb = evb; } @Override public String getKey() { return fi.name; } @Override public Object getValue() { return evb.getKnownField(fi); } @Override public Object setValue(Object v) { return evb.set(fi.name, v); } @Override public int hashCode() { Object val = getValue(); return fi.name.hashCode() + (val != null ? val.hashCode() : 0); } @Override public boolean equals(Object obj) { if (!(obj instanceof EntityFieldEntry)) return false; EntityFieldEntry other = (EntityFieldEntry) obj; if (!fi.name.equals(other.fi.name)) return false; Object thisVal = getValue(); Object otherVal = other.getValue(); return thisVal == null ? otherVal == null : thisVal.equals(otherVal); } } public static class DeletedEntityValue extends EntityValueBase { public DeletedEntityValue(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip); } @Override public EntityValue cloneValue() { return this; } @Override public EntityValue cloneDbValue(boolean getOld) { return this; } @Override public void createExtended(FieldInfo[] fieldInfoArray, Connection con) { throw new UnsupportedOperationException("Not implemented on DeletedEntityValue"); } @Override public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) { throw new UnsupportedOperationException("Not implemented on DeletedEntityValue"); } @Override public void deleteExtended(Connection con) { throw new UnsupportedOperationException("Not implemented on DeletedEntityValue"); } @Override public boolean refreshExtended() { throw new UnsupportedOperationException("Not implemented on DeletedEntityValue"); } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/EntityValueImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.entity.EntityException; import org.moqui.entity.EntityValue; import org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.sql.Connection; import java.sql.ResultSet; import java.sql.SQLException; import java.util.ArrayList; import java.util.Map; public class EntityValueImpl extends EntityValueBase { protected static final Logger logger = LoggerFactory.getLogger(EntityValueImpl.class); /** Default constructor for deserialization ONLY. */ public EntityValueImpl() { } /** Primary constructor, generally used only internally by EntityFacade */ public EntityValueImpl(EntityDefinition ed, EntityFacadeImpl efip) { super(ed, efip); } @Override public EntityValue cloneValue() { EntityValueImpl newObj = new EntityValueImpl(getEntityDefinition(), getEntityFacadeImpl()); newObj.valueMapInternal.putAll(this.valueMapInternal); if (this.dbValueMap != null) newObj.setDbValueMap(this.dbValueMap); // don't set immutable (default to mutable even if original was not) or modified (start out not modified) return newObj; } @Override public EntityValue cloneDbValue(boolean getOld) { EntityValueImpl newObj = new EntityValueImpl(getEntityDefinition(), getEntityFacadeImpl()); newObj.valueMapInternal.putAll(this.valueMapInternal); for (FieldInfo fieldInfo : getEntityDefinition().entityInfo.allFieldInfoArray) newObj.putKnownField(fieldInfo, getOld ? getOldDbValue(fieldInfo.name) : getOriginalDbValue(fieldInfo.name)); newObj.setSyncedWithDb(); return newObj; } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") @Override public void createExtended(FieldInfo[] fieldInfoArray, Connection con) throws SQLException { EntityDefinition ed = getEntityDefinition(); EntityFacadeImpl efi = getEntityFacadeImpl(); if (ed.isViewEntity) throw new EntityException("Create not yet implemented for view-entity"); EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi); StringBuilder sql = eqb.sqlTopLevel; sql.append("INSERT INTO ").append(ed.getFullTableName()).append(" ("); int size = fieldInfoArray.length; StringBuilder values = new StringBuilder(size*3); for (int i = 0; i < size; i++) { FieldInfo fieldInfo = fieldInfoArray[i]; if (fieldInfo == null) break; if (i > 0) { sql.append(", "); values.append(", "); } sql.append(fieldInfo.getFullColumnName()); values.append("?"); } sql.append(") VALUES (").append(values.toString()).append(")"); try { efi.getEntityDbMeta().checkTableRuntime(ed); if (con != null) eqb.useConnection(con); else eqb.makeConnection(false); eqb.makePreparedStatement(); for (int i = 0; i < size; i++) { FieldInfo fieldInfo = fieldInfoArray[i]; if (fieldInfo == null) break; eqb.setPreparedStatementValue(i + 1, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), fieldInfo); } // if (ed.entityName == "Subscription") logger.warn("Create ${this.toString()} tx ${efi.getEcfi().transaction.getTransactionManager().getTransaction()} con ${eqb.connection}") eqb.executeUpdate(); setSyncedWithDb(); } catch (SQLException e) { String txName = "[could not get]"; try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); } catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace("Error getting transaction name: " + txe.toString()); } logger.warn("Error creating " + this.toString() + " tx " + txName + " con " + eqb.connection.toString() + ": " + e.toString()); throw e; } finally { try { eqb.closeAll(); } catch (SQLException sqle) { logger.error("Error in JDBC close in create of " + this.toString(), sqle); } } } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") @Override public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) throws SQLException { EntityDefinition ed = getEntityDefinition(); final EntityFacadeImpl efi = getEntityFacadeImpl(); if (ed.isViewEntity) throw new EntityException("Update not yet implemented for view-entity"); final EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi); ArrayList parameters = eqb.parameters; StringBuilder sql = eqb.sqlTopLevel; sql.append("UPDATE ").append(ed.getFullTableName()).append(" SET "); int size = nonPkFieldArray.length; for (int i = 0; i < size; i++) { FieldInfo fieldInfo = nonPkFieldArray[i]; if (fieldInfo == null) break; if (i > 0) sql.append(", "); sql.append(fieldInfo.getFullColumnName()).append("=?"); parameters.add(new EntityConditionParameter(fieldInfo, valueMapInternal.getByIString(fieldInfo.name, fieldInfo.index), eqb)); } eqb.addWhereClause(pkFieldArray, valueMapInternal); try { efi.getEntityDbMeta().checkTableRuntime(ed); if (con != null) eqb.useConnection(con); else eqb.makeConnection(false); eqb.makePreparedStatement(); eqb.setPreparedStatementValues(); // if (ed.entityName == "Subscription") logger.warn("Update ${this.toString()} tx ${efi.getEcfi().transaction.getTransactionManager().getTransaction()} con ${eqb.connection}") if (eqb.executeUpdate() == 0) throw new EntityException("Tried to update a value that does not exist [" + this.toString() + "]. SQL used was " + eqb.sqlTopLevel.toString() + ", parameters were " + eqb.parameters.toString()); setSyncedWithDb(); } catch (SQLException e) { String txName = "[could not get]"; try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); } catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace("Error getting transaction name: " + txe.toString()); } logger.warn("Error updating " + this.toString() + " tx " + txName + " con " + eqb.connection.toString() + ": " + e.toString()); throw e; } finally { try { eqb.closeAll(); } catch (SQLException sqle) { logger.error("Error in JDBC close in update of " + this.toString(), sqle); } } } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") @Override public void deleteExtended(Connection con) throws SQLException { EntityDefinition ed = getEntityDefinition(); EntityFacadeImpl efi = getEntityFacadeImpl(); if (ed.isViewEntity) throw new EntityException("Delete not implemented for view-entity"); EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi); StringBuilder sql = eqb.sqlTopLevel; sql.append("DELETE FROM ").append(ed.getFullTableName()); FieldInfo[] pkFieldArray = ed.entityInfo.pkFieldInfoArray; eqb.addWhereClause(pkFieldArray, valueMapInternal); try { efi.getEntityDbMeta().checkTableRuntime(ed); if (con != null) eqb.useConnection(con); else eqb.makeConnection(false); eqb.makePreparedStatement(); eqb.setPreparedStatementValues(); if (eqb.executeUpdate() == 0) logger.info("Tried to delete a value that does not exist " + this.toString()); } catch (SQLException e) { String txName = "[could not get]"; try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); } catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace("Error getting transaction name: " + txe.toString()); } logger.warn("Error deleting " + this.toString() + " tx " + txName + " con " + eqb.connection.toString() + ": " + e.toString()); throw e; } finally { try { eqb.closeAll(); } catch (SQLException sqle) { logger.error("Error in JDBC close in delete of " + this.toString(), sqle); } } } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") @Override public boolean refreshExtended() throws SQLException { EntityDefinition ed = getEntityDefinition(); EntityJavaUtil.EntityInfo entityInfo = ed.entityInfo; EntityFacadeImpl efi = getEntityFacadeImpl(); // table doesn't exist, just return false if (!ed.tableExistsDbMetaOnly()) return false; // NOTE: this simple approach may not work for view-entities, but not restricting for now FieldInfo[] pkFieldArray = entityInfo.pkFieldInfoArray; FieldInfo[] allFieldArray = entityInfo.allFieldInfoArray; // NOTE: even if there are no non-pk fields do a refresh in order to see if the record exists or not EntityQueryBuilder eqb = new EntityQueryBuilder(ed, efi); ArrayList parameters = eqb.parameters; StringBuilder sql = eqb.sqlTopLevel; sql.append("SELECT "); eqb.makeSqlSelectFields(allFieldArray, null, "true".equals(efi.getDatabaseNode(ed.groupName).attribute("add-unique-as"))); sql.append(" FROM ").append(ed.getFullTableName()).append(" WHERE "); int sizePk = pkFieldArray.length; for (int i = 0; i < sizePk; i++) { FieldInfo fi = pkFieldArray[i]; if (i > 0) sql.append(" AND "); sql.append(fi.getFullColumnName()).append("=?"); parameters.add(new EntityConditionParameter(fi, valueMapInternal.getByIString(fi.name, fi.index), eqb)); } boolean retVal = false; try { // don't check create, above tableExists check is done: // efi.getEntityDbMeta().checkTableRuntime(ed) // if this is a view-entity and any table in it exists check/create all or will fail with optional members, etc if (ed.isViewEntity) efi.getEntityDbMeta().checkTableRuntime(ed); eqb.makeConnection(false); eqb.makePreparedStatement(); eqb.setPreparedStatementValues(); ResultSet rs = eqb.executeQuery(); if (rs.next()) { int nonPkSize = allFieldArray.length; for (int j = 0; j < nonPkSize; j++) { FieldInfo fi = allFieldArray[j]; fi.getResultSetValue(rs, j + 1, valueMapInternal, efi); } retVal = true; setSyncedWithDb(); } else { if (logger.isTraceEnabled()) logger.trace("No record found in refresh for entity [" + resolveEntityName() + "] with values [" + String.valueOf(getValueMap()) + "]"); } } catch (SQLException e) { String txName = "[could not get]"; try { txName = efi.ecfi.transactionFacade.getTransactionManager().getTransaction().toString(); } catch (Exception txe) { if (logger.isTraceEnabled()) logger.trace("Error getting transaction name: " + txe.toString()); } logger.warn("Error finding " + this.toString() + " tx " + txName + " con " + eqb.connection.toString() + ": " + e.toString()); throw e; } finally { try { eqb.closeAll(); } catch (SQLException sqle) { logger.error("Error in JDBC close in refresh of " + this.toString(), sqle); } } return retVal; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/FieldInfo.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity; import org.moqui.BaseArtifactException; import org.moqui.entity.EntityException; import org.moqui.impl.context.L10nFacadeImpl; import org.moqui.impl.entity.condition.ConditionField; import org.moqui.util.LiteStringMap; import org.moqui.util.MNode; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.sql.rowset.serial.SerialBlob; import javax.sql.rowset.serial.SerialClob; import java.io.*; import java.math.BigDecimal; import java.nio.ByteBuffer; import java.sql.*; import java.util.*; public class FieldInfo { protected final static Logger logger = LoggerFactory.getLogger(FieldInfo.class); protected final static boolean isTraceEnabled = logger.isTraceEnabled(); public final static String[] aggFunctionArray = {"min", "max", "sum", "avg", "count", "count-distinct"}; public final static Set aggFunctions = new HashSet<>(Arrays.asList(aggFunctionArray)); public final static String decryptFailedMagicString = "_DECRYPT_FAILED_"; public final EntityDefinition ed; public final MNode fieldNode; public final int index; public final String entityName, name, aliasFieldName; public final ConditionField conditionField; public final String type, columnName; final String fullColumnNameInternal; public final String expandColumnName, defaultStr, javaType, enableAuditLog; public final int typeValue; public final boolean isPk, isTextVeryLong, encrypt, isSimple, enableLocalization, createOnly, isLastUpdatedStamp; public final MNode memberEntityNode; public final MNode directMemberEntityNode; public final boolean hasAggregateFunction; final Set entityAliasUsedSet = new HashSet<>(); public FieldInfo(EntityDefinition ed, MNode fieldNode, int index) { this.ed = ed; this.fieldNode = fieldNode; this.index = index; entityName = ed.getFullEntityName(); Map fnAttrs = fieldNode.getAttributes(); String nameAttr = fnAttrs.get("name"); if (nameAttr == null) throw new EntityException("No name attribute specified for field in entity " + entityName); // NOTE: intern a must here for use with LiteStringMap, without this all sorts of bad behavior, not finding any fields sort of thing name = nameAttr.intern(); conditionField = new ConditionField(this); // column name from attribute or underscored name, may have override per DB String columnNameAttr = fnAttrs.get("column-name"); String colNameToUse = columnNameAttr != null && columnNameAttr.length() > 0 ? columnNameAttr : EntityJavaUtil.camelCaseToUnderscored(name); // column name: see if there is a name-replace String groupName = ed.getEntityGroupName(); MNode databaseNode = ed.efi.getDatabaseNode(groupName); // some datasources do not have a database node, like the Elastic Entity one if (databaseNode != null) { ArrayList nameReplaceNodes = databaseNode.children("name-replace"); for (int i = 0; i < nameReplaceNodes.size(); i++) { MNode nameReplaceNode = nameReplaceNodes.get(i); if (colNameToUse.equalsIgnoreCase(nameReplaceNode.attribute("original"))) { String replaceName = nameReplaceNode.attribute("replace"); logger.info("Replacing column name " + colNameToUse + " with replace name " + replaceName + " for entity " + entityName); colNameToUse = replaceName; } } } columnName = colNameToUse; defaultStr = fnAttrs.get("default"); String typeAttr = fnAttrs.get("type"); if ((typeAttr == null || typeAttr.length() == 0) && (fieldNode.hasChild("complex-alias") || fieldNode.hasChild("case")) && fnAttrs.get("function") != null) { // this is probably a calculated value, just default to number-decimal typeAttr = "number-decimal"; } type = typeAttr; if (type != null && type.length() > 0) { String fieldJavaType = ed.efi.getFieldJavaType(type, ed); javaType = fieldJavaType != null ? fieldJavaType : "String"; typeValue = EntityFacadeImpl.getJavaTypeInt(javaType); isTextVeryLong = "text-very-long".equals(type); } else { throw new EntityException("No type specified or found for field " + name + " on entity " + entityName); } isPk = "true".equals(fnAttrs.get("is-pk")); encrypt = "true".equals(fnAttrs.get("encrypt")); enableLocalization = "true".equals(fnAttrs.get("enable-localization")); isSimple = !enableLocalization; String createOnlyAttr = fnAttrs.get("create-only"); createOnly = createOnlyAttr != null && createOnlyAttr.length() > 0 ? "true".equals(fnAttrs.get("create-only")) : "true".equals(ed.internalEntityNode.attribute("create-only")); isLastUpdatedStamp = "lastUpdatedStamp".equals(name); String enableAuditLogAttr = fieldNode.attribute("enable-audit-log"); enableAuditLog = enableAuditLogAttr != null ? enableAuditLogAttr : ed.internalEntityNode.attribute("enable-audit-log"); String fcn = ed.makeFullColumnName(fieldNode, true); if (fcn == null) { fullColumnNameInternal = columnName; expandColumnName = null; } else { if (fcn.contains("${")) { expandColumnName = fcn; fullColumnNameInternal = null; } else { fullColumnNameInternal = fcn; expandColumnName = null; } } if (ed.isViewEntity) { String fieldAttr = fieldNode.attribute("field"); aliasFieldName = fieldAttr != null && !fieldAttr.isEmpty() ? fieldAttr : name; MNode tempMembEntNode = null; String entityAlias = fieldNode.attribute("entity-alias"); if (entityAlias != null && entityAlias.length() > 0) { entityAliasUsedSet.add(entityAlias); tempMembEntNode = ed.memberEntityAliasMap.get(entityAlias); } directMemberEntityNode = tempMembEntNode; ArrayList cafList = fieldNode.descendants("complex-alias-field"); int cafListSize = cafList.size(); for (int i = 0; i < cafListSize; i++) { MNode cafNode = cafList.get(i); String cafEntityAlias = cafNode.attribute("entity-alias"); if (cafEntityAlias != null && cafEntityAlias.length() > 0) entityAliasUsedSet.add(cafEntityAlias); } if (tempMembEntNode == null && entityAliasUsedSet.size() == 1) { String singleEntityAlias = entityAliasUsedSet.iterator().next(); tempMembEntNode = ed.memberEntityAliasMap.get(singleEntityAlias); } memberEntityNode = tempMembEntNode; String isAggregateAttr = fieldNode.attribute("is-aggregate"); hasAggregateFunction = isAggregateAttr != null ? "true".equalsIgnoreCase(isAggregateAttr) : aggFunctions.contains(fieldNode.attribute("function")); } else { aliasFieldName = null; memberEntityNode = null; directMemberEntityNode = null; hasAggregateFunction = false; } } /** Full column name for complex finds on view entities; plain entity column names are never expanded */ public String getFullColumnName() { if (fullColumnNameInternal != null) return fullColumnNameInternal; return ed.efi.ecfi.resourceFacade.expand(expandColumnName, "", null, false); } static BigDecimal safeStripZeroes(BigDecimal input) { if (input == null) return null; BigDecimal temp = input.stripTrailingZeros(); if (temp.scale() < 0) temp = temp.setScale(0); return temp; } public Object convertFromString(String value, L10nFacadeImpl l10n) { if (value == null) return null; if ("null".equals(value)) return null; Object outValue; boolean isEmpty = value.length() == 0; try { switch (typeValue) { case 1: outValue = value; break; case 2: // outValue = java.sql.Timestamp.valueOf(value); if (isEmpty) { outValue = null; break; } outValue = l10n.parseTimestamp(value, null); if (outValue == null) throw new BaseArtifactException("The value [" + value + "] is not a valid date/time for field " + entityName + "." + name); break; case 3: // outValue = java.sql.Time.valueOf(value); if (isEmpty) { outValue = null; break; } outValue = l10n.parseTime(value, null); if (outValue == null) throw new BaseArtifactException("The value [" + value + "] is not a valid time for field " + entityName + "." + name); break; case 4: // outValue = java.sql.Date.valueOf(value); if (isEmpty) { outValue = null; break; } outValue = l10n.parseDate(value, null); if (outValue == null) throw new BaseArtifactException("The value [" + value + "] is not a valid date for field " + entityName + "." + name); break; case 5: // outValue = Integer.valueOf(value); break case 6: // outValue = Long.valueOf(value); break case 7: // outValue = Float.valueOf(value); break case 8: // outValue = Double.valueOf(value); break case 9: // outValue = new BigDecimal(value); break if (isEmpty) { outValue = null; break; } BigDecimal bdVal = l10n.parseNumber(value, null); if (bdVal == null) { throw new BaseArtifactException("The value [" + value + "] is not valid for type " + javaType + " for field " + entityName + "." + name); } else { bdVal = safeStripZeroes(bdVal); switch (typeValue) { case 5: outValue = bdVal.intValue(); break; case 6: outValue = bdVal.longValue(); break; case 7: outValue = bdVal.floatValue(); break; case 8: outValue = bdVal.doubleValue(); break; default: outValue = bdVal; break; } } break; case 10: if (isEmpty) { outValue = null; break; } outValue = Boolean.valueOf(value); break; case 11: outValue = value; break; case 12: try { outValue = new SerialBlob(value.getBytes()); } catch (SQLException e) { throw new BaseArtifactException("Error creating SerialBlob for value [" + value + "] for field " + entityName + "." + name); } break; case 13: outValue = value; break; case 14: if (isEmpty) { outValue = null; break; } Timestamp ts = l10n.parseTimestamp(value, null); outValue = new java.util.Date(ts.getTime()); break; // better way for Collection (15)? maybe parse comma separated, but probably doesn't make sense in the first place case 15: outValue = value; break; default: outValue = value; break; } } catch (IllegalArgumentException e) { throw new BaseArtifactException("The value [" + value + "] is not valid for type " + javaType + " for field " + entityName + "." + name, e); } return outValue; } public String convertToString(Object value) { if (value == null) return null; String outValue; try { switch (typeValue) { case 1: outValue = value.toString(); break; case 2: case 3: case 4: case 5: case 6: case 7: case 8: case 9: if (value instanceof BigDecimal) value = safeStripZeroes((BigDecimal) value); L10nFacadeImpl l10n = ed.efi.ecfi.getEci().l10nFacade; outValue = l10n.format(value, null); break; case 10: outValue = value.toString(); break; case 11: outValue = value.toString(); break; case 12: if (value instanceof byte[]) { outValue = Base64.getEncoder().encodeToString((byte[]) value); } else { logger.info("Field on entity is not of type byte[], is [" + value + "] so using plain toString() for field " + entityName + "." + name); outValue = value.toString(); } break; case 13: outValue = value.toString(); break; case 14: outValue = value.toString(); break; // better way for Collection (15)? maybe parse comma separated, but probably doesn't make sense in the first place case 15: outValue = value.toString(); break; default: outValue = value.toString(); break; } } catch (IllegalArgumentException e) { throw new BaseArtifactException("The value [" + value + "] is not valid for type " + javaType + " for field " + entityName + "." + name, e); } return outValue; } void getResultSetValue(ResultSet rs, int index, LiteStringMap valueMap, EntityFacadeImpl efi) throws EntityException { if (typeValue == -1) throw new EntityException("No typeValue found for " + entityName + "." + name); Object value = null; try { switch (typeValue) { case 1: // getMetaData and the column type are somewhat slow (based on profiling), and String values are VERY // common, so only do for text-very-long if (isTextVeryLong) { ResultSetMetaData rsmd = rs.getMetaData(); if (Types.CLOB == rsmd.getColumnType(index)) { // if the String is empty, try to get a text input stream, this is required for some databases // for larger fields, like CLOBs Clob valueClob = rs.getClob(index); Reader valueReader = null; if (valueClob != null) valueReader = valueClob.getCharacterStream(); if (valueReader != null) { // read up to 4096 at a time char[] inCharBuffer = new char[4096]; StringBuilder strBuf = new StringBuilder(); try { int charsRead; while ((charsRead = valueReader.read(inCharBuffer, 0, 4096)) > 0) { strBuf.append(inCharBuffer, 0, charsRead); } valueReader.close(); } catch (IOException e) { throw new EntityException("Error reading long character stream for field " + name + " of entity " + entityName, e); } value = strBuf.toString(); } } else { value = rs.getString(index); } } else { value = rs.getString(index); } break; case 2: try { value = rs.getTimestamp(index, efi.getCalendarForTzLc()); } catch (SQLException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring SQLException for getTimestamp(), leaving null (found this in MySQL with a date/time value of [0000-00-00 00:00:00]): " + e.toString()); } break; case 3: value = rs.getTime(index, efi.getCalendarForTzLc()); break; // for Date don't pass 2nd param efi.getCalendarForTzLc(), causes issues when Java TZ different from DB TZ // when the JDBC driver converts a string to a Date it uses the TZ from the Calendar but we want the Java default TZ case 4: value = rs.getDate(index); break; case 5: int intValue = rs.getInt(index); if (!rs.wasNull()) value = intValue; break; case 6: long longValue = rs.getLong(index); if (!rs.wasNull()) value = longValue; break; case 7: float floatValue = rs.getFloat(index); if (!rs.wasNull()) value = floatValue; break; case 8: double doubleValue = rs.getDouble(index); if (!rs.wasNull()) value = doubleValue; break; case 9: BigDecimal bdVal = rs.getBigDecimal(index); if (!rs.wasNull()) value = safeStripZeroes(bdVal); break; case 10: boolean booleanValue = rs.getBoolean(index); if (!rs.wasNull()) value = booleanValue; break; case 11: Object obj = null; byte[] originalBytes = rs.getBytes(index); InputStream binaryInput = null; if (originalBytes != null && originalBytes.length > 0) { binaryInput = new ByteArrayInputStream(originalBytes); } if (originalBytes != null && originalBytes.length <= 0) { logger.warn("Got byte array back empty for serialized Object with length [" + originalBytes.length + "] for field [" + name + "] (" + index + ")"); } if (binaryInput != null) { ObjectInputStream inStream = null; try { inStream = new ObjectInputStream(binaryInput); obj = inStream.readObject(); } catch (IOException ex) { if (logger.isTraceEnabled()) logger.trace("Unable to read BLOB from input stream for field [" + name + "] (" + index + "): " + ex.toString()); } catch (ClassNotFoundException ex) { if (logger.isTraceEnabled()) logger.trace("Class not found: Unable to cast BLOB data to an Java object for field [" + name + "] (" + index + "); most likely because it is a straight byte[], so just using the raw bytes: " + ex.toString()); } finally { if (inStream != null) { try { inStream.close(); } catch (IOException e) { logger.error("Unable to close binary input stream for field [" + name + "] (" + index + "): " + e.toString(), e); } } } } if (obj != null) { value = obj; } else { value = originalBytes; } break; case 12: SerialBlob sblob = null; try { // NOTE: changed to try getBytes first because Derby blows up on getBlob and on then calling getBytes for the same field, complains about getting value twice byte[] fieldBytes = rs.getBytes(index); if (!rs.wasNull()) sblob = new SerialBlob(fieldBytes); // fieldBytes = theBlob != null ? theBlob.getBytes(1, (int) theBlob.length()) : null } catch (SQLException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring exception trying getBytes(), trying getBlob(): " + e.toString()); Blob theBlob = rs.getBlob(index); if (!rs.wasNull()) sblob = new SerialBlob(theBlob); } value = sblob; break; case 13: value = new SerialClob(rs.getClob(index)); break; case 14: case 15: value = rs.getObject(index); break; } } catch (SQLException sqle) { logger.error("SQL Exception while getting value for field: " + name + " (" + index + ")", sqle); throw new EntityException("SQL Exception while getting value for field: " + name + " (" + index + ")", sqle); } // if field is to be encrypted, do it now if (value != null && encrypt) { if (typeValue != 1) throw new EntityException("The encrypt attribute was set to true on non-String field " + name + " of entity " + entityName); String original = value.toString(); try { value = EntityJavaUtil.enDeCrypt(original, false, efi); } catch (Exception e) { logger.error("Error decrypting field [" + name + "] of entity [" + entityName + "]", e); // NOTE DEJ 20200310 instead of using encrypted value return very clear fake placeholder; this is a bad design // because it uses a magic value that can't change as other code may use it; an alternative might be to have // the EntityValue internally handle it with a Set of fields that failed to decrypt, but this doesn't carry // through to or from web and other clients value = decryptFailedMagicString; } } valueMap.putByIString(this.name, value, this.index); } private static final boolean checkPreparedStatementValueType = false; public void setPreparedStatementValue(PreparedStatement ps, int index, Object value, EntityDefinition ed, EntityFacadeImpl efi) throws EntityException { int localTypeValue = typeValue; if (value != null) { if (checkPreparedStatementValueType && !ObjectUtilities.isInstanceOf(value, javaType)) { // this is only an info level message because under normal operation for most JDBC // drivers this will be okay, but if not then the JDBC driver will throw an exception // and when lower debug levels are on this should help give more info on what happened String fieldClassName = value.getClass().getName(); if (value instanceof byte[]) { fieldClassName = "byte[]"; } else if (value instanceof char[]) { fieldClassName = "char[]"; } if (isTraceEnabled) logger.trace("Type of field " + ed.getFullEntityName() + "." + name + " is " + fieldClassName + ", was expecting " + javaType + " this may " + "indicate an error in the configuration or in the class, and may result " + "in an SQL-Java data conversion error. Will use the real field type: " + fieldClassName + ", not the definition."); localTypeValue = EntityFacadeImpl.getJavaTypeInt(fieldClassName); } // if field is to be encrypted, do it now if (encrypt) { if (localTypeValue != 1) throw new EntityException("The encrypt attribute was set to true on non-String field " + name + " of entity " + entityName); String original = value.toString(); if (decryptFailedMagicString.equals(original)) { throw new EntityException("To prevent data loss, not allowing decrypt failed placeholder for field " + name + " of entity " + entityName); } value = EntityJavaUtil.enDeCrypt(original, true, efi); } } boolean useBinaryTypeForBlob = false; if (localTypeValue == 11 || localTypeValue == 12) { useBinaryTypeForBlob = ("true".equals(efi.getDatabaseNode(ed.getEntityGroupName()).attribute("use-binary-type-for-blob"))); } // if a count function used set as Long (type 6) if (ed.isViewEntity) { String function = fieldNode.attribute("function"); if (function != null && function.startsWith("count")) localTypeValue = 6; } try { setPreparedStatementValue(ps, index, value, localTypeValue, useBinaryTypeForBlob, efi); } catch (EntityException e) { throw e; } catch (Exception e) { throw new EntityException("Error setting prepared statement field " + name + " of entity " + entityName, e); } } private void setPreparedStatementValue(PreparedStatement ps, int index, Object value, int localTypeValue, boolean useBinaryTypeForBlob, EntityFacadeImpl efi) throws EntityException { try { // allow setting, and searching for, String values for all types; JDBC driver should handle this okay if (value instanceof CharSequence) { ps.setString(index, value.toString()); } else { switch (localTypeValue) { case 1: if (value != null) { ps.setString(index, value.toString()); } else { ps.setNull(index, Types.VARCHAR); } break; case 2: if (value != null) { Class valClass = value.getClass(); if (valClass == Timestamp.class) { ps.setTimestamp(index, (Timestamp) value, efi.getCalendarForTzLc()); } else if (valClass == java.sql.Date.class) { ps.setDate(index, (java.sql.Date) value, efi.getCalendarForTzLc()); } else if (valClass == java.util.Date.class) { ps.setTimestamp(index, new Timestamp(((java.util.Date) value).getTime()), efi.getCalendarForTzLc()); } else if (valClass == Long.class) { ps.setTimestamp(index, new Timestamp((Long) value), efi.getCalendarForTzLc()); } else { throw new EntityException("Class " + valClass.getName() + " not allowed for date-time (Timestamp) fields, for field " + entityName + "." + name); } // NOTE for Calendar use with different MySQL vs Oracle JDBC drivers: https://bugs.openjdk.java.net/browse/JDK-4986236 // the TimeZone is treated differently; should stay consistent but may result in unexpected times when looking at // timestamp values directly in the database; not a huge issue, something to keep in mind when configuring the DB TimeZone // NOTE that some JDBC drivers clone the Calendar, others don't; see the efi.getCalendarForTzLc() which now uses a ThreadLocal } else { ps.setNull(index, Types.TIMESTAMP); } break; case 3: Time tm = (Time) value; // logger.warn("=================== setting time tm=${tm} tm long=${tm.getTime()}, cal=${cal}") if (value != null) { ps.setTime(index, tm, efi.getCalendarForTzLc()); } else { ps.setNull(index, Types.TIME); } break; case 4: if (value != null) { Class valClass = value.getClass(); if (valClass == java.sql.Date.class) { java.sql.Date dt = (java.sql.Date) value; // logger.warn("=================== setting date dt=${dt} dt long=${dt.getTime()}, cal=${cal}") ps.setDate(index, dt); // NOTE: don't pass Calendar, Date was likely generated in Java TZ and that's what we want, if DB TZ is different we don't want it to use that } else if (valClass == Timestamp.class) { ps.setDate(index, new java.sql.Date(((Timestamp) value).getTime()), efi.getCalendarForTzLc()); } else if (valClass == java.util.Date.class) { ps.setDate(index, new java.sql.Date(((java.util.Date) value).getTime()), efi.getCalendarForTzLc()); } else if (valClass == Long.class) { ps.setDate(index, new java.sql.Date((Long) value), efi.getCalendarForTzLc()); } else { throw new EntityException("Class " + valClass.getName() + " not allowed for date fields, for field " + entityName + "." + name); } } else { ps.setNull(index, Types.DATE); } break; case 5: if (value != null) { ps.setInt(index, ((Number) value).intValue()); } else { ps.setNull(index, Types.NUMERIC); } break; case 6: if (value != null) { ps.setLong(index, ((Number) value).longValue()); } else { ps.setNull(index, Types.NUMERIC); } break; case 7: if (value != null) { ps.setFloat(index, ((Number) value).floatValue()); } else { ps.setNull(index, Types.NUMERIC); } break; case 8: if (value != null) { ps.setDouble(index, ((Number) value).doubleValue()); } else { ps.setNull(index, Types.NUMERIC); } break; case 9: if (value != null) { Class valClass = value.getClass(); // most common cases BigDecimal, Double, Float; then allow any Number if (valClass == BigDecimal.class) { ps.setBigDecimal(index, (BigDecimal) value); } else if (valClass == Double.class) { ps.setDouble(index, (Double) value); } else if (valClass == Float.class) { ps.setFloat(index, (Float) value); } else if (value instanceof Number) { ps.setDouble(index, ((Number) value).doubleValue()); } else { throw new EntityException("Class " + valClass.getName() + " not allowed for number-decimal (BigDecimal) fields, for field " + entityName + "." + name); } } else { ps.setNull(index, Types.NUMERIC); } break; case 10: if (value != null) { ps.setBoolean(index, (Boolean) value); } else { ps.setNull(index, Types.BOOLEAN); } break; case 11: if (value != null) { try { ByteArrayOutputStream os = new ByteArrayOutputStream(); ObjectOutputStream oos = new ObjectOutputStream(os); oos.writeObject(value); oos.close(); byte[] buf = os.toByteArray(); os.close(); ByteArrayInputStream is = new ByteArrayInputStream(buf); ps.setBinaryStream(index, is, buf.length); is.close(); } catch (IOException ex) { throw new EntityException("Error setting serialized object, for field " + entityName + "." + name, ex); } } else { if (useBinaryTypeForBlob) { ps.setNull(index, Types.BINARY); } else { ps.setNull(index, Types.BLOB); } } break; case 12: if (value instanceof byte[]) { ps.setBytes(index, (byte[]) value); /* } else if (value instanceof ArrayList) { ArrayList valueAl = (ArrayList) value; byte[] theBytes = new byte[valueAl.size()]; valueAl.toArray(theBytes); ps.setBytes(index, theBytes); */ } else if (value instanceof ByteBuffer) { ByteBuffer valueBb = (ByteBuffer) value; ps.setBytes(index, valueBb.array()); } else if (value instanceof Blob) { Blob valueBlob = (Blob) value; // calling setBytes instead of setBlob - old github.com/moqui/moqui repo issue #28 with Postgres JDBC driver // ps.setBlob(index, (Blob) value) // Blob blb = value try { ps.setBytes(index, valueBlob.getBytes(1, (int) valueBlob.length())); } catch (Exception bytesExc) { // try ps.setBlob for larger byte arrays that H2 throws an exception for try { ps.setBlob(index, valueBlob); } catch (Exception blobExc) { // throw the original exception from setBytes() throw bytesExc; } } } else { if (value != null) { throw new EntityException("Type not supported for BLOB field: " + value.getClass().getName() + ", for field " + entityName + "." + name); } else { if (useBinaryTypeForBlob) { ps.setNull(index, Types.BINARY); } else { ps.setNull(index, Types.BLOB); } } } break; case 13: if (value != null) { ps.setClob(index, (Clob) value); } else { ps.setNull(index, Types.CLOB); } break; case 14: if (value != null) { ps.setTimestamp(index, (Timestamp) value); } else { ps.setNull(index, Types.TIMESTAMP); } break; // TODO: is this the best way to do collections and such? case 15: if (value != null) { ps.setObject(index, value, Types.JAVA_OBJECT); } else { ps.setNull(index, Types.JAVA_OBJECT); } break; } } } catch (SQLException sqle) { throw new EntityException("SQL Exception while setting value [" + value + "](" + (value != null ? value.getClass().getName() : "null") + "), type " + type + ", for field " + entityName + "." + name + ": " + sqle.toString(), sqle); } catch (Exception e) { throw new EntityException("Error while setting value for field " + entityName + "." + name + ": " + e.toString(), e); } } @Override public String toString() { return name; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/BasicJoinCondition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityException; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.EntityQueryBuilder; import org.moqui.impl.entity.EntityConditionFactoryImpl; import org.moqui.util.CollectionUtilities; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.*; public class BasicJoinCondition implements EntityConditionImplBase { private static final Class thisClass = BasicJoinCondition.class; private EntityConditionImplBase lhsInternal; protected JoinOperator operator; private EntityConditionImplBase rhsInternal; private int curHashCode; public BasicJoinCondition(EntityConditionImplBase lhs, JoinOperator operator, EntityConditionImplBase rhs) { this.lhsInternal = lhs; this.operator = operator != null ? operator : AND; this.rhsInternal = rhs; curHashCode = createHashCode(); } public JoinOperator getOperator() { return operator; } public EntityConditionImplBase getLhs() { return lhsInternal; } public EntityConditionImplBase getRhs() { return rhsInternal; } @Override @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { StringBuilder sql = eqb.sqlTopLevel; sql.append('('); lhsInternal.makeSqlWhere(eqb, subMemberEd); sql.append(' ').append(EntityConditionFactoryImpl.getJoinOperatorString(this.operator)).append(' '); rhsInternal.makeSqlWhere(eqb, subMemberEd); sql.append(')'); } @Override public void makeSearchFilter(List> filterList) { List> childList = new ArrayList<>(2); lhsInternal.makeSearchFilter(childList); rhsInternal.makeSearchFilter(childList); Map boolMap = new HashMap<>(); if (operator == AND) boolMap.put("filter", childList); else boolMap.put("should", childList); filterList.add(CollectionUtilities.toHashMap("bool", boolMap)); } @Override public boolean mapMatches(Map map) { boolean lhsMatches = lhsInternal.mapMatches(map); // handle cases where we don't need to evaluate rhs if (lhsMatches && operator == OR) return true; if (!lhsMatches && operator == AND) return false; // handle opposite cases since we know cases above aren't true (ie if OR then lhs=false, if AND then lhs=true // if rhs then result is true whether AND or OR // if !rhs then result is false whether AND or OR return rhsInternal.mapMatches(map); } @Override public boolean mapMatchesAny(Map map) { return lhsInternal.mapMatchesAny(map) || rhsInternal.mapMatchesAny(map); } @Override public boolean mapKeysNotContained(Map map) { return lhsInternal.mapKeysNotContained(map) && rhsInternal.mapKeysNotContained(map); } @Override public boolean populateMap(Map map) { return operator == AND && lhsInternal.populateMap(map) && rhsInternal.populateMap(map); } @Override public void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { lhsInternal.getAllAliases(entityAliasSet, fieldAliasSet); rhsInternal.getAllAliases(entityAliasSet, fieldAliasSet); } @Override public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { EntityConditionImplBase filterLhs = lhsInternal.filter(entityAlias, mainEd); EntityConditionImplBase filterRhs = rhsInternal.filter(entityAlias, mainEd); if (filterLhs != null) { if (filterRhs != null) return this; else return filterLhs; } else { return filterRhs; } } @Override public EntityCondition ignoreCase() { throw new EntityException("Ignore case not supported for BasicJoinCondition"); } @Override public String toString() { // general SQL where clause style text with values included return "(" + lhsInternal.toString() + ") " + EntityConditionFactoryImpl.getJoinOperatorString(this.operator) + " (" + rhsInternal.toString() + ")"; } @Override public int hashCode() { return curHashCode; } private int createHashCode() { return (lhsInternal != null ? lhsInternal.hashCode() : 0) + operator.hashCode() + (rhsInternal != null ? rhsInternal.hashCode() : 0); } @Override public boolean equals(Object o) { if (o == null || o.getClass() != thisClass) return false; BasicJoinCondition that = (BasicJoinCondition) o; if (!this.lhsInternal.equals(that.lhsInternal)) return false; // NOTE: for Java Enums the != is WAY faster than the .equals return this.operator == that.operator && this.rhsInternal.equals(that.rhsInternal); } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(lhsInternal); out.writeUTF(operator.name()); out.writeObject(rhsInternal); } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { lhsInternal = (EntityConditionImplBase) in.readObject(); operator = JoinOperator.valueOf(in.readUTF()); rhsInternal = (EntityConditionImplBase) in.readObject(); curHashCode = createHashCode(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/ConditionAlias.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.BaseArtifactException; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.FieldInfo; import org.moqui.util.MNode; import java.io.Externalizable; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; public class ConditionAlias extends ConditionField implements Externalizable { private static final Class thisClass = ConditionAlias.class; String fieldName; String entityAlias = null; private String aliasEntityName = null; private transient EntityDefinition aliasEntityDefTransient = null; private int curHashCode; public ConditionAlias() { } public ConditionAlias(String entityAlias, String fieldName, EntityDefinition aliasEntityDef) { if (fieldName == null) throw new BaseArtifactException("Empty fieldName not allowed"); if (entityAlias == null) throw new BaseArtifactException("Empty entityAlias not allowed"); if (aliasEntityDef == null) throw new BaseArtifactException("Null aliasEntityDef not allowed"); this.fieldName = fieldName.intern(); this.entityAlias = entityAlias.intern(); aliasEntityDefTransient = aliasEntityDef; String entName = aliasEntityDef.getFullEntityName(); aliasEntityName = entName.intern(); curHashCode = createHashCode(); } public String getEntityAlias() { return entityAlias; } public String getFieldName() { return fieldName; } public String getAliasEntityName() { return aliasEntityName; } private EntityDefinition getAliasEntityDef(EntityDefinition otherEd) { if (aliasEntityDefTransient == null && aliasEntityName != null) aliasEntityDefTransient = otherEd.getEfi().getEntityDefinition(aliasEntityName); return aliasEntityDefTransient; } public String getColumnName(EntityDefinition ed) { StringBuilder colName = new StringBuilder(); // NOTE: this could have issues with view-entities as member entities where they have functions/etc; we may // have to pass the prefix in to have it added inside functions/etc colName.append(entityAlias).append('.'); EntityDefinition memberEd = getAliasEntityDef(ed); if (memberEd.isViewEntity) { MNode memberEntity = ed.getMemberEntityNode(entityAlias); String subSelectAttr = memberEntity.attribute("sub-select"); if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) colName.append(memberEd.getFieldInfo(fieldName).columnName); else colName.append(memberEd.getColumnName(fieldName)); } else { colName.append(memberEd.getColumnName(fieldName)); } return colName.toString(); } public FieldInfo getFieldInfo(EntityDefinition ed) { if (aliasEntityName != null) { return getAliasEntityDef(ed).getFieldInfo(fieldName); } else { return ed.getFieldInfo(fieldName); } } @Override public String toString() { return (entityAlias != null ? (entityAlias + ".") : "") + fieldName; } @Override public int hashCode() { return curHashCode; } private int createHashCode() { return fieldName.hashCode() + (entityAlias != null ? entityAlias.hashCode() : 0) + (aliasEntityName != null ? aliasEntityName.hashCode() : 0); } @Override public boolean equals(Object o) { if (o == null || o.getClass() != thisClass) return false; ConditionAlias that = (ConditionAlias) o; // both Strings are intern'ed so use != operator for object compare if (fieldName != that.fieldName) return false; if (entityAlias != that.entityAlias) return false; if (aliasEntityName != that.aliasEntityName) return false; return true; } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(fieldName); out.writeUTF(entityAlias); out.writeUTF(aliasEntityName); } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { fieldName = in.readUTF().intern(); entityAlias = in.readUTF().intern(); aliasEntityName = in.readUTF().intern(); curHashCode = createHashCode(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/ConditionField.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.BaseArtifactException; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.FieldInfo; import java.io.*; public class ConditionField implements Externalizable { private static final Class thisClass = ConditionField.class; String fieldName; private int curHashCode; private FieldInfo fieldInfo = null; public ConditionField() { } public ConditionField(String fieldName) { if (fieldName == null) throw new BaseArtifactException("Empty fieldName not allowed"); this.fieldName = fieldName.intern(); curHashCode = this.fieldName.hashCode(); } public ConditionField(FieldInfo fi) { if (fi == null) throw new BaseArtifactException("FieldInfo required"); fieldInfo = fi; // fi.name is interned in makeFieldInfo() fieldName = fi.name; curHashCode = fieldName.hashCode(); } public String getFieldName() { return fieldName; } public String getColumnName(EntityDefinition ed) { if (fieldInfo != null && fieldInfo.ed.fullEntityName.equals(ed.fullEntityName)) return fieldInfo.getFullColumnName(); return ed.getColumnName(fieldName); } public FieldInfo getFieldInfo(EntityDefinition ed) { if (fieldInfo != null && fieldInfo.ed.fullEntityName.equals(ed.fullEntityName)) return fieldInfo; return ed.getFieldInfo(fieldName); } @Override public String toString() { return fieldName; } @Override public int hashCode() { return curHashCode; } @Override public boolean equals(Object o) { if (o == null) return false; // because of reuse from EntityDefinition this may be the same object, so check that first if (this == o) return true; if (o.getClass() != thisClass) return false; ConditionField that = (ConditionField) o; // intern'ed String to use == operator return fieldName == that.fieldName; } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(fieldName.toCharArray()); } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { fieldName = new String((char[]) in.readObject()).intern(); curHashCode = fieldName.hashCode(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/DateCondition.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityException import org.moqui.impl.entity.EntityDefinition import java.sql.Timestamp import org.moqui.impl.entity.EntityQueryBuilder @CompileStatic class DateCondition implements EntityConditionImplBase, Externalizable { protected ConditionField fromField protected ConditionField thruField protected Timestamp compareStamp private EntityConditionImplBase conditionInternal private int hashCodeInternal DateCondition(ConditionField fromField, ConditionField thruField, Timestamp compareStamp) { this.fromField = fromField this.thruField = thruField this.compareStamp = compareStamp conditionInternal = makeConditionInternal() hashCodeInternal = createHashCode() } DateCondition(String fromFieldName, String thruFieldName, Timestamp compareStamp) { this.fromField = new ConditionField(fromFieldName ?: "fromDate") this.thruField = new ConditionField(thruFieldName ?: "thruDate") if (compareStamp == (Timestamp) null) compareStamp = new Timestamp(System.currentTimeMillis()) this.compareStamp = compareStamp conditionInternal = makeConditionInternal() hashCodeInternal = createHashCode() } @Override void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { conditionInternal.makeSqlWhere(eqb, subMemberEd) } @Override void makeSearchFilter(List> filterList) { conditionInternal.makeSearchFilter(filterList) } @Override void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { if (fromField instanceof ConditionAlias) { entityAliasSet.add(((ConditionAlias) fromField).entityAlias) } else { fieldAliasSet.add(fromField.fieldName) } if (thruField instanceof ConditionAlias) { entityAliasSet.add(((ConditionAlias) thruField).entityAlias) } else { fieldAliasSet.add(thruField.fieldName) } } @Override EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return conditionInternal.filter(entityAlias, mainEd) } @Override boolean mapMatches(Map map) { return conditionInternal.mapMatches(map) } @Override boolean mapMatchesAny(Map map) { return conditionInternal.mapMatchesAny(map) } @Override boolean mapKeysNotContained(Map map) { return conditionInternal.mapKeysNotContained(map) } @Override boolean populateMap(Map map) { return false } @Override EntityCondition ignoreCase() { throw new EntityException("Ignore case not supported for DateCondition.") } @Override String toString() { return conditionInternal.toString() } private EntityConditionImplBase makeConditionInternal() { return new ListCondition([ new ListCondition([new FieldValueCondition(fromField, EQUALS, null), new FieldValueCondition(fromField, LESS_THAN_EQUAL_TO, compareStamp)] as List, EntityCondition.JoinOperator.OR), new ListCondition([new FieldValueCondition(thruField, EQUALS, null), new FieldValueCondition(thruField, GREATER_THAN, compareStamp)] as List, EntityCondition.JoinOperator.OR) ] as List, EntityCondition.JoinOperator.AND) } @Override int hashCode() { return hashCodeInternal } private int createHashCode() { return compareStamp.hashCode() + fromField.hashCode() + thruField.hashCode() } @Override boolean equals(Object o) { if (o == null || o.getClass() != this.getClass()) return false DateCondition that = (DateCondition) o if (!this.compareStamp.equals(that.compareStamp)) return false if (!fromField.equals(that.fromField)) return false if (!thruField.equals(that.thruField)) return false return true } @Override void writeExternal(ObjectOutput out) throws IOException { fromField.writeExternal(out) thruField.writeExternal(out) out.writeLong(compareStamp.getTime()) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { fromField = new ConditionField() fromField.readExternal(objectInput) thruField = new ConditionField() thruField.readExternal(objectInput) compareStamp = new Timestamp(objectInput.readLong()); hashCodeInternal = createHashCode(); conditionInternal = makeConditionInternal(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/EntityConditionImplBase.java ================================================ package org.moqui.impl.entity.condition; import org.moqui.entity.EntityCondition; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.EntityQueryBuilder; import java.util.List; import java.util.Map; import java.util.Set; public interface EntityConditionImplBase extends EntityCondition { /** Build SQL WHERE clause text to evaluate condition in a database. */ void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd); /** Build ElasticSearch style search filter */ void makeSearchFilter(List> filterList); void getAllAliases(Set entityAliasSet, Set fieldAliasSet); /** Get only conditions for fields in the member-entity of a view-entity, or if null then all aliases for member entities without sub-select=true */ EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd); } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/FieldToFieldCondition.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityQueryBuilder import org.moqui.impl.entity.EntityConditionFactoryImpl import org.moqui.impl.entity.FieldInfo import org.moqui.util.MNode @CompileStatic class FieldToFieldCondition implements EntityConditionImplBase { protected static final Class thisClass = FieldValueCondition.class protected ConditionField field protected EntityCondition.ComparisonOperator operator protected ConditionField toField protected boolean ignoreCase = false protected int curHashCode FieldToFieldCondition(ConditionField field, EntityCondition.ComparisonOperator operator, ConditionField toField) { this.field = field this.operator = operator ?: EQUALS this.toField = toField curHashCode = createHashCode() } @Override void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { StringBuilder sql = eqb.sqlTopLevel EntityDefinition mainEd = eqb.getMainEd() FieldInfo fi = field.getFieldInfo(mainEd) FieldInfo toFi = toField.getFieldInfo(mainEd) int typeValue = -1 if (ignoreCase) { typeValue = fi?.typeValue ?: 1 if (typeValue == 1) sql.append("UPPER(") } if (subMemberEd != null) { MNode aliasNode = fi.fieldNode String aliasField = aliasNode.attribute("field") if (aliasField == null || aliasField.isEmpty()) aliasField = fi.name sql.append(subMemberEd.getColumnName(aliasField)) } else { sql.append(field.getColumnName(mainEd)) } if (ignoreCase && typeValue == 1) sql.append(")") sql.append(' ').append(EntityConditionFactoryImpl.getComparisonOperatorString(operator)).append(' ') int toTypeValue = -1 if (ignoreCase) { toTypeValue = toField.getFieldInfo(mainEd)?.typeValue ?: 1 if (toTypeValue == 1) sql.append("UPPER(") } if (subMemberEd != null) { MNode aliasNode = toFi.fieldNode String aliasField = aliasNode.attribute("field") if (aliasField == null || aliasField.isEmpty()) aliasField = toFi.name sql.append(subMemberEd.getColumnName(aliasField)) } else { sql.append(toField.getColumnName(mainEd)) } if (ignoreCase && toTypeValue == 1) sql.append(")") } @Override void makeSearchFilter(List> filterList) { // TODO } @Override boolean mapMatches(Map map) { return EntityConditionFactoryImpl.compareByOperator(map.get(field.getFieldName()), operator, map.get(toField.getFieldName())) } @Override boolean mapMatchesAny(Map map) { return mapMatches(map) } @Override boolean mapKeysNotContained(Map map) { return !map.containsKey(field.fieldName) && !map.containsKey(toField.fieldName) } @Override boolean populateMap(Map map) { return false } @Override void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { // this will only be called for view-entity, so we'll either have a entityAlias or an aliased fieldName if (field instanceof ConditionAlias) { entityAliasSet.add(((ConditionAlias) field).entityAlias) } else { fieldAliasSet.add(field.fieldName) } if (toField instanceof ConditionAlias) { entityAliasSet.add(((ConditionAlias) toField).entityAlias) } else { fieldAliasSet.add(toField.fieldName) } } @Override EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { // only called for view-entity MNode fieldMe = field.getFieldInfo(mainEd).directMemberEntityNode MNode toFieldMe = toField.getFieldInfo(mainEd).directMemberEntityNode if (entityAlias == null) { if (fieldMe != null && toFieldMe != null) { String subSelectAttr = fieldMe.attribute("sub-select"); String toSubSelectAttr = toFieldMe.attribute("sub-select"); if (("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) && ("true".equals(toSubSelectAttr) || "non-lateral".equals(toSubSelectAttr)) && fieldMe.attribute("entity-alias").equals(toFieldMe.attribute("entity-alias"))) return null } return this } else { if ((fieldMe != null && entityAlias.equals(fieldMe.attribute("entity-alias"))) && (toFieldMe != null && entityAlias.equals(toFieldMe.attribute("entity-alias")))) return this return null } } @Override EntityCondition ignoreCase() { ignoreCase = true; curHashCode++; return this } @Override String toString() { return field.toString() + " " + EntityConditionFactoryImpl.getComparisonOperatorString(operator) + " " + toField.toString() } @Override int hashCode() { return curHashCode } private int createHashCode() { return (field ? field.hashCode() : 0) + operator.hashCode() + (toField ? toField.hashCode() : 0) + (ignoreCase ? 1 : 0) } @Override boolean equals(Object o) { if (o == null || o.getClass() != thisClass) return false FieldToFieldCondition that = (FieldToFieldCondition) o if (!field.equals(that.field)) return false // NOTE: for Java Enums the != is WAY faster than the .equals if (operator != that.operator) return false if (!toField.equals(that.toField)) return false if (ignoreCase != that.ignoreCase) return false return true } @Override void writeExternal(ObjectOutput out) throws IOException { field.writeExternal(out) out.writeUTF(operator.name()) toField.writeExternal(out) out.writeBoolean(ignoreCase) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { field = new ConditionField() field.readExternal(objectInput) operator = EntityCondition.ComparisonOperator.valueOf(objectInput.readUTF()) toField = new ConditionField() toField.readExternal(objectInput) } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/FieldValueCondition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.entity.EntityCondition; import org.moqui.entity.EntityException; import org.moqui.impl.entity.*; import org.moqui.impl.entity.EntityJavaUtil.EntityConditionParameter; import org.moqui.util.CollectionUtilities; import org.moqui.util.MNode; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Externalizable; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.*; public class FieldValueCondition implements EntityConditionImplBase, Externalizable { protected final static Logger logger = LoggerFactory.getLogger(FieldValueCondition.class); private static final Class thisClass = FieldValueCondition.class; protected ConditionField field; protected ComparisonOperator operator; protected Object value; protected boolean ignoreCase = false; private int curHashCode; public FieldValueCondition() { } public FieldValueCondition(ConditionField field, ComparisonOperator operator, Object value) { this.field = field; this.value = value; // default to EQUALS ComparisonOperator tempOp = operator != null ? operator : EQUALS; // if EQUALS and we have a Collection value the IN operator is implied, similar with NOT_EQUAL if (value instanceof Collection) { if (tempOp == EQUALS) tempOp = IN; else if (tempOp == NOT_EQUAL) tempOp = NOT_IN; } this.operator = tempOp; curHashCode = createHashCode(); } public ComparisonOperator getOperator() { return operator; } public String getFieldName() { return field.fieldName; } public Object getValue() { return value; } public boolean getIgnoreCase() { return ignoreCase; } @Override public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") StringBuilder sql = eqb.sqlTopLevel; boolean valueDone = false; EntityDefinition curEd = subMemberEd != null ? subMemberEd : eqb.getMainEd(); FieldInfo fi = field.getFieldInfo(curEd); if (fi == null) throw new EntityException("Could not find field " + field.fieldName + " in entity " + curEd.getFullEntityName()); if (value instanceof Collection && ((Collection) value).isEmpty()) { if (operator == IN) { sql.append(" 1 = 2 "); valueDone = true; } else if (operator == NOT_IN) { sql.append(" 1 = 1 "); valueDone = true; } } else { if (ignoreCase && fi.typeValue == 1) sql.append("UPPER("); sql.append(field.getColumnName(curEd)); if (ignoreCase && fi.typeValue == 1) sql.append(')'); sql.append(' '); if (value == null) { if (operator == EQUALS || operator == LIKE || operator == IN || operator == BETWEEN) { sql.append(" IS NULL"); valueDone = true; } else if (operator == NOT_EQUAL || operator == NOT_LIKE || operator == NOT_IN || operator == NOT_BETWEEN) { sql.append(" IS NOT NULL"); valueDone = true; } } } if (operator == IS_NULL || operator == IS_NOT_NULL) { sql.append(EntityConditionFactoryImpl.getComparisonOperatorString(operator)); valueDone = true; } if (!valueDone) { // append operator sql.append(EntityConditionFactoryImpl.getComparisonOperatorString(operator)); // for IN/BETWEEN change string to collection if (operator == IN || operator == NOT_IN || operator == BETWEEN || operator == NOT_BETWEEN) value = valueToCollection(value); if (operator == IN || operator == NOT_IN) { if (value instanceof Collection) { sql.append(" ("); boolean isFirst = true; for (Object curValue : (Collection) value) { if (isFirst) isFirst = false; else sql.append(", "); sql.append("?"); if (ignoreCase && (curValue instanceof CharSequence)) curValue = curValue.toString().toUpperCase(); eqb.parameters.add(new EntityConditionParameter(fi, curValue, eqb)); } sql.append(')'); } else { if (ignoreCase && (value instanceof CharSequence)) value = value.toString().toUpperCase(); sql.append(" (?)"); eqb.parameters.add(new EntityConditionParameter(fi, value, eqb)); } } else if ((operator == BETWEEN || operator == NOT_BETWEEN) && value instanceof Collection && ((Collection) value).size() == 2) { sql.append(" ? AND ?"); Iterator iterator = ((Collection) value).iterator(); Object value1 = iterator.next(); if (ignoreCase && (value1 instanceof CharSequence)) value1 = value1.toString().toUpperCase(); Object value2 = iterator.next(); if (ignoreCase && (value2 instanceof CharSequence)) value2 = value2.toString().toUpperCase(); eqb.parameters.add(new EntityConditionParameter(fi, value1, eqb)); eqb.parameters.add(new EntityConditionParameter(fi, value2, eqb)); } else { if (ignoreCase && (value instanceof CharSequence)) value = value.toString().toUpperCase(); sql.append(" ?"); eqb.parameters.add(new EntityConditionParameter(fi, value, eqb)); } } } Object valueToCollection(Object value) { if (value instanceof CharSequence) { String valueStr = value.toString(); // note: used to do this, now always put in List: if (valueStr.contains(",")) value = Arrays.asList(valueStr.split(",")); } // TODO: any other useful types to convert? return value; } @Override public void makeSearchFilter(List> filterList) { boolean isNot = false; switch (operator) { case NOT_EQUAL: isNot = true; case EQUALS: Map termMap = CollectionUtilities.toHashMap("term", CollectionUtilities.toHashMap(field.fieldName, CollectionUtilities.toHashMap("value", value, "case_insensitive", ignoreCase))); if (isNot) { filterList.add(CollectionUtilities.toHashMap("bool", CollectionUtilities.toHashMap("must_not", termMap))); } else { filterList.add(termMap); } break; case NOT_IN: isNot = true; case IN: value = valueToCollection(value); Map termsMap = CollectionUtilities.toHashMap("terms", CollectionUtilities.toHashMap(field.fieldName, value)); if (isNot) { filterList.add(CollectionUtilities.toHashMap("bool", CollectionUtilities.toHashMap("must_not", termsMap))); } else { filterList.add(termsMap); } break; case NOT_LIKE: isNot = true; case LIKE: // this won't be quite the same as SQL, but close: // - % => * same, zero to many of any char // - _ => ? not same, _ is one of any char while ? is zero to one of any char if (value instanceof CharSequence) { String valueStr = value.toString(); valueStr = valueStr.replaceAll("%", "*"); valueStr = valueStr.replaceAll("_", "?"); value = valueStr; } Map wildcardMap = CollectionUtilities.toHashMap("wildcard", CollectionUtilities.toHashMap(field.fieldName, CollectionUtilities.toHashMap("value", value))); if (isNot) { filterList.add(CollectionUtilities.toHashMap("bool", CollectionUtilities.toHashMap("must_not", wildcardMap))); } else { filterList.add(wildcardMap); } break; case NOT_BETWEEN: isNot = true; case BETWEEN: value = valueToCollection(value); if (value instanceof Collection && ((Collection) value).size() == 2) { Iterator iterator = ((Collection) value).iterator(); Object value1 = iterator.next(); Object value2 = iterator.next(); Map rangeMap = CollectionUtilities.toHashMap("range", CollectionUtilities.toHashMap(field.fieldName, CollectionUtilities.toHashMap("gte", value1, "lte", value2))); if (isNot) { filterList.add(CollectionUtilities.toHashMap("bool", CollectionUtilities.toHashMap("must_not", rangeMap))); } else { filterList.add(rangeMap); } } else { throw new IllegalArgumentException("BETWEEN requires a Collection type value with 2 entries"); } break; case IS_NULL: filterList.add(CollectionUtilities.toHashMap("bool", CollectionUtilities.toHashMap("must_not", CollectionUtilities.toHashMap("exists", CollectionUtilities.toHashMap("field", field.fieldName))))); break; case IS_NOT_NULL: filterList.add(CollectionUtilities.toHashMap("exists", CollectionUtilities.toHashMap("field", field.fieldName))); break; case LESS_THAN: case LESS_THAN_EQUAL_TO: case GREATER_THAN: case GREATER_THAN_EQUAL_TO: filterList.add(CollectionUtilities.toHashMap("range", CollectionUtilities.toHashMap(field.fieldName, CollectionUtilities.toHashMap(getElasticOperator(), value)))); break; } } String getElasticOperator() { switch (operator) { case LESS_THAN: return "lt"; case LESS_THAN_EQUAL_TO: return "lte"; case GREATER_THAN: return "gt"; case GREATER_THAN_EQUAL_TO: return "gte"; default: return null; } } @Override public boolean mapMatches(Map map) { return EntityConditionFactoryImpl.compareByOperator(map.get(field.fieldName), operator, value); } @Override public boolean mapMatchesAny(Map map) { return mapMatches(map); } @Override public boolean mapKeysNotContained(Map map) { return !map.containsKey(field.fieldName); } @Override public boolean populateMap(Map map) { if (operator != EQUALS || ignoreCase || field instanceof ConditionAlias) return false; map.put(field.fieldName, value); return true; } @Override public void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { // this will only be called for view-entity, so we'll either have a entityAlias or an aliased fieldName if (field instanceof ConditionAlias) { entityAliasSet.add(((ConditionAlias) field).entityAlias); } else { fieldAliasSet.add(field.fieldName); } } @Override public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { // only called for view-entity FieldInfo fi = field.getFieldInfo(mainEd); MNode fieldMe = fi.directMemberEntityNode; if (entityAlias == null) { if (fieldMe != null) { String subSelectAttr = fieldMe.attribute("sub-select"); if ("true".equals(subSelectAttr) || "non-lateral".equals(subSelectAttr)) return null; } return this; } else { if (fieldMe != null && entityAlias.equals(fieldMe.attribute("entity-alias"))) { if (fi.aliasFieldName != null && !fi.aliasFieldName.equals(field.fieldName)) { FieldValueCondition newCond = new FieldValueCondition(new ConditionField(fi.aliasFieldName), operator, value); if (ignoreCase) newCond.ignoreCase(); return newCond; } return this; } return null; } } @Override public EntityCondition ignoreCase() { this.ignoreCase = true; curHashCode++; return this; } @Override public String toString() { return field.toString() + " " + EntityConditionFactoryImpl.getComparisonOperatorString(this.operator) + " " + (value != null ? value.toString() + " (" + value.getClass().getName() + ")" : "null"); } @Override public int hashCode() { return curHashCode; } private int createHashCode() { return (field != null ? field.hashCode() : 0) + operator.hashCode() + (value != null ? value.hashCode() : 0) + (ignoreCase ? 1 : 0); } @Override public boolean equals(Object o) { if (o == null || o.getClass() != thisClass) return false; FieldValueCondition that = (FieldValueCondition) o; if (!field.equals(that.field)) return false; if (value != null) { if (!value.equals(that.value)) return false; } else { if (that.value != null) return false; } return operator == that.operator && ignoreCase == that.ignoreCase; } @Override public void writeExternal(ObjectOutput out) throws IOException { field.writeExternal(out); // NOTE: found that the serializer in Hazelcast is REALLY slow with writeUTF(), uses String.chatAt() in a for loop, crazy out.writeObject(operator.name().toCharArray()); out.writeObject(value); out.writeBoolean(ignoreCase); } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { field = new ConditionField(); field.readExternal(in); operator = ComparisonOperator.valueOf(new String((char[]) in.readObject())); value = in.readObject(); ignoreCase = in.readBoolean(); curHashCode = createHashCode(); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/ListCondition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.entity.EntityException; import org.moqui.impl.entity.EntityConditionFactoryImpl; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.EntityQueryBuilder; import org.moqui.entity.EntityCondition; import org.moqui.util.CollectionUtilities; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.*; public class ListCondition implements EntityConditionImplBase { private ArrayList conditionList = new ArrayList<>(); protected JoinOperator operator; private int conditionListSize = 0; private int curHashCode; private static final Class thisClass = ListCondition.class; public ListCondition(List conditionList, JoinOperator operator) { this.operator = operator != null ? operator : AND; if (conditionList != null) { conditionListSize = conditionList.size(); if (conditionListSize > 0) { if (conditionList instanceof RandomAccess) { // avoid creating an iterator if possible int listSize = conditionList.size(); for (int i = 0; i < listSize; i++) { EntityConditionImplBase cond = conditionList.get(i); if (cond != null) this.conditionList.add(cond); } } else { Iterator conditionIter = conditionList.iterator(); while (conditionIter.hasNext()) { EntityConditionImplBase cond = conditionIter.next(); if (cond != null) this.conditionList.add(cond); } } } } curHashCode = createHashCode(); } public void addCondition(EntityConditionImplBase condition) { if (condition != null) conditionList.add(condition); curHashCode = createHashCode(); conditionListSize = conditionList.size(); } public void addConditions(ArrayList condList) { int condListSize = condList != null ? condList.size() : 0; if (condListSize == 0) return; for (int i = 0; i < condListSize; i++) addCondition(condList.get(i)); curHashCode = createHashCode(); conditionListSize = conditionList.size(); } public void addConditions(ListCondition listCond) { addConditions(listCond.getConditionList()); } public JoinOperator getOperator() { return operator; } public ArrayList getConditionList() { return conditionList; } @SuppressWarnings("MismatchedQueryAndUpdateOfStringBuilder") @Override public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { if (conditionListSize == 0) return; StringBuilder sql = eqb.sqlTopLevel; String joinOpString = EntityConditionFactoryImpl.getJoinOperatorString(this.operator); if (conditionListSize > 1) sql.append('('); for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); if (i > 0) sql.append(' ').append(joinOpString).append(' '); condition.makeSqlWhere(eqb, subMemberEd); } if (conditionListSize > 1) sql.append(')'); } @Override public void makeSearchFilter(List> filterList) { if (conditionListSize == 0) return; List> childList = new ArrayList<>(conditionListSize); for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); condition.makeSearchFilter(childList); } Map boolMap = new HashMap<>(); if (operator == AND) boolMap.put("filter", childList); else boolMap.put("should", childList); filterList.add(CollectionUtilities.toHashMap("bool", boolMap)); } @Override public boolean mapMatches(Map map) { for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); boolean conditionMatches = condition.mapMatches(map); if (conditionMatches && this.operator == OR) return true; if (!conditionMatches && this.operator == AND) return false; } // if we got here it means that it's an OR with no trues, or an AND with no falses return (this.operator == AND); } @Override public boolean mapMatchesAny(Map map) { for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); boolean conditionMatches = condition.mapMatchesAny(map); if (conditionMatches) return true; } return false; } @Override public boolean mapKeysNotContained(Map map) { for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); boolean notContained = condition.mapKeysNotContained(map); if (!notContained) return false; } // if we got here it means that it's an OR with no trues, or an AND with no falses return true; } @Override public boolean populateMap(Map map) { if (operator != AND) return false; for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); if (!condition.populateMap(map)) return false; } return true; } @Override public void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); condition.getAllAliases(entityAliasSet, fieldAliasSet); } } @Override public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { ArrayList filteredList = new ArrayList<>(conditionList.size()); for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase curCond = conditionList.get(i); EntityConditionImplBase filterCond = curCond.filter(entityAlias, mainEd); if (filterCond != null) filteredList.add(filterCond); } int filteredSize = filteredList.size(); if (filteredSize == conditionListSize) return this; if (filteredSize == 0) return null; // keep OR conditions together: return all if entityAlias is null (top-level where) or null if not (sub-select where) if (operator == OR) { if (entityAlias == null) return this; return null; } else { if (filteredSize == 1) return filteredList.get(0); return new ListCondition(filteredList, operator); } } @Override public EntityCondition ignoreCase() { throw new EntityException("Ignore case not supported for this type of condition."); } @Override public String toString() { StringBuilder sb = new StringBuilder(); for (int i = 0; i < conditionListSize; i++) { EntityConditionImplBase condition = conditionList.get(i); if (sb.length() > 0) sb.append(' ').append(EntityConditionFactoryImpl.getJoinOperatorString(this.operator)).append(' '); sb.append('(').append(condition.toString()).append(')'); } return sb.toString(); } @Override public int hashCode() { return curHashCode; } private int createHashCode() { return (conditionList != null ? conditionList.hashCode() : 0) + operator.hashCode(); } @Override public boolean equals(Object o) { if (o == null || o.getClass() != thisClass) return false; ListCondition that = (ListCondition) o; // NOTE: for Java Enums the != is WAY faster than the .equals return this.operator == that.operator && this.conditionList.equals(that.conditionList); } @Override public void writeExternal(ObjectOutput out) throws IOException { out.writeObject(conditionList); out.writeObject(operator.name().toCharArray()); } @Override @SuppressWarnings("unchecked") public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { conditionList = (ArrayList) in.readObject(); operator = JoinOperator.valueOf(new String((char[]) in.readObject())); curHashCode = createHashCode(); conditionListSize = conditionList != null ? conditionList.size() : 0; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/TrueCondition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition; import org.moqui.entity.EntityCondition; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.EntityQueryBuilder; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.*; public class TrueCondition implements EntityConditionImplBase { private static final Class thisClass = TrueCondition.class; public TrueCondition() { } @Override public void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { eqb.sqlTopLevel.append("1=1"); } @Override public void makeSearchFilter(List> filterList) { // TODO how would this work in ES? } @Override public boolean mapMatches(Map map) { return true; } @Override public boolean mapMatchesAny(Map map) { return true; } @Override public boolean mapKeysNotContained(Map map) { return true; } @Override public boolean populateMap(Map map) { return true; } @Override public void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { } @Override public EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return entityAlias == null ? this : null; } @Override public EntityCondition ignoreCase() { return this; } @Override public String toString() { return "1=1"; } @Override public int hashCode() { return 127; } @Override public boolean equals(Object o) { return !(o == null || o.getClass() != thisClass); } @Override public void writeExternal(ObjectOutput out) throws IOException { } @Override public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/condition/WhereCondition.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.condition import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityException import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityQueryBuilder import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class WhereCondition implements EntityConditionImplBase { protected final static Logger logger = LoggerFactory.getLogger(WhereCondition.class) protected String sqlWhereClause WhereCondition(String sqlWhereClause) { this.sqlWhereClause = sqlWhereClause != null ? sqlWhereClause : "" } @Override void makeSqlWhere(EntityQueryBuilder eqb, EntityDefinition subMemberEd) { eqb.sqlTopLevel.append(this.sqlWhereClause) } @Override public void makeSearchFilter(List> filterList) { throw new IllegalArgumentException("Where Condition not supported for Elastic Entity") } @Override boolean mapMatches(Map map) { // NOTE: always return false unless we eventually implement some sort of SQL parsing, for caching/etc // always consider not matching logger.warn("The mapMatches for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]") return false } @Override boolean mapMatchesAny(Map map) { // NOTE: always return true unless we eventually implement some sort of SQL parsing, for caching/etc // always consider matching so cache values are cleared logger.warn("The mapMatchesAny for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]") return true } @Override boolean mapKeysNotContained(Map map) { // always consider matching so cache values are cleared logger.warn("The mapMatchesAny for the SQL Where Condition is not supported, text is [${this.sqlWhereClause}]") return true } @Override boolean populateMap(Map map) { return false } @Override void getAllAliases(Set entityAliasSet, Set fieldAliasSet) { } @Override EntityConditionImplBase filter(String entityAlias, EntityDefinition mainEd) { return entityAlias == null ? this : null } @Override EntityCondition ignoreCase() { throw new EntityException("Ignore case not supported for this type of condition.") } @Override String toString() { return sqlWhereClause } @Override int hashCode() { return (sqlWhereClause != null ? sqlWhereClause.hashCode() : 0) } @Override boolean equals(Object o) { if (o == null || o.getClass() != getClass()) return false WhereCondition that = (WhereCondition) o if (!sqlWhereClause.equals(that.sqlWhereClause)) return false return true } @Override void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(sqlWhereClause) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { sqlWhereClause = objectInput.readUTF() } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticDatasourceFactory.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.elastic import groovy.json.JsonOutput import groovy.transform.CompileStatic import org.moqui.context.ElasticFacade import org.moqui.entity.* import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.entity.EntityValueBase import org.moqui.impl.entity.FieldInfo import org.moqui.util.LiteStringMap import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.xml.bind.DatatypeConverter import javax.sql.DataSource import java.sql.Time import java.sql.Timestamp /** * To use this: * 1. add a datasource under the entity-facade element in the Moqui Conf file; for example: * * * * * 2. to get OrientDB to automatically create the database, add a corresponding "storage" element to the * orientdb-server-config.xml file * * 3. add the group attribute to entity elements as needed to point them to the new datasource; for example: * group="nontransactional" */ @CompileStatic class ElasticDatasourceFactory implements EntityDatasourceFactory { protected final static Logger logger = LoggerFactory.getLogger(ElasticDatasourceFactory.class) protected EntityFacadeImpl efi protected MNode datasourceNode protected String indexPrefix, clusterName protected Set checkedEntityIndexSet = new HashSet() ElasticDatasourceFactory() { } @Override EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode) { // local fields this.efi = (EntityFacadeImpl) ef this.datasourceNode = datasourceNode // init the DataSource MNode inlineOtherNode = datasourceNode.first("inline-other") inlineOtherNode.setSystemExpandAttributes(true) indexPrefix = inlineOtherNode.attribute("index-prefix") ?: "" clusterName = inlineOtherNode.attribute("cluster-name") ?: "default" return this } @Override void destroy() { } @Override boolean checkTableExists(String entityName) { EntityDefinition ed try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false } if (ed == null) return false String indexName = getIndexName(ed) if (checkedEntityIndexSet.contains(indexName)) return true if (!getElasticClient().indexExists(indexName)) return false checkedEntityIndexSet.add(indexName) return true } @Override boolean checkAndAddTable(String entityName) { EntityDefinition ed try { ed = efi.getEntityDefinition(entityName) } catch (EntityException e) { return false } if (ed == null) return false checkCreateDocumentIndex(ed) return true } @Override int checkAndAddAllTables() { int tablesAdded = 0 String groupName = datasourceNode.attribute("group-name") for (String entityName in efi.getAllEntityNames()) { String entGroupName = efi.getEntityGroupName(entityName) ?: efi.defaultGroupName if (entGroupName.equals(groupName)) { if (checkAndAddTable(entityName)) tablesAdded++ } } return tablesAdded } @Override EntityValue makeEntityValue(String entityName) { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed == null) throw new EntityException("Entity not found for name ${entityName}") return new ElasticEntityValue(ed, efi, this) } @Override EntityFind makeEntityFind(String entityName) { return new ElasticEntityFind(efi, entityName, this) } @Override void createBulk(List valueList) { if (valueList == null || valueList.isEmpty()) return ElasticFacade.ElasticClient elasticClient = getElasticClient() EntityValueBase firstEv = (EntityValueBase) valueList.get(0) EntityDefinition ed = firstEv.getEntityDefinition() FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray String idField = pkFieldInfos.length == 1 ? pkFieldInfos[0].name : "_id" List mapList = new ArrayList(valueList.size()) Iterator valueIterator = valueList.iterator() while (valueIterator.hasNext()) { EntityValueBase ev = (EntityValueBase) valueIterator.next() LiteStringMap evMap = ev.getValueMap() // to pass a key/id for each record it has to be in the Map, this will cause the LiteStringMap to grow // the array for the additional field, so there is a performance overhead to this if (pkFieldInfos.length > 1) evMap.put("_id", ev.getPrimaryKeysString()) mapList.add(evMap) } checkCreateDocumentIndex(ed) elasticClient.bulkIndex(getIndexName(ed), (String) null, idField, (List) mapList, true) } @Override DataSource getDataSource() { // no DataSource for this 'db', return nothing and EntityFacade ignores it and Connection parameters will be null (in ElasticEntityValue, etc) return null } void checkCreateDocumentIndex(EntityDefinition ed) { String indexName = getIndexName(ed) if (checkedEntityIndexSet.contains(indexName)) return ElasticFacade.ElasticClient elasticClient = efi.ecfi.elasticFacade.getClient(clusterName) if (elasticClient == null) throw new IllegalStateException("No ElasticClient found for cluster name " + clusterName) if (!elasticClient.indexExists(indexName)) { Map mapping = makeElasticEntityMapping(ed) // logger.warn("Creating ES Index ${indexName} with mapping: ${JsonOutput.prettyPrint(JsonOutput.toJson(mapping))}") elasticClient.createIndex(indexName, mapping, null) } checkedEntityIndexSet.add(indexName) } ElasticFacade.ElasticClient getElasticClient() { ElasticFacade.ElasticClient client = efi.ecfi.elasticFacade.getClient(clusterName) if (client == null) throw new IllegalStateException("No ElasticClient found for cluster name " + clusterName) return client } String getIndexName(EntityDefinition ed) { return indexPrefix + ed.getTableNameLowerCase() } static Object convertFieldValue(FieldInfo fi, Object fValue) { if (fi.typeValue == EntityFacadeImpl.ENTITY_TIMESTAMP) { if (fValue instanceof Number) { return new Timestamp(((Number) fValue).longValue()) } else if (fValue instanceof CharSequence) { Calendar cal = DatatypeConverter.parseDateTime(fValue.toString()) if (cal != null) return new Timestamp(cal.getTimeInMillis()) } } else if (fi.typeValue == EntityFacadeImpl.ENTITY_DATE) { if (fValue instanceof Number) { return new java.sql.Date(((Number) fValue).longValue()) } else if (fValue instanceof CharSequence) { Calendar cal = DatatypeConverter.parseDate(fValue.toString()) if (cal != null) return new java.sql.Date(cal.getTimeInMillis()) } } else if (fi.typeValue == EntityFacadeImpl.ENTITY_TIME) { if (fValue instanceof Number) { return new Time(((Number) fValue).longValue()) } else if (fValue instanceof CharSequence) { Calendar cal = DatatypeConverter.parseTime(fValue.toString()) if (cal != null) return new Time(cal.getTimeInMillis()) } } return fValue } static final Map esEntityTypeMap = [id:'keyword', 'id-long':'keyword', date:'date', time:'keyword', 'date-time':'date', 'number-integer':'long', 'number-decimal':'double', 'number-float':'double', 'currency-amount':'double', 'currency-precise':'double', 'text-indicator':'keyword', 'text-short':'text', 'text-medium':'text', 'text-intermediate':'text', 'text-long':'text', 'text-very-long':'text', 'binary-very-long':'binary'] static final Set esEntityIsKeywordSet = esEntityTypeMap.findAll({"keyword".equals(it.value)}).keySet() static final Set esEntityAddKeywordSet = new HashSet<>(['text-short', 'text-medium', 'text-intermediate']) static Map makeElasticEntityMapping(EntityDefinition ed) { Map rootProperties = [_entity:[type:'keyword']] as Map Map mappingMap = [properties:rootProperties] as Map FieldInfo[] allFieldInfo = ed.entityInfo.allFieldInfoArray for (int i = 0; i < allFieldInfo.length; i++) { FieldInfo fieldInfo = allFieldInfo[i] rootProperties.put(fieldInfo.name, makeEntityFieldPropertyMap(fieldInfo)) } return mappingMap } static Map makeEntityFieldPropertyMap(FieldInfo fieldInfo) { String mappingType = esEntityTypeMap.get(fieldInfo.type) ?: 'text' Map propertyMap = new LinkedHashMap<>() propertyMap.put("type", mappingType) if (esEntityAddKeywordSet.contains(fieldInfo.type) && "text".equals(mappingType)) propertyMap.put("fields", [keyword: [type: "keyword"]]) if ("date-time".equals(fieldInfo.type)) propertyMap.format = "date_time||epoch_millis||date_time_no_millis||yyyy-MM-dd HH:mm:ss.SSS||yyyy-MM-dd HH:mm:ss.S||yyyy-MM-dd" else if ("date".equals(fieldInfo.type)) propertyMap.format = "date||strict_date_optional_time||epoch_millis" return propertyMap } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityFind.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.elastic; import org.moqui.context.ElasticFacade; import org.moqui.entity.EntityDynamicView; import org.moqui.entity.EntityException; import org.moqui.entity.EntityListIterator; import org.moqui.impl.entity.*; import org.moqui.impl.entity.condition.EntityConditionImplBase; import org.moqui.util.CollectionUtilities; import org.moqui.util.LiteStringMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.*; public class ElasticEntityFind extends EntityFindBase { protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityValue.class); private final ElasticDatasourceFactory edf; public ElasticEntityFind(EntityFacadeImpl efip, String entityName, ElasticDatasourceFactory edf) { super(efip, entityName); this.edf = edf; } @Override public EntityDynamicView makeEntityDynamicView() { throw new UnsupportedOperationException("EntityDynamicView is not yet supported for Orient DB"); } Map makeQueryMap(EntityConditionImplBase whereCondition) { List> filterList = new ArrayList<>(); whereCondition.makeSearchFilter(filterList); Map boolMap = new HashMap<>(); boolMap.put("filter", filterList); Map queryMap = new HashMap<>(); queryMap.put("bool", boolMap); return queryMap; } List makeSortList(ArrayList orderByExpanded, EntityDefinition ed) { int orderByExpandedSize = orderByExpanded != null ? orderByExpanded.size() : 0; if (orderByExpandedSize > 0) { List sortList = new ArrayList<>(orderByExpandedSize); for (int i = 0; i < orderByExpandedSize; i++) { String sortField = orderByExpanded.get(i); EntityJavaUtil.FieldOrderOptions foo = new EntityJavaUtil.FieldOrderOptions(sortField); // to make this more fun, need to look for fields which have: keyword child field, text with no keyword (can't sort) String fieldName = foo.getFieldName(); FieldInfo fi = ed.getFieldInfo(fieldName); if (ElasticDatasourceFactory.getEsEntityAddKeywordSet().contains(fi.type)) fieldName += ".keyword"; else if ("text".equals(ElasticDatasourceFactory.getEsEntityTypeMap().get(fi.type))) throw new IllegalArgumentException("Cannot sort by field " + fi.name + " with type " + fi.type); if (foo.getDescending()) { sortList.add(CollectionUtilities.toHashMap(fieldName, "desc")); } else { sortList.add(fieldName); } } // logger.warn("new sortList " + sortList + " from orderBy " + orderByExpanded); return sortList; } return null; } @Override public EntityValueBase oneExtended(EntityConditionImplBase whereCondition, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray) throws EntityException { EntityDefinition ed = this.getEntityDef(); if (ed.isViewEntity) throw new EntityException("Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)"); edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects, // will perform better and have way less memory overhead, but code will be a lot more complicated // optimization if we have full PK: use ElasticClient.get() if (tempHasFullPk) { // we may have a singleCondField/Value OR simpleAndMap with the PK String combinedId; if (singleCondField != null) { combinedId = singleCondValue.toString(); } else { combinedId = ed.getPrimaryKeysString(simpleAndMap); } Map getResponse = elasticClient.get(edf.getIndexName(ed), combinedId); if (getResponse == null) return null; Map dbValue = (Map) getResponse.get("_source"); if (dbValue == null) return null; ElasticEntityValue newValue = new ElasticEntityValue(ed, efi, edf); LiteStringMap valueMap = newValue.getValueMap(); FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray; for (int j = 0; j < allFieldArray.length; j++) { FieldInfo fi = allFieldArray[j]; Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, dbValue.get(fi.name)); valueMap.putByIString(fi.name, fValue, fi.index); } newValue.setSyncedWithDb(); return newValue; } else { Map searchMap = new LinkedHashMap<>(); // query if (whereCondition != null) searchMap.put("query", makeQueryMap(whereCondition)); // _source or fields // TODO: use _source or fields to get partial documents, some possible oddness to it: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html // size searchMap.put("size", 1); logger.warn("find one elastic searchMap " + searchMap); Map resultMap = elasticClient.search(edf.getIndexName(ed), searchMap); Map hitsMap = (Map) resultMap.get("hits"); List hitsList = (List) hitsMap.get("hits"); if (hitsList != null && hitsList.size() > 0) { Map firstHit = (Map) hitsList.get(0); if (firstHit != null) { Map hitSource = (Map) firstHit.get("_source"); ElasticEntityValue newValue = new ElasticEntityValue(ed, efi, edf); LiteStringMap valueMap = newValue.getValueMap(); int size = fieldInfoArray.length; for (int i = 0; i < size; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; valueMap.putByIString(fi.name, hitSource.get(fi.name), fi.index); } newValue.setSyncedWithDb(); return newValue; } } return null; } } @Override public EntityListIterator iteratorExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, ArrayList orderByExpanded, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray) throws EntityException { EntityDefinition ed = this.getEntityDef(); if (ed.isViewEntity) throw new EntityException("Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)"); if (havingCondition != null) throw new EntityException("Having condition not supported, no view-entity support yet (future feature along with single-entity view entities for aggregations)"); // also not supported: if (this.getDistinct()) efb.makeDistinct() // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects, // will perform better and have way less memory overhead, but code will be a lot more complicated Map searchMap = new LinkedHashMap<>(); // query Map queryMap = whereCondition != null ? makeQueryMap(whereCondition) : null; if (queryMap == null || queryMap.isEmpty()) queryMap = CollectionUtilities.toHashMap("match_all", Collections.EMPTY_MAP); searchMap.put("query", queryMap); // _source or fields // TODO: use _source or fields to get partial documents, some possible oddness to it: https://www.elastic.co/guide/en/elasticsearch/reference/current/mapping-source-field.html // sort with fieldOptionsArray List sortList = makeSortList(orderByExpanded, ed); if (sortList == null) { // if no sort, sort by PK fields by default (for pagination over large queries a sort order is always required) sortList = new LinkedList<>(); FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray; for (int i = 0; i < pkFieldInfos.length; i++) { FieldInfo fi = pkFieldInfos[i]; sortList.add(fi.name); } } searchMap.put("sort", sortList); // from & size if (this.offset != null) searchMap.put("from", this.offset); if (this.limit != null) searchMap.put("size", this.limit); edf.checkCreateDocumentIndex(ed); return new ElasticEntityListIterator(searchMap, ed, fieldInfoArray, fieldOptionsArray, edf, txCache, whereCondition, orderByExpanded); } @Override public long countExtended(EntityConditionImplBase whereCondition, EntityConditionImplBase havingCondition, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray) throws EntityException { EntityDefinition ed = this.getEntityDef(); if (ed.isViewEntity) throw new EntityException("Multi-entity view entities are not supported, Elastic/OpenSearch does not support joins; single-entity view entities for aggregations are not yet supported (future feature)"); if (havingCondition != null) throw new EntityException("Having condition not supported, no view-entity support yet (future feature along with single-entity view entities for aggregations)"); // also not supported: if (this.getDistinct()) efb.makeDistinct() // TODO FUTURE: consider building a JSON string instead of Map/List structure with lots of objects, // will perform better and have way less memory overhead, but code will be a lot more complicated Map countMap = new LinkedHashMap<>(); // query if (whereCondition != null) countMap.put("query", makeQueryMap(whereCondition)); // NOTE: if no where condition don't need to add default all query map, ElasticClient.countResponse() does this (used by count()) edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); return elasticClient.count(edf.getIndexName(ed), countMap); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityListIterator.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.elastic; import groovy.json.JsonOutput; import org.moqui.BaseArtifactException; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.context.ElasticFacade; import org.moqui.entity.*; import org.moqui.impl.context.TransactionCache; import org.moqui.impl.entity.*; import org.moqui.impl.entity.condition.EntityConditionImplBase; import org.moqui.util.CollectionUtilities; import org.moqui.util.LiteStringMap; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.Writer; import java.util.*; public class ElasticEntityListIterator implements EntityListIterator { protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityListIterator.class); static final int MAX_FETCH_SIZE = 100; static final int CUR_LIST_MAX_SIZE = MAX_FETCH_SIZE * 3; private int fetchSize = 50; protected final ElasticDatasourceFactory edf; protected final EntityFacadeImpl efi; protected final Map originalSearchMap; private final EntityDefinition entityDefinition; protected final FieldInfo[] fieldInfoArray; private final int fieldInfoListSize; private ArrayList currentDocList = new ArrayList<>(CUR_LIST_MAX_SIZE); private int overallIndex = -1, currentListStartIndex = -1; private Integer resultCount = null; private final Integer maxResultCount, originalFromInt; private String esPitId = null, esKeepAlive; private List esSearchAfter = null; private final TransactionCache txCache; private final EntityJavaUtil.FindAugmentInfo findAugmentInfo; private final int txcListSize; private int txcListIndex = -1; private final EntityConditionImplBase whereCondition; private final CollectionUtilities.MapOrderByComparator orderByComparator; private boolean haveMadeValue = false; protected boolean closed = false; private StackTraceElement[] constructStack = null; private final ArrayList artifactStack; public ElasticEntityListIterator(Map searchMap, EntityDefinition entityDefinition, FieldInfo[] fieldInfoArray, EntityJavaUtil.FieldOrderOptions[] fieldOptionsArray, ElasticDatasourceFactory edf, TransactionCache txCache, EntityConditionImplBase whereCondition, ArrayList obf) { this.edf = edf; this.efi = edf.efi; this.originalSearchMap = searchMap; this.entityDefinition = entityDefinition; fieldInfoListSize = fieldInfoArray.length; this.fieldInfoArray = fieldInfoArray; this.whereCondition = whereCondition; this.txCache = txCache; if (txCache != null && whereCondition != null) { orderByComparator = obf != null && obf.size() > 0 ? new CollectionUtilities.MapOrderByComparator(obf) : null; // add all created values (updated and deleted values will be handled by the next() method findAugmentInfo = txCache.getFindAugmentInfo(entityDefinition.getFullEntityName(), whereCondition); if (findAugmentInfo.valueListSize > 0) { // update the order if we know the order by field list if (orderByComparator != null) findAugmentInfo.valueList.sort(orderByComparator); txcListSize = findAugmentInfo.valueListSize; } else { txcListSize = 0; } } else { findAugmentInfo = null; txcListSize = 0; orderByComparator = null; } // if there is a limit (size) then set that as the maxResultCount maxResultCount = (Integer) searchMap.get("size"); originalFromInt = (Integer) originalSearchMap.get("from"); esKeepAlive = efi.ecfi.transactionFacade.getTransactionTimeout() + "s"; // capture the current artifact stack for finalize not closed debugging, has minimal performance impact (still ~0.0038ms per call compared to numbers below) artifactStack = efi.ecfi.getEci().artifactExecutionFacade.getStackArray(); /* uncomment only if needed temporarily: huge performance impact, ~0.036ms per call with, ~0.0037ms without (~10x difference!) StackTraceElement[] tempStack = Thread.currentThread().getStackTrace(); if (tempStack.length > 20) tempStack = java.util.Arrays.copyOfRange(tempStack, 0, 20); constructStack = tempStack; */ } boolean isFirst() { return overallIndex == 0; } boolean isBeforeFirst() { return overallIndex < 0; } boolean isLast() { if (resultCount != null) { return overallIndex == resultCount - 1; } else { return false; } } boolean isAfterLast() { if (resultCount != null) { return overallIndex >= resultCount; } else { return false; } } boolean nextResult() { if (resultCount != null && overallIndex >= resultCount) return false; overallIndex++; if (overallIndex >= (currentListStartIndex + currentDocList.size())) { // make sure we aren't at the end if (resultCount != null && overallIndex >= resultCount) return false; // fetch next results fetchNext(); } // logger.warn("nextResult end resultCount " + resultCount + " overallIndex " + overallIndex + " currentListStartIndex " + currentListStartIndex + " currentDocList.size() " + currentDocList.size()); return hasCurrentValue(); } @SuppressWarnings("unchecked") void fetchNext() { if (this.closed) throw new IllegalStateException("EntityListIterator is closed, cannot fetch next results"); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); Map searchMap = new LinkedHashMap<>(originalSearchMap); // where to start (from)? int curFrom = currentListStartIndex + currentDocList.size(); if (curFrom < 0) curFrom = 0; // how many to get (size)? int curSize = fetchSize; if (resultCount != null && curFrom + curSize > resultCount) { // no more to get, return if (curFrom >= resultCount) return; curSize = resultCount - curFrom; } if (maxResultCount != null && curFrom + curSize > maxResultCount) { // no more to get, return if (curFrom >= maxResultCount) return; curSize = maxResultCount - curFrom; } // before doing the search, see if we need a PIT ID: if we can't get all in one fetch if (esPitId == null && (maxResultCount == null || maxResultCount > fetchSize)) { esPitId = elasticClient.getPitId(edf.getIndexName(entityDefinition), esKeepAlive); } // add PIT ID (pit.id, pit.keep_alive:1m (use tx length)), search_after if (esPitId != null) searchMap.put("pit", CollectionUtilities.toHashMap("id", esPitId, "keep_alive", esKeepAlive)); if (esSearchAfter != null) { // with search_after the from field should always be zero searchMap.put("search_after", esSearchAfter); searchMap.put("from", 0); } else { // if origFromInt has a value always add it just before the query, basically a starting offset for the whole query searchMap.put("from", originalFromInt != null ? curFrom + originalFromInt : curFrom); } searchMap.put("size", curSize); // if no resultCount yet then track_total_hits (also set to false for better performance on subsequent requests) searchMap.put("track_total_hits", resultCount == null); // logger.info("fetchNext request: " + JsonOutput.prettyPrint(JsonOutput.toJson(searchMap))); // do the query Map resultMap = elasticClient.search(esPitId != null ? null : edf.getIndexName(entityDefinition), searchMap); Map hitsMap = (Map) resultMap.get("hits"); List hitsList = (List) hitsMap.get("hits"); // log response without hits /* Map resultNoHits = new LinkedHashMap(resultMap); Map hitsNoHits = new LinkedHashMap(hitsMap); hitsNoHits.remove("hits"); resultNoHits.put("hits", hitsNoHits); logger.info("fetchNext response: " + JsonOutput.prettyPrint(JsonOutput.toJson(resultNoHits))); */ // set resultCount if we have one Map totalMap = (Map) hitsMap.get("total"); if (totalMap != null) { Integer hitsTotal = (Integer) totalMap.get("value"); String relation = (String) totalMap.get("relation"); // TODO remove this log message, only for testing behavior if (!"eq".equals(relation)) logger.warn("Got non eq total relation " + relation + " with value " + hitsTotal + " for entity " + entityDefinition.fullEntityName); if (hitsTotal != null && "eq".equals(relation)) resultCount = originalFromInt != null ? hitsTotal - originalFromInt : hitsTotal; } // process hits if (hitsList != null && hitsList.size() > 0) { int hitCount = hitsList.size(); if (hitCount > fetchSize) logger.warn("In ElasticEntityListIterator got back " + hitCount + " hits with fetchSize " + fetchSize); if (hitCount < curSize) { // we found the end int calcTotal = curFrom + hitCount; if (resultCount != calcTotal) logger.warn("In ElasticEntityListIterator reached end of results at " + calcTotal + " but server claimed " + resultCount + " total hits"); resultCount = calcTotal; } // do we need to make room in currentDocList? if (currentListStartIndex == -1) { currentListStartIndex = 0; } else { int avail = CUR_LIST_MAX_SIZE - currentDocList.size(); if (avail < hitCount) { // how many can we retain? int retain = hitCount - CUR_LIST_MAX_SIZE; if (retain < 0) retain = 0; int remove = currentDocList.size() - retain; if (retain == 0) { currentDocList.clear(); } else { // this is two array copies instead of potential one, but better than iterating manually to move elements or something currentDocList = new ArrayList<>(currentDocList.subList(remove, currentDocList.size())); currentDocList.ensureCapacity(CUR_LIST_MAX_SIZE); } // update start index currentListStartIndex += remove; } } // does the Jackson parser (used in ElasticFacade) use an ArrayList? probably not... Iterator overhead not too bad here anyway Iterator hitsIterator = hitsList.iterator(); while (hitsIterator.hasNext()) { Map hit = (Map) hitsIterator.next(); Map hitSource = (Map) hit.get("_source"); currentDocList.add(hitSource); if (!hitsIterator.hasNext()) { // get search_after from sort on last result List hitSort = (List) hit.get("sort"); if (hitSort != null) esSearchAfter = hitSort; } } } // logger.warn("fetchNext resultCount " + resultCount + " currentListStartIndex " + currentListStartIndex + " currentDocList size " + currentDocList.size()); } boolean previousResult() { if (overallIndex < 0) return false; overallIndex--; if (overallIndex < currentListStartIndex) { // make sure we aren't at the beginning if (overallIndex < 0) return false; // fetch previous results fetchPrevious(); } return hasCurrentValue(); } void fetchPrevious() { if (this.closed) throw new IllegalStateException("EntityListIterator is closed, cannot fetch previous results"); // TODO throw new BaseArtifactException("ElasticEntityListIterator.fetchPrevious() TODO"); } boolean hasCurrentValue() { // if the numbers are such that we have a result (after a fetchPrevious() if needed) then return true return overallIndex >= currentListStartIndex && overallIndex < (currentListStartIndex + currentDocList.size()); } void resetCurrentList() { // TODO: given multi-fetch space in the current list this could be optimized to avoid future fetch if currentListStartIndex < CUR_LIST_MAX_SIZE if (currentListStartIndex > 0) { currentListStartIndex = -1; currentDocList.clear(); esSearchAfter = null; } } @Override public void close() { if (this.closed) { logger.warn("EntityListIterator for entity " + this.entityDefinition.getFullEntityName() + " is already closed, not closing again"); } else { if (esPitId != null) { ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); elasticClient.deletePit(esPitId); esPitId = null; } this.closed = true; } } @Override public void afterLast() { throw new BaseArtifactException("ElasticEntityListIterator.afterLast() not currently supported"); // rs.afterLast(); // txcListIndex = txcListSize; } @Override public void beforeFirst() { txcListIndex = -1; overallIndex = -1; resetCurrentList(); } @Override public boolean last() { throw new BaseArtifactException("ElasticEntityListIterator.last() not currently supported"); /* if (txcListSize > 0) { try { rs.afterLast(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to last", e); } txcListIndex = txcListSize - 1; return true; } else { try { return rs.last(); } catch (SQLException e) { throw new EntityException("Error moving EntityListIterator to last", e); } } */ } @Override public boolean first() { txcListIndex = -1; overallIndex = 0; if (currentListStartIndex > 0) { resetCurrentList(); if (currentListStartIndex < 0) fetchNext(); } return hasCurrentValue(); } @Override public EntityValue currentEntityValue() { return currentEntityValueBase(); } public EntityValueBase currentEntityValueBase() { if (txcListIndex >= 0) return findAugmentInfo.valueList.get(txcListIndex); if (overallIndex == -1) return null; int curIndex = overallIndex - currentListStartIndex; Map docMap = currentDocList.get(curIndex); EntityValueImpl newEntityValue = new EntityValueImpl(entityDefinition, efi); LiteStringMap valueMap = newEntityValue.getValueMap(); for (int i = 0; i < fieldInfoListSize; i++) { FieldInfo fi = fieldInfoArray[i]; if (fi == null) break; Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, docMap.get(fi.name)); valueMap.putByIString(fi.name, fValue, fi.index); } newEntityValue.setSyncedWithDb(); // if txCache in place always put in cache for future reference (onePut handles any stale from DB issues too) // NOTE: because of this don't use txCache for very large result sets if (txCache != null) txCache.onePut(newEntityValue, false); haveMadeValue = true; return newEntityValue; } @Override public int currentIndex() { // NOTE: add one because this is based on the JDBC ResultSet object which is 1 based return overallIndex + txcListIndex + 1; } @Override public boolean absolute(final int rowNum) { // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go if (txcListSize > 0) throw new EntityException("Cannot go to absolute row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); // subtract 1 to convet to zero based index int internalIndex = rowNum - 1; if (internalIndex >= currentListStartIndex && internalIndex < (currentListStartIndex + currentDocList.size())) { overallIndex = internalIndex; } else { txcListIndex = -1; overallIndex = internalIndex; resetCurrentList(); fetchNext(); } return hasCurrentValue(); } @Override public boolean relative(final int rows) { throw new BaseArtifactException("ElasticEntityListIterator.relative() not currently supported"); // TODO: somehow implement this for txcList? would need to know how many rows after last we tried to go // if (txcListSize > 0) throw new EntityException("Cannot go to relative row number when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); // return rs.relative(rows); } @Override public boolean hasNext() { if (isLast() || isAfterLast()) { return txcListIndex < (txcListSize - 1); } else { // if not in the first or beforeFirst positions and haven't made any values yet, the result set is empty return !(!haveMadeValue && !isBeforeFirst() && !isFirst()); } } @Override public boolean hasPrevious() { if (isFirst() || isBeforeFirst()) { return false; } else { // if not in the last or afterLast positions and we haven't made any values yet, the result set is empty return !(!haveMadeValue && !isAfterLast() && !isLast()); } } @Override public EntityValue next() { // first try the txcList if we are in it if (txcListIndex >= 0) { if (txcListIndex >= txcListSize) return null; txcListIndex++; if (txcListIndex >= txcListSize) return null; return currentEntityValue(); } // not in txcList, try the DB if (nextResult()) { EntityValueBase evb = currentEntityValueBase(); if (txCache != null) { EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo); // if deleted skip this value if (writeMode == EntityJavaUtil.WriteMode.DELETE) return next(); } return evb; } else { if (txcListSize > 0) { // txcListIndex should be -1, but instead of incrementing set to 0 just to make sure txcListIndex = 0; return currentEntityValue(); } else { return null; } } } @Override public int nextIndex() { return currentIndex() + 1; } @Override public EntityValue previous() { // first try the txcList if we are in it if (txcListIndex >= 0) { txcListIndex--; if (txcListIndex >= 0) return currentEntityValue(); } if (previousResult()) { EntityValueBase evb = (EntityValueBase) currentEntityValue(); if (txCache != null) { EntityJavaUtil.WriteMode writeMode = txCache.checkUpdateValue(evb, findAugmentInfo); // if deleted skip this value if (writeMode == EntityJavaUtil.WriteMode.DELETE) return this.previous(); } return evb; } else { return null; } } @Override public int previousIndex() { return currentIndex() - 1; } @Override public void setFetchSize(int rows) { if (rows > MAX_FETCH_SIZE) rows = MAX_FETCH_SIZE; this.fetchSize = rows; } @Override public EntityList getCompleteList(boolean closeAfter) { try { // move back to before first if we need to if (haveMadeValue && !isBeforeFirst()) beforeFirst(); EntityList list = new EntityListImpl(efi); EntityValue value; while ((value = next()) != null) list.add(value); if (findAugmentInfo != null) { // all created, updated, and deleted values will be handled by the next() method // update the order if we know the order by field list if (orderByComparator != null) list.sort(orderByComparator); } return list; } finally { if (closeAfter) close(); } } @Override public EntityList getPartialList(int offset, int limit, boolean closeAfter) { // TODO: somehow handle txcList after DB list? same issue as absolute() and relative() methods if (txcListSize > 0) throw new EntityException("Cannot get partial list when transaction cache is in place and there are augmenting creates; disable the tx cache before this operation"); try { EntityList list = new EntityListImpl(this.efi); if (limit == 0) return list; // list is 1 based if (offset == 0) offset = 1; // jump to start index, or just get the first result if (!this.absolute(offset)) { // not that many results, get empty list return list; } // get the first as the current one list.add(this.currentEntityValue()); int numberSoFar = 1; EntityValue nextValue; while (limit > numberSoFar && (nextValue = this.next()) != null) { list.add(nextValue); numberSoFar++; } return list; } finally { if (closeAfter) close(); } } @Override public int writeXmlText(Writer writer, String prefix, int dependentLevels) { int recordsWritten = 0; // move back to before first if we need to if (haveMadeValue && !isBeforeFirst()) beforeFirst(); EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlText(writer, prefix, dependentLevels); return recordsWritten; } @Override public int writeXmlTextMaster(Writer writer, String prefix, String masterName) { int recordsWritten = 0; // move back to before first if we need to if (haveMadeValue && !isBeforeFirst()) beforeFirst(); EntityValue value; while ((value = this.next()) != null) recordsWritten += value.writeXmlTextMaster(writer, prefix, masterName); return recordsWritten; } @Override public void remove() { throw new BaseArtifactException("ElasticEntityListIterator.remove() not currently supported"); // TODO: call EECAs // efi.getEntityCache().clearCacheForValue((EntityValueBase) currentEntityValue(), false); // rs.deleteRow(); } @Override public void set(EntityValue e) { throw new BaseArtifactException("ElasticEntityListIterator.set() not currently supported"); // TODO implement this // TODO: call EECAs // TODO: notify cache clear } @Override public void add(EntityValue e) { throw new BaseArtifactException("ElasticEntityListIterator.add() not currently supported"); // TODO implement this } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticEntityValue.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.elastic; import org.moqui.Moqui; import org.moqui.context.ElasticFacade; import org.moqui.entity.EntityException; import org.moqui.entity.EntityFacade; import org.moqui.entity.EntityValue; import org.moqui.impl.entity.*; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import jakarta.xml.bind.DatatypeConverter; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.sql.Connection; import java.sql.Timestamp; import java.util.Calendar; import java.util.Map; public class ElasticEntityValue extends EntityValueBase { protected static final Logger logger = LoggerFactory.getLogger(ElasticEntityValue.class); private ElasticDatasourceFactory edfInternal; /** Default constructor for deserialization ONLY. */ public ElasticEntityValue() { } public ElasticEntityValue(EntityDefinition ed, EntityFacadeImpl efip, ElasticDatasourceFactory edf) { super(ed, efip); this.edfInternal = edf; } @Override public void writeExternal(ObjectOutput out) throws IOException { super.writeExternal(out); } @Override public void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { super.readExternal(objectInput); } public ElasticDatasourceFactory getEdf() { if (edfInternal == null) { // not much option other than static access via Moqui object EntityFacade ef = Moqui.getExecutionContextFactory().getEntity(); edfInternal = (ElasticDatasourceFactory) ef.getDatasourceFactory(ef.getEntityGroupName(resolveEntityName())); } return edfInternal; } @Override public EntityValue cloneValue() { ElasticEntityValue newObj = new ElasticEntityValue(getEntityDefinition(), getEntityFacadeImpl(), edfInternal); newObj.valueMapInternal.putAll(this.valueMapInternal); if (this.dbValueMap != null) newObj.setDbValueMap(this.dbValueMap); // don't set mutable (default to mutable even if original was not) or modified (start out not modified) return newObj; } @Override public EntityValue cloneDbValue(boolean getOld) { ElasticEntityValue newObj = new ElasticEntityValue(getEntityDefinition(), getEntityFacadeImpl(), edfInternal); newObj.valueMapInternal.putAll(this.valueMapInternal); for (FieldInfo fieldInfo : getEntityDefinition().entityInfo.allFieldInfoArray) newObj.putKnownField(fieldInfo, getOld ? getOldDbValue(fieldInfo.name) : getOriginalDbValue(fieldInfo.name)); newObj.setSyncedWithDb(); return newObj; } @Override public void createExtended(FieldInfo[] fieldInfoArray, Connection con) { EntityDefinition ed = getEntityDefinition(); if (ed.isViewEntity) throw new EntityException("View entities are not supported, Elastic/OpenSearch does not support joins"); ElasticDatasourceFactory edf = getEdf(); edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); String combinedId = getPrimaryKeysString(); // logger.warn("create elastic combinedId " + combinedId + " valueMapInternal " + valueMapInternal); elasticClient.index(edf.getIndexName(ed), combinedId, valueMapInternal); setSyncedWithDb(); } @Override public void updateExtended(FieldInfo[] pkFieldArray, FieldInfo[] nonPkFieldArray, Connection con) { EntityDefinition ed = getEntityDefinition(); if (ed.isViewEntity) throw new EntityException("View entities are not supported, Elastic/OpenSearch does not support joins"); ElasticDatasourceFactory edf = getEdf(); edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); String combinedId = getPrimaryKeysString(); // use ElasticClient.update() method, supports partial doc update, see https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html elasticClient.update(edf.getIndexName(ed), combinedId, valueMapInternal); setSyncedWithDb(); } @Override public void deleteExtended(Connection con) { EntityDefinition ed = getEntityDefinition(); if (ed.isViewEntity) throw new EntityException("View entities are not supported, Elastic/OpenSearch does not support joins"); ElasticDatasourceFactory edf = getEdf(); edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); String combinedId = getPrimaryKeysString(); elasticClient.delete(edf.getIndexName(ed), combinedId); } @Override public boolean refreshExtended() { EntityDefinition ed = getEntityDefinition(); if (ed.isViewEntity) throw new EntityException("View entities are not supported, Elastic/OpenSearch does not support joins"); ElasticDatasourceFactory edf = getEdf(); edf.checkCreateDocumentIndex(ed); ElasticFacade.ElasticClient elasticClient = edf.getElasticClient(); String combinedId = getPrimaryKeysString(); Map getResponse = elasticClient.get(edf.getIndexName(ed), combinedId); if (getResponse == null) return false; Map dbValue = (Map) getResponse.get("_source"); if (dbValue == null) return false; FieldInfo[] allFieldArray = ed.entityInfo.allFieldInfoArray; for (int j = 0; j < allFieldArray.length; j++) { FieldInfo fi = allFieldArray[j]; Object fValue = ElasticDatasourceFactory.convertFieldValue(fi, dbValue.get(fi.name)); valueMapInternal.putByIString(fi.name, fValue, fi.index); } setSyncedWithDb(); return true; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/entity/elastic/ElasticSynchronization.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.entity.elastic import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.transaction.Status import jakarta.transaction.Synchronization import jakarta.transaction.Transaction import javax.transaction.xa.XAException /** NOT YET IMPLEMENTED OR USED, may be used for future Elastic Entity transactional behavior (none so far...) */ @CompileStatic class ElasticSynchronization implements Synchronization { protected final static Logger logger = LoggerFactory.getLogger(ElasticSynchronization.class) protected ExecutionContextFactoryImpl ecfi protected ElasticDatasourceFactory edf protected Transaction tx = null ElasticSynchronization(ExecutionContextFactoryImpl ecfi, ElasticDatasourceFactory edf) { this.ecfi = ecfi this.edf = edf } @Override void beforeCompletion() { } @Override void afterCompletion(int status) { /* if (status == Status.STATUS_COMMITTED) { try { // TODO database.commit() } catch (Exception e) { logger.error("Error in OrientDB commit: ${e.toString()}", e) throw new XAException("Error in OrientDB commit: ${e.toString()}") } finally { // TODO database.close() } } else { try { // TODO database.rollback() } catch (Exception e) { logger.error("Error in OrientDB rollback: ${e.toString()}", e) throw new XAException("Error in OrientDB rollback: ${e.toString()}") } finally { // TODO database.close() } } */ } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenDefinition.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.json.JsonOutput import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper import org.moqui.BaseArtifactException import org.moqui.BaseException import org.moqui.context.ArtifactExecutionInfo import org.moqui.context.ExecutionContext import org.moqui.context.ResourceFacade import org.moqui.impl.context.ContextJavaUtil import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.screen.ScreenUrlInfo.UrlInstance import org.moqui.impl.service.ServiceDefinition import org.moqui.resource.ResourceReference import org.moqui.context.WebFacade import org.moqui.entity.EntityFind import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.ContextStack import org.moqui.util.MNode import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.http.HttpServletResponse @CompileStatic class ScreenDefinition { private final static Logger logger = LoggerFactory.getLogger(ScreenDefinition.class) private final static Set scanWidgetNames = new HashSet( ['section', 'section-iterate', 'section-include', 'form-single', 'form-list', 'tree', 'subscreens-panel', 'subscreens-menu']) private final static Set screenStaticWidgetNames = new HashSet( ['subscreens-panel', 'subscreens-menu', 'subscreens-active']) @SuppressWarnings("GrFinalVariableAccess") protected final ScreenFacadeImpl sfi @SuppressWarnings("GrFinalVariableAccess") protected final MNode screenNode @SuppressWarnings("GrFinalVariableAccess") protected final MNode subscreensNode @SuppressWarnings("GrFinalVariableAccess") protected final MNode webSettingsNode @SuppressWarnings("GrFinalVariableAccess") protected final String location @SuppressWarnings("GrFinalVariableAccess") protected final String screenName @SuppressWarnings("GrFinalVariableAccess") final long screenLoadedTime protected boolean standalone = false protected boolean allowExtraPath = false protected Set renderModes = null protected Set serverStatic = null Long sourceLastModified = null protected Map parameterByName = new HashMap<>() protected boolean hasRequired = false protected Map transitionByName = new HashMap<>() protected Map subscreensByName = new HashMap<>() protected ArrayList subscreensItemsSorted = null protected ArrayList subscreensNoSubPath = null protected String defaultSubscreensItem = null protected XmlAction alwaysActions = null protected XmlAction preActions = null protected ScreenSection rootSection = null protected Map sectionByName = new HashMap<>() protected Map formByName = new LinkedHashMap<>() protected Map treeByName = new HashMap<>() protected final Set dependsOnScreenLocations = new HashSet<>() protected boolean hasTabMenu = false protected Map subContentRefByPath = new HashMap() protected Map macroTemplateByRenderMode = null ScreenDefinition(ScreenFacadeImpl sfi, MNode screenNode, String location) { this.sfi = sfi // merge screen-extend (before using anything from screenNode) int locationSepLoc = location.indexOf("://") String locPath = locationSepLoc == -1 ? location : location.substring(locationSepLoc + 3) String locPathAfterScreen = null int locPathScreenLoc = locPath.indexOf("/screen/") if (locPathScreenLoc >= 0) locPathAfterScreen = locPath.substring(locPathScreenLoc + 8) // search components for screen-extend files ArrayList screenExtendNodeList = new ArrayList<>() for (String componentLoc in sfi.ecfi.getComponentBaseLocations().values()) { ResourceReference screenExtendRr = sfi.ecfi.resourceFacade.getLocationReference(componentLoc + "/screen-extend") if (!screenExtendRr.supportsExists()) { logger.warn("For screen-extend skipping component that does not support exists check: ${componentLoc}") continue } // continue to next component if screen-extend directory does not exist (quit early) if (!screenExtendRr.exists) continue // try the after '/screen/' path after the full path so that different screens with the same after-screen path can be distinguished ResourceReference matchingRr = screenExtendRr.findChildFile(locPath) if (!matchingRr.exists && locPathAfterScreen != null) matchingRr = screenExtendRr.findChildFile(locPathAfterScreen) // still found nothing? move along if (!matchingRr.exists) continue logger.info("Found screen-extend at ${matchingRr.location} for screen at ${location}") MNode screenExtendNode = MNode.parse(matchingRr) screenExtendNodeList.add(screenExtendNode) } // merge/etc screen-extend nodes from files Map> extendDescendantsMap = new HashMap<>() for (int seIdx = 0; seIdx < screenExtendNodeList.size(); seIdx++) { MNode screenExtendNode = (MNode) screenExtendNodeList.get(seIdx) // NOTE: form-single, form-list merged below; various others overridden below (section, section-iterate) screenExtendNode.descendants(scanWidgetNames, extendDescendantsMap) // start with attributes and simple override by name/id elements screenNode.attributes.putAll(screenExtendNode.attributes) screenNode.mergeChildrenByKey(screenExtendNode, "parameter", "name", null) screenNode.mergeChildrenByKey(screenExtendNode, "transition", "name", null) screenNode.mergeChildrenByKey(screenExtendNode, "transition-include", "name", null) MNode overrideSubscreensNode = screenExtendNode.first("subscreens") if (overrideSubscreensNode != null) { MNode baseSubscreensNode = screenNode.first("subscreens") if (baseSubscreensNode == null) { screenNode.append(overrideSubscreensNode.deepCopy(screenNode)) } else { baseSubscreensNode.mergeNodeWithChildKey(overrideSubscreensNode, "subscreens-item", "name", null) } } ArrayList actionsExtendNodeList = screenExtendNode.children("actions-extend") for (int i = 0; i < actionsExtendNodeList.size(); i++) { MNode actionsExtendNode = (MNode) actionsExtendNodeList.get(i) String typeName = actionsExtendNode.attribute("type") ?: "actions" MNode curActionsNode = screenNode.first(typeName) if (curActionsNode == null) curActionsNode = screenNode.append(typeName, null) String when = actionsExtendNode.attribute("when") if ("replace".equals(when)) { curActionsNode.removeAll() curActionsNode.appendAll(actionsExtendNode.children, true) } else if ("before".equals(when)) { curActionsNode.appendAll(actionsExtendNode.children, 0, true) } else { // default to "after" curActionsNode.appendAll(actionsExtendNode.children, true) } } ArrayList widgetsExtendNodeList = screenExtendNode.children("widgets-extend") for (int weIdx = 0; weIdx < widgetsExtendNodeList.size(); weIdx++) { // need any explicit support? form-single, form-list, section, section-iterate, container (id), container-box (id), container-dialog (id), dynamic-dialog (id) // for now just look for any matching name or id attribute and go for it MNode widgetsExtendNode = (MNode) widgetsExtendNodeList.get(weIdx) String extendName = widgetsExtendNode.attribute("name") if (extendName == null || extendName.isEmpty()) continue ArrayList matchingNodes = screenNode.breadthFirst({ MNode it -> extendName.equals(it.attribute("name")) || extendName.equals(it.attribute("id")) }) // logger.warn("widgets-extend name=${extendName} matchingNodes ${matchingNodes}") for (int mnIdx = 0; mnIdx < matchingNodes.size(); mnIdx++) { MNode matchingNode = (MNode) matchingNodes.get(mnIdx) MNode matchParent = matchingNode.getParent() if (matchParent == null) { logger.warn("In screen-extend no parent found for element ${matchingNode.name} name=${matchingNode.attribute('name')} id=${matchingNode.attribute('id')} in target screen at ${location}") continue } int childIdx = matchParent.firstIndex(matchingNode) if (childIdx == -1) { logger.warn("In screen-extend could not find index for element ${matchingNode.name} name=${matchingNode.attribute('name')} id=${matchingNode.attribute('id')} in target screen at ${location}") continue } // if where=after (default before) then add 1 to childIdx if ("after".equals(widgetsExtendNode.attribute("where"))) childIdx++ // ready to go, append cloned widgets-extend child nodes matchParent.appendAll(widgetsExtendNode.children, childIdx, true) } } } // if (screenExtendNodeList.size() > 0) logger.warn("after extend of screen at ${location}:\n${screenNode.toString()}") // init screen def fields this.screenNode = screenNode subscreensNode = screenNode.first("subscreens") webSettingsNode = screenNode.first("web-settings") this.location = location ExecutionContextFactoryImpl ecfi = sfi.ecfi long startTime = System.currentTimeMillis() screenLoadedTime = startTime String filename = location.contains("/") ? location.substring(location.lastIndexOf("/")+1) : location screenName = filename.contains(".") ? filename.substring(0, filename.indexOf(".")) : filename standalone = "true".equals(screenNode.attribute("standalone")) allowExtraPath = "true".equals(screenNode.attribute("allow-extra-path")) String renderModesStr = screenNode.attribute("render-modes") ?: "all" renderModes = new HashSet(Arrays.asList(renderModesStr.split(",")).collect({ it.trim() })) String serverStaticStr = screenNode.attribute("server-static") if (serverStaticStr) serverStatic = new HashSet(Arrays.asList(serverStaticStr.split(",")).collect({ it.trim() })) // parameter for (MNode parameterNode in screenNode.children("parameter")) { ParameterItem parmItem = new ParameterItem(parameterNode, location, ecfi) parameterByName.put(parameterNode.attribute("name"), parmItem) if (parmItem.required) hasRequired = true } // prep always-actions if (screenNode.hasChild("always-actions")) alwaysActions = new XmlAction(ecfi, screenNode.first("always-actions"), location + ".always_actions") // transition for (MNode transitionNode in screenNode.children("transition")) { TransitionItem ti = new TransitionItem(transitionNode, this) transitionByName.put(ti.method == "any" ? ti.name : ti.name + "#" + ti.method, ti) } // transition-include for (MNode transitionInclNode in screenNode.children("transition-include")) { ScreenDefinition includeScreen = ecfi.screenFacade.getScreenDefinition(transitionInclNode.attribute("location")) if (includeScreen != null) dependsOnScreenLocations.add(includeScreen.location) MNode transitionNode = includeScreen?.getTransitionItem(transitionInclNode.attribute("name"), transitionInclNode.attribute("method"))?.transitionNode if (transitionNode == null) throw new BaseArtifactException("For transition-include could not find transition ${transitionInclNode.attribute("name")} with method ${transitionInclNode.attribute("method")} in screen at ${transitionInclNode.attribute("location")}") TransitionItem ti = new TransitionItem(transitionNode, this) transitionByName.put(ti.method == "any" ? ti.name : ti.name + "#" + ti.method, ti) } // default/automatic transitions if (!transitionByName.containsKey("actions")) transitionByName.put("actions", new ActionsTransitionItem(this)) if (!transitionByName.containsKey("formSelectColumns")) transitionByName.put("formSelectColumns", new FormSelectColumnsTransitionItem(this)) if (!transitionByName.containsKey("formSaveFind")) transitionByName.put("formSaveFind", new FormSavedFindsTransitionItem(this)) if (!transitionByName.containsKey("screenDoc")) transitionByName.put("screenDoc", new ScreenDocumentTransitionItem(this)) // subscreens defaultSubscreensItem = subscreensNode?.attribute("default-item") populateSubscreens() for (SubscreensItem si in getSubscreensItemsSorted()) if (si.noSubPath) { if (subscreensNoSubPath == null) subscreensNoSubPath = new ArrayList<>() subscreensNoSubPath.add(si) } // macro-template - go through entire list and set all found, basically we want the last one if there are more than one List macroTemplateList = screenNode.children("macro-template") if (macroTemplateList.size() > 0) { macroTemplateByRenderMode = new HashMap<>() for (MNode mt in macroTemplateList) macroTemplateByRenderMode.put(mt.attribute('type'), mt.attribute('location')) } // prep pre-actions if (screenNode.hasChild("pre-actions")) preActions = new XmlAction(ecfi, screenNode.first("pre-actions"), location + ".pre_actions") // get the root section rootSection = new ScreenSection(ecfi, screenNode, location + ".screen") if (rootSection != null && rootSection.widgets != null) { Map> descMap = rootSection.widgets.widgetsNode.descendants(scanWidgetNames) // get all of the other sections by name for (MNode sectionNode in descMap.get('section')) { String sectionName = sectionNode.attribute("name") // get last matching node, is replace/override ArrayList extendNodes = extendDescendantsMap.get("section") Integer replaceIndex = extendNodes?.findLastIndexOf({ it.attribute("name") == sectionName }) MNode useNode = (replaceIndex != null && replaceIndex != -1) ? extendNodes.get(replaceIndex) : sectionNode sectionByName.put(sectionName, new ScreenSection(ecfi, useNode, "${location}.section\$${sectionName}")) } for (MNode sectionNode in descMap.get('section-iterate')) { String sectionName = sectionNode.attribute("name") ArrayList extendNodes = extendDescendantsMap.get("section-iterate") Integer replaceIndex = extendNodes?.findLastIndexOf({ it.attribute("name") == sectionName }) MNode useNode = (replaceIndex != null && replaceIndex != -1) ? extendNodes.get(replaceIndex) : sectionNode sectionByName.put(sectionName, new ScreenSection(ecfi, useNode, "${location}.section_iterate\$${sectionName}")) } for (MNode sectionNode in descMap.get('section-include')) { String sectionLocation = sectionNode.attribute("location") String sectionName = sectionNode.attribute("name") boolean isDynamic = (sectionLocation != null && sectionLocation.contains('${')) || (sectionName != null && sectionName.contains('${')) // if the section-include is dynamic then don't pull it now, do at runtime based on dynamic name and location if (!isDynamic) pullSectionInclude(sectionNode) } // get all forms by name for (MNode formNode in descMap.get("form-single")) { String formName = formNode.attribute("name") List extendList = extendDescendantsMap.get("form-single") if (extendList != null) extendList = extendList.findAll({ it.attribute("name") == formName }) ScreenForm newForm = new ScreenForm(ecfi, this, formNode, extendList, "${location}.form_single\$${formName}") if (newForm.extendsScreenLocation != null) dependsOnScreenLocations.add(newForm.extendsScreenLocation) formByName.put(formName, newForm) } for (MNode formNode in descMap.get('form-list')) { String formName = formNode.attribute("name") List extendList = extendDescendantsMap.get("form-list") if (extendList != null) extendList = extendList.findAll({it.attribute("name") == formName}) ScreenForm newForm = new ScreenForm(ecfi, this, formNode, extendList, "${location}.form_list\$${formName}") if (newForm.extendsScreenLocation != null) dependsOnScreenLocations.add(newForm.extendsScreenLocation) formByName.put(formName, newForm) } // get all trees by name for (MNode treeNode in descMap.get('tree')) treeByName.put(treeNode.attribute("name"), new ScreenTree(ecfi, this, treeNode, "${location}.tree\$${treeNode.attribute("name")}")) // see if any subscreens-panel or subscreens-menu elements are type=tab (or empty type, defaults to tab) for (MNode menuNode in descMap.get("subscreens-panel")) { String type = menuNode.attribute("type") if (type == null || type.isEmpty() || "tab".equals(type)) { hasTabMenu = true; break } } if (!hasTabMenu) for (MNode menuNode in descMap.get("subscreens-menu")) { String type = menuNode.attribute("type") if (type == null || type.isEmpty() || "tab".equals(type)) { hasTabMenu = true; break } } if (serverStatic == null) { // if there are no elements except subscreens-panel, subscreens-active, and subscreens-menu then set serverStatic to all boolean otherElements = false MNode widgetsNode = rootSection.widgets.widgetsNode if (!"widgets".equals(widgetsNode.getName())) widgetsNode = widgetsNode.first("widgets") for (MNode child in widgetsNode.getChildren()) { if (!screenStaticWidgetNames.contains(child.getName())) {otherElements = true; break } } if (!otherElements) serverStatic = new HashSet<>(['all']) } } if (logger.isTraceEnabled()) logger.trace("Loaded screen at [${location}] in [${(System.currentTimeMillis()-startTime)/1000}] seconds") } void pullSectionInclude(MNode sectionIncludeNode) { String location = sectionIncludeNode.attribute("location") String sectionName = sectionIncludeNode.attribute("name") boolean isDynamic = (location != null && location.contains('${')) || (sectionName != null && sectionName.contains('${')) String cacheName = null if (isDynamic) { location = sfi.ecfi.resourceFacade.expandNoL10n(location, null) sectionName = sfi.ecfi.resourceFacade.expandNoL10n(sectionName, null) // get fullName for sectionByName cache before checking location for # so that matches what ScreenRenderImpl.renderSectionInclude() does cacheName = location + "#" + sectionName } if (location.contains('#')) { sectionName = location.substring(location.indexOf('#') + 1) location = location.substring(0, location.indexOf('#')) } if (!isDynamic) cacheName = sectionName ScreenDefinition includeScreen = sfi.getEcfi().screenFacade.getScreenDefinition(location) ScreenSection includeSection = includeScreen?.getSection(sectionName) if (includeSection == null) throw new BaseArtifactException("Could not find section ${sectionName} to include at location ${location}") sectionByName.put(cacheName, includeSection) dependsOnScreenLocations.add(location) Map> descMap = includeSection.sectionNode.descendants( new HashSet(['section', 'section-iterate', 'section-include', 'form-single', 'form-list', 'tree'])) // see if the included section contains any SECTIONS, need to reference those here too! for (MNode inclRefNode in descMap.get('section')) sectionByName.put(inclRefNode.attribute("name"), includeScreen.getSection(inclRefNode.attribute("name"))) for (MNode inclRefNode in descMap.get('section-iterate')) sectionByName.put(inclRefNode.attribute("name"), includeScreen.getSection(inclRefNode.attribute("name"))) // recurse for section-include for (MNode inclRefNode in descMap.get('section-include')) pullSectionInclude(inclRefNode) // see if the included section contains any FORMS or TREES, need to reference those here too! for (MNode formNode in descMap.get('form-single')) { ScreenForm inclForm = includeScreen.getForm(formNode.attribute("name")) if (inclForm.extendsScreenLocation != null) dependsOnScreenLocations.add(inclForm.extendsScreenLocation) formByName.put(formNode.attribute("name"), inclForm) } for (MNode formNode in descMap.get('form-list')) { ScreenForm inclForm = includeScreen.getForm(formNode.attribute("name")) if (inclForm.extendsScreenLocation != null) dependsOnScreenLocations.add(inclForm.extendsScreenLocation) formByName.put(formNode.attribute("name"), inclForm) } for (MNode treeNode in descMap.get('tree')) treeByName.put(treeNode.attribute("name"), includeScreen.getTree(treeNode.attribute("name"))) } void populateSubscreens() { // start with file/directory structure String cleanLocationBase = location.substring(0, location.lastIndexOf(".")) ResourceReference locationRef = sfi.ecfi.resourceFacade.getLocationReference(location) if (logger.traceEnabled) logger.trace("Finding subscreens for screen at [${locationRef}]") if (locationRef.supportsAll()) { String subscreensDirStr = locationRef.location subscreensDirStr = subscreensDirStr.substring(0, subscreensDirStr.lastIndexOf(".")) ResourceReference subscreensDirRef = sfi.ecfi.resourceFacade.getLocationReference(subscreensDirStr) if (subscreensDirRef.exists && subscreensDirRef.isDirectory()) { if (logger.traceEnabled) logger.trace("Looking for subscreens in directory [${subscreensDirRef}]") for (ResourceReference subscreenRef in subscreensDirRef.directoryEntries) { if (!subscreenRef.isFile() || !subscreenRef.location.endsWith(".xml")) continue MNode subscreenRoot = MNode.parse(subscreenRef) if (subscreenRoot.name == "screen") { String ssName = subscreenRef.getFileName() ssName = ssName.substring(0, ssName.lastIndexOf(".")) String cleanLocation = cleanLocationBase + "/" + subscreenRef.getFileName() SubscreensItem si = new SubscreensItem(ssName, cleanLocation, subscreenRoot, this) subscreensByName.put(si.name, si) if (logger.traceEnabled) logger.trace("Added file subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]") } } } } else { logger.info("Not getting subscreens by file/directory structure for screen [${location}] because it is not a location that supports directories") } // override dir structure with subscreens.subscreens-item elements if (screenNode.hasChild("subscreens")) for (MNode subscreensItem in screenNode.first("subscreens").children("subscreens-item")) { SubscreensItem si = new SubscreensItem(subscreensItem, this) subscreensByName.put(si.name, si) if (logger.traceEnabled) logger.trace("Added Screen XML defined subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]") } // override dir structure and screen.subscreens.subscreens-item elements with Moqui Conf XML screen-facade.screen.subscreens-item elements MNode screenFacadeNode = sfi.ecfi.confXmlRoot.first("screen-facade") MNode confScreenNode = screenFacadeNode.first("screen", "location", location) if (confScreenNode != null) { for (MNode subscreensItem in confScreenNode.children("subscreens-item")) { SubscreensItem si = new SubscreensItem(subscreensItem, this) subscreensByName.put(si.name, si) if (logger.traceEnabled) logger.trace("Added Moqui Conf XML defined subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]") } if (confScreenNode.attribute("default-subscreen")) defaultSubscreensItem = confScreenNode.attribute("default-subscreen") } // override dir structure and subscreens-item elements with moqui.screen.SubscreensItem entity EntityFind subscreensItemFind = sfi.ecfi.entityFacade.find("moqui.screen.SubscreensItem") .condition([screenLocation:location] as Map) // NOTE: this filter should NOT be done here, causes subscreen items to be filtered by first user that renders the screen, not by current user! // subscreensItemFind.condition("userGroupId", EntityCondition.IN, sfi.ecfi.executionContext.user.userGroupIdSet) EntityList subscreensItemList = subscreensItemFind.useCache(true).disableAuthz().list() for (EntityValue subscreensItem in subscreensItemList) { SubscreensItem si = new SubscreensItem(subscreensItem, this) subscreensByName.put(si.name, si) if ("Y".equals(subscreensItem.makeDefault)) defaultSubscreensItem = si.name if (logger.traceEnabled) logger.trace("Added database subscreen [${si.name}] at [${si.location}] to screen [${locationRef}]") } } MNode getScreenNode() { return screenNode } MNode getSubscreensNode() { return subscreensNode } MNode getWebSettingsNode() { return webSettingsNode } String getLocation() { return location } String getDefaultSubscreensItem() { return defaultSubscreensItem } ArrayList getSubscreensNoSubPath() { return subscreensNoSubPath } String getScreenName() { return screenName } boolean isStandalone() { return standalone } boolean isServerStatic(String renderMode) { return serverStatic != null && (serverStatic.contains('all') || serverStatic.contains(renderMode)) } String getDefaultMenuName() { return getPrettyMenuName(screenNode.attribute("default-menu-title"), location, sfi.ecfi) } static String getPrettyMenuName(String menuName, String location, ExecutionContextFactoryImpl ecfi) { if (menuName == null || menuName.isEmpty()) { String filename = location.substring(location.lastIndexOf("/")+1, location.length()-4) StringBuilder prettyName = new StringBuilder() for (String part in filename.split("(?=[A-Z])")) { if (prettyName) prettyName.append(" ") prettyName.append(part) } char firstChar = prettyName.charAt(0) if (Character.isLowerCase(firstChar)) prettyName.setCharAt(0, Character.toUpperCase(firstChar)) menuName = prettyName.toString() } return ecfi.getEci().l10nFacade.localize(menuName) } /** Get macro template location specific to screen from marco-template elements */ String getMacroTemplateLocation(String renderMode) { if (macroTemplateByRenderMode == null) return null return (String) macroTemplateByRenderMode.get(renderMode) } Map getParameterMap() { return parameterByName } boolean hasRequiredParameters() { return hasRequired } boolean hasTabMenu() { return hasTabMenu } XmlAction getPreActions() { return preActions } XmlAction getAlwaysActions() { return alwaysActions } boolean hasTransition(String name) { for (TransitionItem curTi in transitionByName.values()) if (curTi.name == name) return true return false } TransitionItem getTransitionItem(String name, String method) { method = method != null ? method.toLowerCase() : "" TransitionItem ti = (TransitionItem) transitionByName.get(name.concat("#").concat(method)) // if no ti, try by name only which will catch transitions with "any" or empty method if (ti == null) ti = (TransitionItem) transitionByName.get(name) // still none? try each one to see if it matches as a regular expression (first one to match wins) if (ti == null) for (TransitionItem curTi in transitionByName.values()) { if (method != null && !method.isEmpty() && ("any".equals(curTi.method) || method.equals(curTi.method))) { if (name.equals(curTi.name)) { ti = curTi; break } if (name.matches(curTi.name)) { ti = curTi; break } } // logger.info("In getTransitionItem() transition with name [${curTi.name}] method [${curTi.method}] did not match name [${name}] method [${method}]") } return ti } Collection getAllTransitions() { return transitionByName.values() } SubscreensItem getSubscreensItem(String name) { return (SubscreensItem) subscreensByName.get(name) } ArrayList findSubscreenPath(ArrayList remainingPathNameList) { if (!remainingPathNameList) return null String curName = remainingPathNameList.get(0) SubscreensItem curSsi = getSubscreensItem(curName) if (curSsi != null) { if (remainingPathNameList.size() > 1) { ArrayList subPathNameList = new ArrayList<>(remainingPathNameList) subPathNameList.remove(0) try { ScreenDefinition subSd = sfi.getScreenDefinition(curSsi.getLocation()) ArrayList subPath = subSd.findSubscreenPath(subPathNameList) if (!subPath) return null subPath.add(0, curName) return subPath } catch (Exception e) { logger.error("Error finding subscreens under screen at ${curSsi.getLocation()}", BaseException.filterStackTrace(e)) return null } } else { return remainingPathNameList } } // if this is a transition right under this screen use it before searching subscreens if (hasTransition(curName)) return remainingPathNameList // breadth first by looking at subscreens of each subscreen on a first pass for (Map.Entry entry in subscreensByName.entrySet()) { ScreenDefinition subSd = null try { subSd = sfi.getScreenDefinition(entry.getValue().getLocation()) } catch (Exception e) { logger.error("Error finding subscreens under screen ${entry.key} at ${entry.getValue().getLocation()}", BaseException.filterStackTrace(e)) } if (subSd == null) { if (logger.isTraceEnabled()) logger.trace("Screen ${entry.getKey()} at ${entry.getValue().getLocation()} not found, subscreen of [${this.getLocation()}]") continue } SubscreensItem subSsi = subSd.getSubscreensItem(curName) if (subSsi != null) { if (remainingPathNameList.size() > 1) { // if there are still more path elements, recurse to find them ArrayList subPathNameList = new ArrayList<>(remainingPathNameList) subPathNameList.remove(0) ScreenDefinition subSubSd = sfi.getScreenDefinition(subSsi.getLocation()) ArrayList subPath = subSubSd.findSubscreenPath(subPathNameList) // found a partial match, not the full thing, no match so give up if (!subPath) return null // we've found it two deep, add both names, sub name first subPath.add(0, curName) subPath.add(0, entry.getKey()) return subPath } else { return new ArrayList([entry.getKey(), curName]) } } } // not immediate child or grandchild subscreen, start recursion for (Map.Entry entry in subscreensByName.entrySet()) { ScreenDefinition subSd = null try { subSd = sfi.getScreenDefinition(entry.getValue().getLocation()) } catch (Exception e) { logger.error("Error finding subscreens under screen ${entry.key} at ${entry.getValue().getLocation()}", BaseException.filterStackTrace(e)) } if (subSd == null) { if (logger.isTraceEnabled()) logger.trace("Screen ${entry.getKey()} at ${entry.getValue().getLocation()} not found, subscreen of [${this.getLocation()}]") continue } List subPath = subSd.findSubscreenPath(remainingPathNameList) if (subPath) { subPath.add(0, entry.getKey()) return subPath } } // is this a resource (file) under the screen? ResourceReference existingFileRef = getSubContentRef(remainingPathNameList) if (existingFileRef && existingFileRef.supportsExists() && existingFileRef.exists) { return remainingPathNameList } /* Used mainly for transition responses where the final path element is a screen, transition, or resource with no extra path elements; allowing extra path elements causes problems only solvable by first searching without allowing extra path elements, then searching the full tree for all possible paths that include extra elements and choosing the maximal match (highest number of original sparse path elements matching actual screens) if (allowExtraPath) { return remainingPathNameList } */ // nothing found, return null by default return null } List nestedNoReqParmLocations(String currentPath, Set screensToSkip) { if (!screensToSkip) screensToSkip = new HashSet() List locList = new ArrayList<>() List ssiList = getSubscreensItemsSorted() for (SubscreensItem ssi in ssiList) { if (screensToSkip.contains(ssi.name)) continue try { ScreenDefinition subSd = sfi.getScreenDefinition(ssi.location) if (!subSd.hasRequiredParameters()) { String subPath = (currentPath ? currentPath + "/" : '') + ssi.name // don't add current if it a has a default subscreen item if (!subSd.getDefaultSubscreensItem()) locList.add(subPath) locList.addAll(subSd.nestedNoReqParmLocations(subPath, screensToSkip)) } } catch (Exception e) { logger.error("Error finding no parameter screens under ${this.location} for subscreen location ${ssi.location}", e) } } return locList } ArrayList getSubscreensItemsSorted() { if (subscreensItemsSorted != null) return subscreensItemsSorted ArrayList newList = new ArrayList(subscreensByName.size()) if (subscreensByName.size() == 0) return newList newList.addAll(subscreensByName.values()) Collections.sort(newList, new SubscreensItemComparator()) return subscreensItemsSorted = newList } ArrayList getMenuSubscreensItems() { ArrayList allItems = getSubscreensItemsSorted() int allItemSize = allItems.size() ArrayList filteredList = new ArrayList(allItemSize) for (int i = 0; i < allItemSize; i++) { SubscreensItem si = (SubscreensItem) allItems.get(i) // check the menu include flag if (!si.menuInclude) continue // valid in current context? (user group, etc) if (!si.isValidInCurrentContext()) continue // made it through the checks? add it in... filteredList.add(si) } return filteredList } ScreenSection getRootSection() { return rootSection } void render(ScreenRenderImpl sri, boolean isTargetScreen) { // NOTE: don't require authz if the screen doesn't require auth String requireAuthentication = screenNode.attribute("require-authentication") ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(location, ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, sri.outputContentType) if ("false".equals(screenNode.attribute("track-artifact-hit"))) aei.setTrackArtifactHit(false) sri.ec.artifactExecutionFacade.pushInternal(aei, isTargetScreen ? (requireAuthentication == null || requireAuthentication.length() == 0 || "true".equals(requireAuthentication)) : false, true) boolean loggedInAnonymous = false if ("anonymous-all".equals(requireAuthentication)) { sri.ec.artifactExecutionFacade.setAnonymousAuthorizedAll() loggedInAnonymous = sri.ec.userFacade.loginAnonymousIfNoUser() } else if ("anonymous-view".equals(requireAuthentication)) { sri.ec.artifactExecutionFacade.setAnonymousAuthorizedView() loggedInAnonymous = sri.ec.userFacade.loginAnonymousIfNoUser() } // logger.info("Rendering screen ${location}, screenNode: \n${screenNode}") try { rootSection.render(sri) } finally { sri.ec.artifactExecutionFacade.pop(aei) if (loggedInAnonymous) sri.ec.userFacade.logoutAnonymousOnly() } } ScreenSection getSection(String sectionName) { ScreenSection ss = sectionByName.get(sectionName) if (ss == null) throw new BaseArtifactException("Could not find section ${sectionName} in screen ${getLocation()}") return ss } ScreenForm getForm(String formName) { ScreenForm sf = formByName.get(formName) if (sf == null) throw new BaseArtifactException("Could not find form ${formName} in screen ${getLocation()}") return sf } ArrayList getAllForms() { return new ArrayList<>(formByName.values()) } ScreenTree getTree(String treeName) { ScreenTree st = treeByName.get(treeName) if (st == null) throw new BaseArtifactException("Could not find tree ${treeName} in screen ${getLocation()}") return st } ResourceReference getSubContentRef(List pathNameList) { StringBuilder pathNameBldr = new StringBuilder() // add the path elements that remain for (String rp in pathNameList) pathNameBldr.append("/").append(rp) String pathName = pathNameBldr.toString() ResourceReference contentRef = subContentRefByPath.get(pathName) if (contentRef != null) return contentRef ResourceReference lastScreenRef = sfi.ecfi.resourceFacade.getLocationReference(location) if (lastScreenRef.supportsAll()) { // NOTE: this caches internally so consider getting rid of subContentRefByPath contentRef = lastScreenRef.findChildFile(pathName) } else { logger.info("Not looking for sub-content [${pathName}] under screen [${location}] because screen location does not support exists, isFile, etc") } if (contentRef != null) subContentRefByPath.put(pathName, contentRef) return contentRef } List> getScreenDocumentInfoList() { String localeString = sfi.ecfi.getEci().userFacade.getLocale().toString() int localeUnderscoreIndex = localeString.indexOf('_') String langString = null // look for locale match, lang only match, or null if (localeUnderscoreIndex > 0) langString = localeString.substring(0, localeUnderscoreIndex) // do very simple cached query for all, then filter in iterator by locale EntityList list = sfi.ecfi.entityFacade.find("moqui.screen.ScreenDocument").condition("screenLocation", location) .orderBy("docIndex").useCache(true).disableAuthz().list() int listSize = list.size() List> outList = new ArrayList<>(listSize) for (int i = 0; i < listSize; i++) { EntityValue screenDoc = (EntityValue) list.get(i) String docLocale = screenDoc.getNoCheckSimple("locale") if (docLocale != null && (!localeString.equals(docLocale) || (langString != null && !langString.equals(docLocale)))) continue String title = screenDoc.getNoCheckSimple("docTitle") if (title == null) { String loc = screenDoc.getNoCheckSimple("docLocation") int fnStart = loc.lastIndexOf("/") + 1 if (fnStart == -1) fnStart = 0 int fnEnd = loc.indexOf(".", fnStart) if (fnEnd == -1) fnEnd = loc.length() title = loc.substring(fnStart, fnEnd) } outList.add([title:title, index:(Long) screenDoc.getNoCheckSimple("docIndex")] as Map) } return outList } @Override String toString() { return location } @CompileStatic static class ParameterItem { protected String name protected Class fromFieldGroovy = null protected String valueString = null protected Class valueGroovy = null protected boolean required = false ParameterItem(MNode parameterNode, String location, ExecutionContextFactoryImpl ecfi) { this.name = parameterNode.attribute("name") if (parameterNode.attribute("required") == "true") required = true if (parameterNode.attribute("from")) fromFieldGroovy = ecfi.getGroovyClassLoader().parseClass( parameterNode.attribute("from"), StringUtilities.cleanStringForJavaName("${location}.parameter_${name}.from_field")) valueString = parameterNode.attribute("value") if (valueString != null && valueString.length() == 0) valueString = null if (valueString != null && valueString.contains('${')) { valueGroovy = ecfi.getGroovyClassLoader().parseClass(('"""' + parameterNode.attribute("value") + '"""'), StringUtilities.cleanStringForJavaName("${location}.parameter_${name}.value")) } } String getName() { return name } Object getValue(ExecutionContext ec) { Object value = null if (fromFieldGroovy != null) { value = InvokerHelper.createScript(fromFieldGroovy, ec.contextBinding).run() } if (value == null) { if (valueGroovy != null) { value = InvokerHelper.createScript(valueGroovy, ec.contextBinding).run() } else { value = valueString } } if (value == null) value = ec.context.getByString(name) if (value == null && ec.web != null) value = ec.web.parameters.get(name) return value } } @CompileStatic static class TransitionItem { protected ScreenDefinition parentScreen protected MNode transitionNode protected String name protected String method protected String location protected XmlAction condition = null protected XmlAction actions = null protected XmlAction serviceActions = null protected String singleServiceName = null protected Map parameterByName = new HashMap<>() protected List pathParameterList = null protected List conditionalResponseList = new ArrayList() protected ResponseItem defaultResponse = null protected ResponseItem errorResponse = null protected boolean beginTransaction = true protected boolean readOnly = false protected boolean requireSessionToken = true protected TransitionItem(ScreenDefinition parentScreen) { this.parentScreen = parentScreen } TransitionItem(MNode transitionNode, ScreenDefinition parentScreen) { this.parentScreen = parentScreen this.transitionNode = transitionNode name = transitionNode.attribute("name") method = transitionNode.attribute("method") ?: "any" location = "${parentScreen.location}.transition\$${StringUtilities.cleanStringForJavaName(name)}" beginTransaction = transitionNode.attribute("begin-transaction") != "false" requireSessionToken = transitionNode.attribute("require-session-token") != "false" ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi // parameter for (MNode parameterNode in transitionNode.children("parameter")) parameterByName.put(parameterNode.attribute("name"), new ParameterItem(parameterNode, location, ecfi)) // path-parameter if (transitionNode.hasChild("path-parameter")) { pathParameterList = new ArrayList() for (MNode pathParameterNode in transitionNode.children("path-parameter")) pathParameterList.add(pathParameterNode.attribute("name")) } // condition if (transitionNode.first("condition")?.first() != null) { // the script is effectively the first child of the condition element condition = new XmlAction(parentScreen.sfi.ecfi, transitionNode.first("condition").first(), location + ".condition") } // allow both call-service and actions if (transitionNode.hasChild("actions")) { actions = new XmlAction(parentScreen.sfi.ecfi, transitionNode.first("actions"), location + ".actions") } if (transitionNode.hasChild("service-call")) { MNode callServiceNode = transitionNode.first("service-call") if (!callServiceNode.attribute("in-map")) callServiceNode.attributes.put("in-map", "true") if (!callServiceNode.attribute("out-map")) callServiceNode.attributes.put("out-map", "context") if (!callServiceNode.attribute("multi") && !"true".equals(callServiceNode.attribute("async"))) callServiceNode.attributes.put("multi", "parameter") serviceActions = new XmlAction(parentScreen.sfi.ecfi, callServiceNode, location + ".service_call") singleServiceName = callServiceNode.attribute("name") } readOnly = (actions == null && serviceActions == null) || transitionNode.attribute("read-only") == "true" // conditional-response* for (MNode condResponseNode in transitionNode.children("conditional-response")) conditionalResponseList.add(new ResponseItem(condResponseNode, this, parentScreen)) // default-response defaultResponse = new ResponseItem(transitionNode.first("default-response"), this, parentScreen) // error-response if (transitionNode.hasChild("error-response")) errorResponse = new ResponseItem(transitionNode.first("error-response"), this, parentScreen) } String getName() { return name } String getMethod() { return method } String getSingleServiceName() { return singleServiceName } List getPathParameterList() { return pathParameterList } Map getParameterMap() { return parameterByName } boolean hasActionsOrSingleService() { return actions != null || serviceActions != null} boolean getBeginTransaction() { return beginTransaction } boolean isReadOnly() { return readOnly } boolean getRequireSessionToken() { return requireSessionToken } boolean checkCondition(ExecutionContextImpl ec) { return condition ? condition.checkCondition(ec) : true } void setAllParameters(List extraPathNameList, ExecutionContextImpl ec) { // get the path parameters if (extraPathNameList && getPathParameterList()) { List pathParameterList = getPathParameterList() int i = 0 for (String extraPathName in extraPathNameList) { if (pathParameterList.size() > i) { // logger.warn("extraPathName ${extraPathName} i ${i} name ${pathParameterList.get(i)}") if (ec.webImpl != null) ec.webImpl.addDeclaredPathParameter(pathParameterList.get(i), extraPathName) ec.getContext().put(pathParameterList.get(i), extraPathName) i++ } else { break } } } // put parameters in the context if (ec.getWeb() != null) { // screen parameters for (ParameterItem pi in parentScreen.getParameterMap().values()) { Object value = pi.getValue(ec) if (value != null) ec.contextStack.put(pi.getName(), value) } // transition parameters for (ParameterItem pi in parameterByName.values()) { Object value = pi.getValue(ec) if (value != null) ec.contextStack.put(pi.getName(), value) } } } ResponseItem run(ScreenRenderImpl sri) { ExecutionContextImpl ec = sri.ec // NOTE: if parent screen of transition does not require auth, don't require authz // NOTE: use the View authz action to leave it open, ie require minimal authz; restrictions are often more // in the services/etc if/when needed, or specific transitions can have authz settings String requireAuthentication = (String) parentScreen.screenNode.attribute('require-authentication') ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl("${parentScreen.location}/${name}", ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, ArtifactExecutionInfo.AUTHZA_VIEW, sri.outputContentType) ec.artifactExecutionFacade.pushInternal(aei, (!requireAuthentication || "true".equals(requireAuthentication)), true) boolean loggedInAnonymous = false if (requireAuthentication == "anonymous-all") { ec.artifactExecutionFacade.setAnonymousAuthorizedAll() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } else if (requireAuthentication == "anonymous-view") { ec.artifactExecutionFacade.setAnonymousAuthorizedView() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } try { ScreenUrlInfo screenUrlInfo = sri.getScreenUrlInfo() ScreenUrlInfo.UrlInstance screenUrlInstance = sri.getScreenUrlInstance() setAllParameters(screenUrlInfo.getExtraPathNameList(), ec) // for alias transitions rendered in-request put the parameters in the context if (screenUrlInstance.getTransitionAliasParameters()) ec.contextStack.putAll(screenUrlInstance.getTransitionAliasParameters()) if (!checkCondition(ec)) { sri.ec.message.addError(ec.resource.expand('Condition failed for transition [${location}], not running actions or redirecting','',[location:location])) if (errorResponse) return errorResponse return defaultResponse } // don't push a map on the context, let the transition actions set things that will remain: sri.ec.context.push() ec.contextStack.put("sri", sri) // logger.warn("Running transition ${name} context: ${ec.contextStack.toString()}") if (serviceActions != null) { // if this is an implicit entity auto service filter input for HTML like done in defined service calls by default; // to get around define a service with a parameter that allows safe or any HTML instead of using implicit entity auto directly if (ec.serviceFacade.isEntityAutoPattern(singleServiceName)) { String entityName = ServiceDefinition.getNounFromName(singleServiceName) EntityDefinition ed = ec.entityFacade.getEntityDefinition(entityName) if (ed != null) { ArrayList fieldNameList = ed.getAllFieldNames() int fieldNameListSize = fieldNameList.size() for (int i = 0; i < fieldNameListSize; i++) { String fieldName = (String) fieldNameList.get(i) Object fieldValue = ec.contextStack.getByString(fieldName) if (fieldValue instanceof CharSequence) { String fieldString = fieldValue.toString() if (fieldString.contains("<")) { ec.messageFacade.addValidationError(null, fieldName, singleServiceName, ec.getL10n().localize("HTML not allowed including less-than (<), greater-than (>), etc symbols"), null) } } } } } if (!ec.messageFacade.hasError()) { serviceActions.run(ec) } } // run actions if any defined, even if service-call also used // NOTE: prior code also required !ec.messageFacade.hasError() which doesn't allow actions to handle errors if (actions != null) { actions.run(ec) } ResponseItem ri = null // if there is an error-response and there are errors, we have a winner if (ec.messageFacade.hasError() && errorResponse) ri = errorResponse // check all conditional-response, if condition then return that response if (ri == null) for (ResponseItem condResp in conditionalResponseList) { if (condResp.checkCondition(ec)) ri = condResp } // no errors, no conditionals, return default if (ri == null) ri = defaultResponse return ri } finally { // don't pop the context until after evaluating conditions so that data set in the actions can be used // don't pop the context at all, see note above about push: sri.ec.context.pop() // all done so pop the artifact info; don't bother making sure this is done on errors/etc like in a finally // clause because if there is an error this will help us know how we got there ec.artifactExecutionFacade.pop(aei) if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly() } } } static class ActionsTransitionItem extends TransitionItem { ActionsTransitionItem(ScreenDefinition parentScreen) { super(parentScreen) name = "actions"; method = "any"; location = "${parentScreen.location}.transition\$${name}" transitionNode = null; beginTransaction = true; readOnly = true; requireSessionToken = false defaultResponse = new ResponseItem(new MNode("default-response", [type:"none"]), this, parentScreen) } // NOTE: runs pre-actions too, see sri.recursiveRunTransition() call in sri.internalRender() ResponseItem run(ScreenRenderImpl sri) { ExecutionContextImpl ec = sri.ec ContextStack context = ec.contextStack context.put("sri", sri) WebFacade wf = ec.getWeb() if (wf == null) throw new BaseArtifactException("Cannot run actions transition outside of a web request") ArrayList extraPathList = sri.screenUrlInfo.extraPathNameList if (extraPathList != null && extraPathList.size() > 0) { String partName = (String) extraPathList.get(0) // is it a form or tree? ScreenForm form = parentScreen.formByName.get(partName) if (form != null) { if (!form.hasDataPrep()) throw new BaseArtifactException("Found form ${partName} in screen ${parentScreen.getScreenName()} but it does not have its own data preparation") ScreenForm.FormInstance formInstance = form.getFormInstance() if (formInstance.isList()) { ScreenForm.FormListRenderInfo renderInfo = formInstance.makeFormListRenderInfo() // old approach, raw data: Object listObj = renderInfo.getListObject(true) // new approach: transformed and auto values filled in based on field defs ArrayList> listObj = sri.getFormListRowValues(renderInfo) HttpServletResponse response = wf.response String listName = formInstance.formNode.attribute("list") if (context.get(listName.concat("Count")) != null) { response.addIntHeader('X-Total-Count', context.get(listName.concat("Count")) as int) response.addIntHeader('X-Page-Index', context.get(listName.concat("PageIndex")) as int) response.addIntHeader('X-Page-Size', context.get(listName.concat("PageSize")) as int) response.addIntHeader('X-Page-Max-Index', context.get(listName.concat("PageMaxIndex")) as int) response.addIntHeader('X-Page-Range-Low', context.get(listName.concat("PageRangeLow")) as int) response.addIntHeader('X-Page-Range-High', context.get(listName.concat("PageRangeHigh")) as int) } logger.info("form ${partName} actions result:\n${JsonOutput.prettyPrint(JsonOutput.toJson(listObj))}") wf.sendJsonResponse(listObj) } // TODO: else support form-single data prep once something is added } else { ScreenTree tree = parentScreen.treeByName.get(partName) if (tree != null) { tree.sendSubNodeJson() } else { throw new BaseArtifactException("Could not find form or tree named ${partName} in screen ${parentScreen.getScreenName()} so cannot run its actions") } } } else { // run actions (if there are any) XmlAction actions = parentScreen.rootSection.actions if (actions != null) { actions.run(ec) // use entire ec.context to get values from always-actions and pre-actions wf.sendJsonResponse(ContextJavaUtil.unwrapMap(context)) } else { wf.sendJsonResponse(new HashMap()) } } return defaultResponse } } /** Special automatic transition to save results of Select Columns form for form-list with select-columns=true */ static class FormSelectColumnsTransitionItem extends TransitionItem { FormSelectColumnsTransitionItem(ScreenDefinition parentScreen) { super(parentScreen) name = "formSelectColumns"; method = "any"; location = "${parentScreen.location}.transition\$${name}" transitionNode = null; beginTransaction = true; readOnly = false; requireSessionToken = false defaultResponse = new ResponseItem(new MNode("default-response", [type:"none"]), this, parentScreen) } ResponseItem run(ScreenRenderImpl sri) { ScreenForm.saveFormConfig(sri.ec) ScreenUrlInfo.UrlInstance redirectUrl = sri.buildUrl(sri.rootScreenDef, sri.screenUrlInfo.preTransitionPathNameList, ".") redirectUrl.addParameters(sri.getCurrentScreenUrl().getParameterMap()).removeParameter("columnsTree") .removeParameter("formLocation").removeParameter("ResetColumns") .removeParameter("SaveColumns").removeParameter("_uiType") if (!sri.sendJsonRedirect(redirectUrl, null)) sri.response.sendRedirect(redirectUrl.getUrlWithParams()) return defaultResponse } } /** Special automatic transition to manage Saved Finds for form-list with saved-finds=true */ static class FormSavedFindsTransitionItem extends TransitionItem { protected ResponseItem noneResponse = null FormSavedFindsTransitionItem(ScreenDefinition parentScreen) { super(parentScreen) name = "formSaveFind"; method = "any"; location = "${parentScreen.location}.transition\$${name}" transitionNode = null; beginTransaction = true; readOnly = false; requireSessionToken = false defaultResponse = new ResponseItem(new MNode("default-response", [url:"."]), this, parentScreen) noneResponse = new ResponseItem(new MNode("default-response", [type:"none"]), this, parentScreen) } ResponseItem run(ScreenRenderImpl sri) { String formListFindId = ScreenForm.processFormSavedFind(sri.ec) if (formListFindId == null || sri.response == null) return defaultResponse ScreenUrlInfo curUrlInfo = sri.getScreenUrlInfo() ArrayList curFpnl = new ArrayList<>(curUrlInfo.fullPathNameList) // remove last path element, is transition name and we just want the screen this is from curFpnl.remove(curFpnl.size() - 1) ScreenUrlInfo fwdUrlInfo = ScreenUrlInfo.getScreenUrlInfo(sri, null, curFpnl, null, 0) ScreenUrlInfo.UrlInstance fwdInstance = fwdUrlInfo.getInstance(sri, null) // use only formListFindId now that ScreenRenderImpl picks it up and auto adds configured parameters: // Map flfInfo = ScreenForm.getFormListFindInfo(formListFindId, sri.ec, null) // fwdInstance.addParameters((Map) flfInfo.findParameters) fwdInstance.addParameter("formListFindId", formListFindId) if (!sri.sendJsonRedirect(fwdInstance, null)) sri.response.sendRedirect(fwdInstance.getUrlWithParams()) return noneResponse } } /** Special automatic transition to get content of a ScreenDocument by docIndex */ static class ScreenDocumentTransitionItem extends TransitionItem { ScreenDocumentTransitionItem(ScreenDefinition parentScreen) { super(parentScreen) name = "screenDoc"; method = "any"; location = "${parentScreen.location}.transition\$${name}" transitionNode = null; beginTransaction = false; readOnly = true; requireSessionToken = false defaultResponse = new ResponseItem(new MNode("default-response", [type:"none"]), this, parentScreen) } ResponseItem run(ScreenRenderImpl sri) { ExecutionContextImpl eci = sri.ec String docIndexString = eci.contextStack.getByString("docIndex") if (docIndexString == null || docIndexString.isEmpty()) { eci.web.sendError(HttpServletResponse.SC_NOT_FOUND, "No docIndex specified", null) return defaultResponse } Long docIndex = docIndexString as Long EntityValue screenDocument = eci.entityFacade.find("moqui.screen.ScreenDocument") .condition("screenLocation", parentScreen.location).condition("docIndex", docIndex) .useCache(true).disableAuthz().one() if (screenDocument == null) { eci.web.sendError(HttpServletResponse.SC_NOT_FOUND, "No document found for index ${docIndex}", null) return defaultResponse } String location = screenDocument.getNoCheckSimple("docLocation") eci.resourceFacade.template(location, sri.response.getWriter()) return defaultResponse } } @CompileStatic static class ResponseItem { protected TransitionItem transitionItem protected ScreenDefinition parentScreen protected XmlAction condition = null protected Map parameterMap = new HashMap<>() protected String type protected String url protected String urlType protected Class parameterMapNameGroovy = null protected boolean saveCurrentScreen protected boolean saveParameters ResponseItem(MNode responseNode, TransitionItem ti, ScreenDefinition parentScreen) { this.transitionItem = ti this.parentScreen = parentScreen String location = "${parentScreen.location}.transition_${ti.name}.${responseNode.name.replace("-","_")}" if (responseNode.first("condition")?.first() != null) { // the script is effectively the first child of the condition element condition = new XmlAction(parentScreen.sfi.ecfi, responseNode.first("condition").first(), location + ".condition") } ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi type = responseNode.attribute("type") ?: "url" url = responseNode.attribute("url") urlType = responseNode.attribute("url-type") ?: "screen-path" if (responseNode.attribute("parameter-map")) parameterMapNameGroovy = ecfi.getGroovyClassLoader() .parseClass(responseNode.attribute("parameter-map"), "${location}.parameter_map") // deferred for future version: saveLastScreen = responseNode."@save-last-screen" == "true" saveCurrentScreen = responseNode.attribute("save-current-screen") == "true" saveParameters = responseNode.attribute("save-parameters") == "true" for (MNode parameterNode in responseNode.children("parameter")) parameterMap.put(parameterNode.attribute("name"), new ParameterItem(parameterNode, location, ecfi)) } boolean checkCondition(ExecutionContextImpl ec) { return condition ? condition.checkCondition(ec) : true } String getType() { return type } String getUrl() { return parentScreen.sfi.ecfi.resourceFacade.expandNoL10n(url, "") } String getUrlType() { return urlType } boolean getSaveCurrentScreen() { return saveCurrentScreen } boolean getSaveParameters() { return saveParameters } Map expandParameters(List extraPathNameList, ExecutionContextImpl ec) { transitionItem.setAllParameters(extraPathNameList, ec) Map ep = new HashMap() for (ParameterItem pi in parameterMap.values()) ep.put(pi.getName(), pi.getValue(ec)) if (parameterMapNameGroovy != null) { Object pm = InvokerHelper.createScript(parameterMapNameGroovy, ec.getContextBinding()).run() if (pm && pm instanceof Map) ep.putAll((Map) pm) } // logger.warn("========== Expanded response map to url [${url}] to: ${ep}; parameterMap=${parameterMap}; parameterMapNameGroovy=[${parameterMapNameGroovy}]") return ep } } @CompileStatic static class SubscreensItem { protected ScreenDefinition parentScreen protected String name protected String location protected String menuTitle protected Integer menuIndex protected boolean menuInclude protected boolean noSubPath = false protected Class disableWhenGroovy = null protected String userGroupId = null SubscreensItem(String name, String location, MNode screen, ScreenDefinition parentScreen) { this.parentScreen = parentScreen this.name = name this.location = location menuTitle = screen.attribute("default-menu-title") ?: getDefaultTitle() menuIndex = screen.attribute("default-menu-index") ? (screen.attribute("default-menu-index") as Integer) : null menuInclude = (!screen.attribute("default-menu-include") || screen.attribute("default-menu-include") == "true") } SubscreensItem(MNode subscreensItem, ScreenDefinition parentScreen) { this.parentScreen = parentScreen name = subscreensItem.attribute("name") location = subscreensItem.attribute("location") menuTitle = subscreensItem.attribute("menu-title") ?: getDefaultTitle() menuIndex = subscreensItem.attribute("menu-index") ? (subscreensItem.attribute("menu-index") as Integer) : null menuInclude = !subscreensItem.attribute("menu-include") || subscreensItem.attribute("menu-include") == "true" noSubPath = subscreensItem.attribute("no-sub-path") == "true" if (subscreensItem.attribute("disable-when")) disableWhenGroovy = parentScreen.sfi.ecfi.getGroovyClassLoader() .parseClass(subscreensItem.attribute("disable-when"), "${parentScreen.location}.subscreens_item_${name}.disable_when") } SubscreensItem(EntityValue subscreensItem, ScreenDefinition parentScreen) { this.parentScreen = parentScreen name = subscreensItem.subscreenName location = subscreensItem.subscreenLocation menuTitle = subscreensItem.menuTitle ?: getDefaultTitle() menuIndex = subscreensItem.menuIndex ? subscreensItem.menuIndex as Integer : null menuInclude = subscreensItem.menuInclude == "Y" noSubPath = subscreensItem.noSubPath == "Y" userGroupId = subscreensItem.userGroupId } String getDefaultTitle() { ExecutionContextFactoryImpl ecfi = parentScreen.sfi.ecfi ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location) MNode screenNode = MNode.parseRootOnly(screenRr) return getPrettyMenuName(screenNode?.attribute("default-menu-title"), location, ecfi) } String getName() { return name } String getLocation() { return location } String getMenuTitle() { return menuTitle } Integer getMenuIndex() { return menuIndex } boolean getMenuInclude() { return menuInclude } boolean getDisable(ExecutionContext ec) { if (disableWhenGroovy == null) return false return InvokerHelper.createScript(disableWhenGroovy, ec.contextBinding).run() as boolean } String getUserGroupId() { return userGroupId } boolean isValidInCurrentContext() { ExecutionContextImpl eci = parentScreen.sfi.getEcfi().getEci() // if the subscreens item is limited to a UserGroup make sure user is in that group if (userGroupId && !(userGroupId in eci.getUser().getUserGroupIdSet())) return false return true } } @CompileStatic static class SubscreensItemComparator implements Comparator { SubscreensItemComparator() { } @Override int compare(SubscreensItem ssi1, SubscreensItem ssi2) { // order by index, null index first if (ssi1.menuIndex == null && ssi2.menuIndex != null) return -1 if (ssi1.menuIndex != null && ssi2.menuIndex == null) return 1 if (ssi1.menuIndex != null && ssi2.menuIndex != null) { int indexComp = ssi1.menuIndex.compareTo(ssi2.menuIndex) if (indexComp != 0) return indexComp } // if index is the same or both null, order by localized title ResourceFacade rf = ssi1.parentScreen.sfi.ecfi.resourceFacade return rf.expand(ssi1.menuTitle,'',null,true).toUpperCase().compareTo( rf.expand(ssi2.menuTitle,'',null,true).toUpperCase()) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import freemarker.template.Template import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.resource.ResourceReference import org.moqui.screen.ScreenFacade import org.moqui.screen.ScreenRender import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.screen.ScreenDefinition.SubscreensItem import org.moqui.impl.screen.ScreenDefinition.TransitionItem import org.moqui.screen.ScreenTest import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.Cache @CompileStatic class ScreenFacadeImpl implements ScreenFacade { protected final static Logger logger = LoggerFactory.getLogger(ScreenFacadeImpl.class) protected final ExecutionContextFactoryImpl ecfi protected final Cache screenLocationCache protected final Cache screenLocationPermCache // used by ScreenUrlInfo final Cache screenUrlCache protected final Cache> screenInfoCache protected final Cache> screenInfoRefRevCache protected final Cache screenTemplateModeCache protected final Map mimeTypeByRenderMode = new HashMap<>() protected final Map alwaysStandaloneByRenderMode = new HashMap<>() protected final Map skipActionsByRenderMode = new HashMap<>() protected final Cache screenTemplateLocationCache protected final Cache widgetTemplateLocationCache protected final Cache> screenFindPathCache protected final Cache dbFormNodeByIdCache protected final Map screenWidgetRenderByMode = new HashMap<>() protected final ScreenWidgetRender textMacroWidgetRender = new ScreenWidgetRenderFtl() protected final Set textOutputRenderModes = new HashSet<>() protected final Set allRenderModes = new HashSet<>() protected final Map> themeIconByTextByTheme = new HashMap<>() ScreenFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi screenLocationCache = ecfi.cacheFacade.getCache("screen.location", String.class, ScreenDefinition.class) screenLocationPermCache = ecfi.cacheFacade.getCache("screen.location.perm", String.class, ScreenDefinition.class) screenUrlCache = ecfi.cacheFacade.getCache("screen.url", String.class, ScreenUrlInfo.class) screenInfoCache = ecfi.cacheFacade.getCache("screen.info", String.class, List.class) screenInfoRefRevCache = ecfi.cacheFacade.getCache("screen.info.ref.rev", String.class, Set.class) screenTemplateModeCache = ecfi.cacheFacade.getCache("screen.template.mode", String.class, Template.class) screenTemplateLocationCache = ecfi.cacheFacade.getCache("screen.template.location", String.class, Template.class) widgetTemplateLocationCache = ecfi.cacheFacade.getCache("widget.template.location", String.class, MNode.class) screenFindPathCache = ecfi.cacheFacade.getCache("screen.find.path", String.class, ArrayList.class) dbFormNodeByIdCache = ecfi.cacheFacade.getCache("screen.form.db.node", String.class, MNode.class) MNode screenFacadeNode = ecfi.getConfXmlRoot().first("screen-facade") ArrayList stoNodes = screenFacadeNode.children("screen-text-output") for (MNode stoNode in stoNodes) textOutputRenderModes.add(stoNode.attribute("type")) ArrayList outputNodes = new ArrayList<>(stoNodes) ArrayList soutNodes = screenFacadeNode.children("screen-output") if (soutNodes != null && soutNodes.size() > 0) outputNodes.addAll(soutNodes) for (MNode outputNode in outputNodes) { String type = outputNode.attribute("type") allRenderModes.add(type) mimeTypeByRenderMode.put(type, outputNode.attribute("mime-type")) alwaysStandaloneByRenderMode.put(type, outputNode.attribute("always-standalone") == "true") skipActionsByRenderMode.put(type, outputNode.attribute("skip-actions") == "true") } } ExecutionContextFactoryImpl getEcfi() { return ecfi } void warmCache() { long startTime = System.currentTimeMillis() int screenCount = 0 for (String rootLocation in getAllRootScreenLocations()) { logger.info("Warming cache for all screens under ${rootLocation}") ScreenDefinition rootSd = getScreenDefinition(rootLocation) screenCount++ screenCount += warmCacheScreen(rootSd) } logger.info("Warmed screen definition cache for ${screenCount} screens in ${(System.currentTimeMillis() - startTime)/1000} seconds") } protected int warmCacheScreen(ScreenDefinition sd) { int screenCount = 0 for (SubscreensItem ssi in sd.subscreensByName.values()) { try { ScreenDefinition subSd = getScreenDefinition(ssi.getLocation()) screenCount++ if (subSd) screenCount += warmCacheScreen(subSd) } catch (Throwable t) { logger.error("Error loading screen at [${ssi.getLocation()}] during cache warming", t) } } return screenCount } List getAllRootScreenLocations() { List allLocations = [] for (MNode webappNode in ecfi.confXmlRoot.first("webapp-list").children("webapp")) { for (MNode rootScreenNode in webappNode.children("root-screen")) { String rootLocation = rootScreenNode.attribute("location") allLocations.add(rootLocation) } } return allLocations } boolean isScreen(String location) { if (location == null || location.length() == 0) return false if (!location.endsWith(".xml")) return false if (screenLocationCache.containsKey(location)) return true try { // we checked the screenLocationCache above, so now do a quick file parse to see if it is a XML file with 'screen' root // element; this is faster and more reliable when a screen is not loaded, screen doesn't have to be fully valid // which is important as with the old approach if there was an error parsing or compiling the screen it was a false // negative and the screen source would be sent in response ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location) MNode screenNode = MNode.parseRootOnly(screenRr) return screenNode != null && "screen".equals(screenNode.getName()) // old approach // ScreenDefinition checkSd = getScreenDefinition(location) // return (checkSd != null) } catch (Throwable t) { // ignore the error, just checking to see if it is a screen if (logger.isInfoEnabled()) logger.info("Error when checking to see if [${location}] is a XML Screen: ${t.toString()}", t) return false } } ScreenDefinition getScreenDefinition(String location) { if (location == null || location.length() == 0) return null ScreenDefinition sd = (ScreenDefinition) screenLocationCache.get(location) if (sd != null) return sd return makeScreenDefinition(location) } protected synchronized ScreenDefinition makeScreenDefinition(String location) { ScreenDefinition sd = (ScreenDefinition) screenLocationCache.get(location) if (sd != null) return sd ResourceReference screenRr = ecfi.resourceFacade.getLocationReference(location) ScreenDefinition permSd = (ScreenDefinition) screenLocationPermCache.get(location) if (permSd != null) { // check to see if file has been modified, if we know when it was last modified boolean modified = true if (screenRr.supportsLastModified()) { long rrLastModified = screenRr.getLastModified() modified = permSd.screenLoadedTime < rrLastModified // see if any screens it depends on (any extends, etc) have been modified if (!modified) { for (String dependLocation in permSd.dependsOnScreenLocations) { ScreenDefinition dependSd = getScreenDefinition(dependLocation) if (dependSd.sourceLastModified == null) { modified = true; break; } if (dependSd.sourceLastModified > permSd.screenLoadedTime) { // logger.info("Screen ${location} depends on ${dependLocation}, modified ${dependSd.sourceLastModified} > ${permSd.screenLoadedTime}") modified = true; break; } } } } if (modified) { screenLocationPermCache.remove(location) logger.info("Reloading modified screen ${location}") } else { //logger.warn("========= screen expired but hasn't changed so reusing: ${location}") // call this just in case a new screen was added, note this does slow things down just a bit, but only in dev (not in production) permSd.populateSubscreens() screenLocationCache.put(location, permSd) return permSd } } MNode screenNode = MNode.parse(screenRr) if (screenNode == null) throw new BaseArtifactException("Could not find definition for screen location ${location}") sd = new ScreenDefinition(this, screenNode, location) // logger.warn("========= loaded screen [${location}] supports LM ${screenRr.supportsLastModified()}, LM: ${screenRr.getLastModified()}") if (screenRr.supportsLastModified()) sd.sourceLastModified = screenRr.getLastModified() screenLocationCache.put(location, sd) if (screenRr.supportsLastModified()) screenLocationPermCache.put(location, sd) return sd } /** NOTE: this is used in ScreenServices.xml for dynamic form stuff (FormResponse, etc) */ MNode getFormNode(String location) { if (!location) return null if (location.contains("#")) { String screenLocation = location.substring(0, location.indexOf("#")) String formName = location.substring(location.indexOf("#")+1) if (screenLocation == "moqui.screen.form.DbForm" || screenLocation == "DbForm") { return ScreenForm.getDbFormNode(formName, ecfi) } else { ScreenDefinition esd = getScreenDefinition(screenLocation) ScreenForm esf = esd ? esd.getForm(formName) : null return esf?.getOrCreateFormNode() } } else { throw new BaseArtifactException("Must use full form location (with #) to get a form node, [${location}] has no hash (#).") } } boolean isRenderModeValid(String renderMode) { return allRenderModes.contains(renderMode) } boolean isRenderModeText(String renderMode) { return textOutputRenderModes.contains(renderMode) } boolean isRenderModeAlwaysStandalone(String renderMode) { return alwaysStandaloneByRenderMode.get(renderMode) } boolean isRenderModeSkipActions(String renderMode) { return skipActionsByRenderMode.get(renderMode) } String getMimeTypeByMode(String renderMode) { return (String) mimeTypeByRenderMode.get(renderMode) } Template getTemplateByMode(String renderMode) { Template template = (Template) screenTemplateModeCache.get(renderMode) if (template != null) return template template = makeTemplateByMode(renderMode) if (template == null) throw new BaseArtifactException("Could not find screen render template for mode [${renderMode}]") return template } protected synchronized Template makeTemplateByMode(String renderMode) { Template template = (Template) screenTemplateModeCache.get(renderMode) if (template != null) return template MNode stoNode = ecfi.getConfXmlRoot().first("screen-facade") .first({ MNode it -> it.name == "screen-text-output" && it.attribute("type") == renderMode }) String templateLocation = stoNode != null ? stoNode.attribute("macro-template-location") : null if (!templateLocation) throw new BaseArtifactException("Could not find macro-template-location for render mode (screen-text-output.@type) [${renderMode}]") // NOTE: this is a special case where we need something to call #recurse so that all includes can be straight libraries String rootTemplate = """<#include "${templateLocation}"/><#visit widgetsNode>""" Template newTemplate try { newTemplate = new Template("moqui.automatic.${renderMode}", new StringReader(rootTemplate), ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration()) } catch (Exception e) { throw new BaseArtifactException("Error while initializing Screen Widgets template at [${templateLocation}]", e) } screenTemplateModeCache.put(renderMode, newTemplate) return newTemplate } Template getTemplateByLocation(String templateLocation) { Template template = (Template) screenTemplateLocationCache.get(templateLocation) if (template != null) return template return makeTemplateByLocation(templateLocation) } protected synchronized Template makeTemplateByLocation(String templateLocation) { Template template = (Template) screenTemplateLocationCache.get(templateLocation) if (template != null) return template // NOTE: this is a special case where we need something to call #recurse so that all includes can be straight libraries String rootTemplate = """<#include "${templateLocation}"/><#visit widgetsNode>""" Template newTemplate try { // this location needs to look like a filename in the runtime directory, otherwise FTL will look for includes under the directory it looks like instead String filename = templateLocation.substring(templateLocation.lastIndexOf("/")+1) newTemplate = new Template(filename, new StringReader(rootTemplate), ecfi.resourceFacade.ftlTemplateRenderer.getFtlConfiguration()) } catch (Exception e) { throw new BaseArtifactException("Error while initializing Screen Widgets template at [${templateLocation}]", e) } screenTemplateLocationCache.put(templateLocation, newTemplate) return newTemplate } MNode getWidgetTemplatesNodeByLocation(String templateLocation) { MNode templatesNode = (MNode) widgetTemplateLocationCache.get(templateLocation) if (templatesNode != null) return templatesNode return makeWidgetTemplatesNodeByLocation(templateLocation) } protected synchronized MNode makeWidgetTemplatesNodeByLocation(String templateLocation) { MNode templatesNode = (MNode) widgetTemplateLocationCache.get(templateLocation) if (templatesNode != null) return templatesNode templatesNode = MNode.parse(templateLocation, ecfi.resourceFacade.getLocationStream(templateLocation)) widgetTemplateLocationCache.put(templateLocation, templatesNode) return templatesNode } ScreenWidgetRender getWidgetRenderByMode(String renderMode) { // first try the cache ScreenWidgetRender swr = (ScreenWidgetRender) screenWidgetRenderByMode.get(renderMode) if (swr != null) return swr // special case for text output render modes if (textOutputRenderModes.contains(renderMode)) return textMacroWidgetRender // try making the ScreenWidgerRender object swr = makeWidgetRenderByMode(renderMode) if (swr == null) throw new BaseArtifactException("Could not find screen widger renderer for mode ${renderMode}") return swr } protected synchronized ScreenWidgetRender makeWidgetRenderByMode(String renderMode) { ScreenWidgetRender swr = (ScreenWidgetRender) screenWidgetRenderByMode.get(renderMode) if (swr != null) return swr MNode stoNode = ecfi.getConfXmlRoot().first("screen-facade") .first({ MNode it -> it.name == "screen-output" && it.attribute("type") == renderMode }) String renderClass = stoNode != null ? stoNode.attribute("widget-render-class") : null if (!renderClass) throw new BaseArtifactException("Could not find widget-render-class for render mode (screen-output.@type) ${renderMode}") ScreenWidgetRender newSwr try { Class swrClass = Thread.currentThread().getContextClassLoader().loadClass(renderClass) newSwr = (ScreenWidgetRender) swrClass.newInstance() } catch (Exception e) { throw new BaseArtifactException("Error while initializing Screen Widgets render class [${renderClass}]", e) } screenWidgetRenderByMode.put(renderMode, newSwr) return newSwr } Map getThemeIconByText(String screenThemeId) { Map themeIconByText = (Map) themeIconByTextByTheme.get(screenThemeId) if (themeIconByText == null) { themeIconByText = new HashMap<>() themeIconByTextByTheme.put(screenThemeId, themeIconByText) } return themeIconByText } String rootScreenFromHost(String host, String webappName) { ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName) MNode webappNode = webappInfo.webappNode MNode wildcardHost = (MNode) null for (MNode rootScreenNode in webappNode.children("root-screen")) { String hostAttr = rootScreenNode.attribute("host") if (".*".equals(hostAttr)) { // remember wildcard host, default to it if no other matches (just in case put earlier in the list than others) wildcardHost = rootScreenNode } else if (host.matches(hostAttr)) { return rootScreenNode.attribute("location") } } if (wildcardHost != null) return wildcardHost.attribute("location") throw new BaseArtifactException("Could not find root screen for host: ${host}") } /** Called from ArtifactStats screen */ List getScreenInfoList(String rootLocation, int levels) { ScreenInfo rootInfo = new ScreenInfo(getScreenDefinition(rootLocation), null, null, 0) List infoList = [] infoList.add(rootInfo) rootInfo.addChildrenToList(infoList, levels) return infoList } class ScreenInfo implements Serializable { ScreenDefinition sd SubscreensItem ssi ScreenInfo parentInfo ScreenInfo rootInfo Map subscreenInfoByName = new TreeMap() Map transitionInfoByName = new TreeMap() int level String name ArrayList screenPath = new ArrayList<>() boolean isNonPlaceholder = false int subscreens = 0, allSubscreens = 0, subscreensNonPlaceholder = 0, allSubscreensNonPlaceholder = 0 int forms = 0, allSubscreensForms = 0 int trees = 0, allSubscreensTrees = 0 int sections = 0, allSubscreensSections = 0 int transitions = 0, allSubscreensTransitions = 0 int transitionsWithActions = 0, allSubscreensTransitionsWithActions = 0 ScreenInfo(ScreenDefinition sd, SubscreensItem ssi, ScreenInfo parentInfo, int level) { this.sd = sd this.ssi = ssi this.parentInfo = parentInfo this.level = level this.name = ssi ? ssi.getName() : sd.getScreenName() if (parentInfo != null) this.screenPath.addAll(parentInfo.screenPath) this.screenPath.add(name) subscreens = sd.subscreensByName.size() forms = sd.formByName.size() trees = sd.treeByName.size() sections = sd.sectionByName.size() transitions = sd.transitionByName.size() for (TransitionItem ti in sd.transitionByName.values()) if (ti.hasActionsOrSingleService()) transitionsWithActions++ isNonPlaceholder = forms > 0 || sections > 0 || transitions > 4 // if (isNonPlaceholder) logger.info("Screen ${name} forms ${forms} sections ${sections} transitions ${transitions}") // trickle up totals ScreenInfo curParent = parentInfo while (curParent != null) { curParent.allSubscreens += 1 if (isNonPlaceholder) curParent.allSubscreensNonPlaceholder += 1 curParent.allSubscreensForms += forms curParent.allSubscreensTrees += trees curParent.allSubscreensSections += sections curParent.allSubscreensTransitions += transitions curParent.allSubscreensTransitionsWithActions += transitionsWithActions if (curParent.parentInfo == null) rootInfo = curParent curParent = curParent.parentInfo } if (rootInfo == null) rootInfo = this // get info for all subscreens ArrayList ssItemEntryList = new ArrayList>(sd.subscreensByName.entrySet()) for (Map.Entry ssEntry in ssItemEntryList) { SubscreensItem curSsi = ssEntry.getValue() List childPath = new ArrayList(screenPath) childPath.add(curSsi.getName()) List curInfoList = (List) screenInfoCache.get(screenPathToString(childPath)) ScreenInfo existingSi = curInfoList ? (ScreenInfo) curInfoList.get(0) : null if (existingSi != null) { subscreenInfoByName.put(ssEntry.getKey(), existingSi) } else { ScreenDefinition ssSd = getScreenDefinition(curSsi.getLocation()) if (ssSd == null) { logger.info("While getting ScreenInfo screen not found for ${curSsi.getName()} at: ${curSsi.getLocation()}") continue } try { ScreenInfo newSi = new ScreenInfo(ssSd, curSsi, this, level+1) subscreenInfoByName.put(ssEntry.getKey(), newSi) } catch (Exception e) { logger.warn("Error loading subscreen ${curSsi.getLocation()}", e) } } } // populate transition references for (Map.Entry tiEntry in sd.transitionByName.entrySet()) { transitionInfoByName.put(tiEntry.getKey(), new TransitionInfo(this, tiEntry.getValue())) } // now that subscreen is initialized save in list for location and path List curInfoList = (List) screenInfoCache.get(sd.location) if (curInfoList == null) { curInfoList = new LinkedList<>() screenInfoCache.put(sd.location, curInfoList) } curInfoList.add(this) screenInfoCache.put(screenPathToString(screenPath), [this]) } String getIndentedName() { StringBuilder sb = new StringBuilder() for (int i = 0; i < level; i++) sb.append("- ") sb.append(" ").append(name) return sb.toString() } void addChildrenToList(List infoList, int maxLevel) { ArrayList ssInfoList = new ArrayList(subscreenInfoByName.values()) ssInfoList.sort({ a, b -> a.ssi?.menuIndex <=> b.ssi?.menuIndex }) for (ScreenInfo si in ssInfoList) { infoList.add(si) if (maxLevel > level) si.addChildrenToList(infoList, maxLevel) } } } class TransitionInfo implements Serializable { ScreenInfo si TransitionItem ti Set responseScreenPathSet = new TreeSet() List transitionPath TransitionInfo(ScreenInfo si, TransitionItem ti) { this.si = si this.ti = ti transitionPath = si.screenPath transitionPath.add(ti.getName()) for (ScreenDefinition.ResponseItem ri in ti.conditionalResponseList) { if (ri.urlType && ri.urlType != "transition" && ri.urlType != "screen") continue String expandedUrl = ri.url if (expandedUrl.contains('${')) expandedUrl = ecfi.getResource().expand(expandedUrl, "") ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(ecfi.screenFacade, si.rootInfo.sd, si.sd, si.screenPath, expandedUrl, 0) if (sui.targetScreen == null) continue String targetScreenPath = screenPathToString(sui.getPreTransitionPathNameList()) responseScreenPathSet.add(targetScreenPath) Set refSet = (Set) screenInfoRefRevCache.get(targetScreenPath) if (refSet == null) { refSet = new HashSet(); screenInfoRefRevCache.put(targetScreenPath, refSet) } refSet.add(screenPathToString(transitionPath)) } } } @CompileStatic static String screenPathToString(List screenPath) { StringBuilder sb = new StringBuilder() for (String screenName in screenPath) sb.append("/").append(screenName) return sb.toString() } @Override ScreenRender makeRender() { return new ScreenRenderImpl(this) } @Override ScreenTest makeTest() { return new ScreenTestImpl(ecfi) } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenForm.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.context.ExecutionContext import org.moqui.entity.* import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.* import org.moqui.impl.entity.AggregationUtil.AggregateFunction import org.moqui.impl.entity.AggregationUtil.AggregateField import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.impl.screen.ScreenDefinition.TransitionItem import org.moqui.impl.service.ServiceDefinition import org.moqui.util.CollectionUtilities import org.moqui.util.ContextStack import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.math.RoundingMode import java.sql.Timestamp @CompileStatic class ScreenForm { protected final static Logger logger = LoggerFactory.getLogger(ScreenForm.class) protected static final Set fieldAttributeNames = new HashSet(["name", "from", "entry-name", "hide"]) protected static final Set subFieldAttributeNames = new HashSet(["title", "tooltip", "red-when", "validate-service", "validate-parameter", "validate-entity", "validate-field"]) protected ExecutionContextFactoryImpl ecfi protected ScreenDefinition sd protected MNode internalFormNode protected List extendFormNodes = null protected FormInstance internalFormInstance protected String location, formName, fullFormName protected boolean hasDbExtensions = false, isDynamic = false, isFormList = false protected String extendsScreenLocation = null protected MNode entityFindNode = null protected XmlAction rowActions = null ScreenForm(ExecutionContextFactoryImpl ecfi, ScreenDefinition sd, MNode baseFormNode, List extendFormNodes, String location) { this.ecfi = ecfi this.sd = sd this.location = location this.formName = baseFormNode.attribute("name") this.fullFormName = sd.getLocation() + "#" + formName // is this a dynamic form? isDynamic = (baseFormNode.attribute("dynamic") == "true") isFormList = "form-list".equals(baseFormNode.name) // does this form have DbForm extensions? boolean alreadyDisabled = ecfi.getExecutionContext().getArtifactExecution().disableAuthz() try { EntityList dbFormLookupList = ecfi.entityFacade.find("DbFormLookup") .condition("modifyXmlScreenForm", fullFormName).useCache(true).list() if (dbFormLookupList) hasDbExtensions = true } finally { if (!alreadyDisabled) ecfi.getExecutionContext().getArtifactExecution().enableAuthz() } if (isDynamic) { internalFormNode = baseFormNode this.extendFormNodes = extendFormNodes } else { // setting parent to null so that this isn't found in addition to the literal form-* element internalFormNode = new MNode(baseFormNode.name, null) initForm(baseFormNode, internalFormNode, extendFormNodes) internalFormInstance = new FormInstance(this) } } boolean isDisplayOnly() { ContextStack cs = ecfi.getEci().contextStack return "true".equals(cs.getByString("formDisplayOnly")) || "true".equals(cs.getByString("formDisplayOnly_${formName}")) } boolean hasDataPrep() { return entityFindNode != null } void initForm(MNode baseFormNode, MNode newFormNode, List extendFormNodes) { // if there is an extends, put that in first (everything else overrides it) if (baseFormNode.attribute("extends")) { String extendsForm = baseFormNode.attribute("extends") if (isDynamic) extendsForm = ecfi.resourceFacade.expand(extendsForm, "") MNode formNode if (extendsForm.contains("#")) { String screenLocation = extendsForm.substring(0, extendsForm.indexOf("#")) String formName = extendsForm.substring(extendsForm.indexOf("#")+1) if (screenLocation == sd.getLocation()) { ScreenForm esf = sd.getForm(formName) formNode = esf?.getOrCreateFormNode() } else if ("moqui.screen.form.DbForm".equals(screenLocation) || "DbForm".equals(screenLocation)) { formNode = getDbFormNode(formName, ecfi) } else { ScreenDefinition esd = ecfi.screenFacade.getScreenDefinition(screenLocation) ScreenForm esf = esd ? esd.getForm(formName) : null formNode = esf?.getOrCreateFormNode() if (formNode != null) { // see if the included section contains any SECTIONS, need to reference those here too! Map> descMap = formNode.descendants(new HashSet(['section', 'section-iterate'])) for (MNode inclRefNode in descMap.get("section")) this.sd.sectionByName.put(inclRefNode.attribute("name"), esd.getSection(inclRefNode.attribute("name"))) for (MNode inclRefNode in descMap.get("section-iterate")) this.sd.sectionByName.put(inclRefNode.attribute("name"), esd.getSection(inclRefNode.attribute("name"))) extendsScreenLocation = screenLocation } } } else { ScreenForm esf = sd.getForm(extendsForm) formNode = esf?.getOrCreateFormNode() } if (formNode == null) throw new BaseArtifactException("Cound not find extends form [${extendsForm}] referred to in form [${newFormNode.attribute("name")}] of screen [${sd.location}]") mergeFormNodes(newFormNode, formNode, true, true) } LinkedHashMap> fieldColumnInfo = "form-list".equals(baseFormNode.name) ? new LinkedHashMap>() : null ArrayList childNodeList = baseFormNode.getChildren() for (int cni = 0; cni < childNodeList.size(); cni++) { MNode formSubNode = (MNode) childNodeList.get(cni) if (formSubNode.name == "field") { MNode nodeCopy = formSubNode.deepCopy(null) expandFieldNode(newFormNode, nodeCopy) mergeFieldNode(newFormNode, nodeCopy, false) } else if (formSubNode.name == "auto-fields-service") { String serviceName = formSubNode.attribute("service-name") ArrayList excludeList = formSubNode.children("exclude") int excludeListSize = excludeList.size() Set excludes = excludeListSize > 0 ? new HashSet() : (Set) null for (int i = 0; i < excludeListSize; i++) { MNode excludeNode = (MNode) excludeList.get(i) excludes.add(excludeNode.attribute("parameter-name")) } if (isDynamic) { serviceName = ecfi.resourceFacade.expandNoL10n(serviceName, null) // NOTE: because this is a GString expand if value not found will evaluate to 'null' if (!serviceName || "null".equals(serviceName)) serviceName = ecfi.getEci().contextStack.getByString("formLocationExtension") } ServiceDefinition serviceDef = ecfi.serviceFacade.getServiceDefinition(serviceName) if (serviceDef != null) { addServiceFields(serviceDef, formSubNode.attribute("include")?:"in", formSubNode.attribute("field-type")?:"edit", excludes, newFormNode, ecfi) continue } if (ecfi.serviceFacade.isEntityAutoPattern(serviceName)) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(ServiceDefinition.getNounFromName(serviceName)) if (ed != null) { addEntityFields(ed, "all", formSubNode.attribute("field-type")?:"edit", null, newFormNode, fieldColumnInfo) continue } } throw new BaseArtifactException("Cound not find service [${serviceName}] or entity noun referred to in auto-fields-service of form [${newFormNode.attribute("name")}] of screen [${sd.location}]") } else if (formSubNode.name == "auto-fields-entity") { String entityName = formSubNode.attribute("entity-name") Boolean addAutoColumns = !"false".equals(formSubNode.attribute("auto-columns")) if (isDynamic) { entityName = ecfi.resourceFacade.expandNoL10n(entityName, null) // NOTE: because this is a GString expand if value not found will evaluate to 'null' if (!entityName || "null".equals(entityName)) entityName = ecfi.getEci().contextStack.getByString("formLocationExtension") } EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) if (ed != null) { ArrayList excludeList = formSubNode.children("exclude") int excludeListSize = excludeList.size() Set excludes = excludeListSize > 0 ? new HashSet() : (Set) null for (int i = 0; i < excludeListSize; i++) { MNode excludeNode = (MNode) excludeList.get(i) excludes.add(excludeNode.attribute("field-name")) } addEntityFields(ed, formSubNode.attribute("include")?:"all", formSubNode.attribute("field-type")?:"find-display", excludes, newFormNode, addAutoColumns ? fieldColumnInfo : null) } else { throw new BaseArtifactException("Cound not find entity [${entityName}] referred to in auto-fields-entity of form [${newFormNode.attribute("name")}] of screen [${sd.location}]") } } } // merge original formNode to override any applicable settings mergeFormNodes(newFormNode, baseFormNode, false, false) // merge screen-extend forms if (extendFormNodes != null) { for (MNode extendFormNode in extendFormNodes) { mergeFormNodes(newFormNode, extendFormNode, true, true) } } // populate validate-service and validate-entity attributes if the target transition calls a single service setSubFieldValidateAttrs(newFormNode, "transition", "default-field") setSubFieldValidateAttrs(newFormNode, "transition", "conditional-field") setSubFieldValidateAttrs(newFormNode, "transition-first-row", "first-row-field") setSubFieldValidateAttrs(newFormNode, "transition-second-row", "second-row-field") setSubFieldValidateAttrs(newFormNode, "transition-last-row", "last-row-field") // check form-single.field-layout and add ONLY hidden fields that are missing MNode fieldLayoutNode = newFormNode.first("field-layout") if (fieldLayoutNode && !fieldLayoutNode.depthFirst({ MNode it -> it.name == "fields-not-referenced" })) { for (MNode fieldNode in newFormNode.children("field")) { if (!fieldLayoutNode.depthFirst({ MNode it -> it.name == "field-ref" && it.attribute("name") == fieldNode.attribute("name") }) && fieldNode.depthFirst({ MNode it -> it.name == "hidden" })) addFieldToFieldLayout(newFormNode, fieldNode) } } // for form-list auto add entity columns if (fieldColumnInfo != null && fieldColumnInfo.size() > 0) { for (Map.Entry> curInfo in fieldColumnInfo.entrySet()) { addAutoEntityColumns(newFormNode, baseFormNode, curInfo.getKey(), curInfo.getValue()) } } if (logger.traceEnabled) logger.trace("Form [${location}] resulted in expanded def: " + newFormNode.toString()) // if (location.contains("FOO")) logger.warn("======== Form [${location}] resulted in expanded def: " + newFormNode.toString()) entityFindNode = newFormNode.first("entity-find") // prep row-actions if (newFormNode.hasChild("row-actions")) rowActions = new XmlAction(ecfi, newFormNode.first("row-actions"), location + ".row_actions") } void setSubFieldValidateAttrs(MNode newFormNode, String transitionAttribute, String subFieldNodeName) { if (newFormNode.attribute(transitionAttribute)) { TransitionItem ti = this.sd.getTransitionItem(newFormNode.attribute(transitionAttribute), null) if (ti != null && ti.getSingleServiceName()) { String singleServiceName = ti.getSingleServiceName() ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(singleServiceName) if (sd != null) { ArrayList inParamNames = sd.getInParameterNames() for (MNode fieldNode in newFormNode.children("field")) { // if the field matches an in-parameter name and does not already have a validate-service, then set it // do it even if it has a validate-service since it might be from another form, in general we want the current service: && !fieldNode."@validate-service" if (inParamNames.contains(fieldNode.attribute("name"))) { for (MNode subField in fieldNode.children(subFieldNodeName)) if (!subField.attribute("validate-service")) subField.attributes.put("validate-service", singleServiceName) } } } else if (ecfi.serviceFacade.isEntityAutoPattern(singleServiceName)) { String entityName = ServiceDefinition.getNounFromName(singleServiceName) EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) ArrayList fieldNames = ed.getAllFieldNames() for (MNode fieldNode in newFormNode.children("field")) { // if the field matches an in-parameter name and does not already have a validate-entity, then set it if (fieldNames.contains(fieldNode.attribute("name"))) { for (MNode subField in fieldNode.children(subFieldNodeName)) if (!subField.attribute("validate-entity")) subField.attributes.put("validate-entity", entityName) } } } } } } String getSavedFindFullLocation() { String fullLocation = location if (isDynamic) { String locationExtension = ecfi.getEci().contextStack.getByString("formLocationExtension") if (!locationExtension) { // try getting service name from auto-fields-service MNode autoFieldsServiceNode = internalFormNode.first("auto-fields-service") if (autoFieldsServiceNode != null) locationExtension = ecfi.resourceFacade.expandNoL10n(autoFieldsServiceNode.attribute("service-name"), null) } if (!locationExtension) { // try getting entity name from auto-fields-entity MNode autoFieldsServiceNode = internalFormNode.first("auto-fields-entity") if (autoFieldsServiceNode != null) locationExtension = ecfi.resourceFacade.expandNoL10n(autoFieldsServiceNode.attribute("entity-name"), null) } if (locationExtension) fullLocation = fullLocation + '#' + locationExtension } return fullLocation } List> getUserFormListFinds(ExecutionContextImpl ec) { EntityList flfuList = ec.entity.find("moqui.screen.form.FormListFindUserView") .condition("userId", ec.user.userId) .condition("formLocation", getSavedFindFullLocation()).useCache(true).list() EntityList flfugList = ec.entity.find("moqui.screen.form.FormListFindUserGroupView") .condition("userGroupId", EntityCondition.IN, ec.user.userGroupIdSet) .condition("formLocation", getSavedFindFullLocation()).useCache(true).list() Set userOnlyFlfIdSet = new HashSet<>() Set formListFindIdSet = new HashSet<>() for (EntityValue ev in flfuList) { userOnlyFlfIdSet.add((String) ev.formListFindId) formListFindIdSet.add((String) ev.formListFindId) } for (EntityValue ev in flfugList) formListFindIdSet.add((String) ev.formListFindId) // get info for each formListFindId List> flfInfoList = new LinkedList<>() for (String formListFindId in formListFindIdSet) flfInfoList.add(getFormListFindInfo(formListFindId, ec, userOnlyFlfIdSet)) // sort by description CollectionUtilities.orderMapList(flfInfoList, ["description"]) return flfInfoList } String getUserDefaultFormListFindId(ExecutionContextImpl ec) { String userId = ec.user.userId if (userId == null) return null EntityValue formListFindUserDefault = ec.entityFacade.find("moqui.screen.form.FormListFindUserDefault") .condition("userId", userId).condition("screenLocation", sd?.location) .disableAuthz().useCache(true).one() if (formListFindUserDefault == null) return null return formListFindUserDefault.get("formListFindId") } List getDbFormNodeList() { if (!hasDbExtensions) return null boolean alreadyDisabled = ecfi.getExecutionContext().getArtifactExecution().disableAuthz() try { // find DbForm records and merge them in as well String formName = sd.getLocation() + "#" + internalFormNode.attribute("name") EntityList dbFormLookupList = this.ecfi.entityFacade.find("DbFormLookup") .condition("userGroupId", EntityCondition.IN, ecfi.getExecutionContext().getUser().getUserGroupIdSet()) .condition("modifyXmlScreenForm", formName) .useCache(true).list() // logger.warn("TOREMOVE: looking up DbForms for form [${formName}], found: ${dbFormLookupList}") if (!dbFormLookupList) return null List formNodeList = new ArrayList() for (EntityValue dbFormLookup in dbFormLookupList) formNodeList.add(getDbFormNode(dbFormLookup.getString("formId"), ecfi)) return formNodeList } finally { if (!alreadyDisabled) ecfi.getExecutionContext().getArtifactExecution().enableAuthz() } } static MNode getDbFormNode(String formId, ExecutionContextFactoryImpl ecfi) { MNode dbFormNode = (MNode) ecfi.screenFacade.dbFormNodeByIdCache.get(formId) if (dbFormNode == null) { boolean alreadyDisabled = ecfi.getEci().artifactExecutionFacade.disableAuthz() try { EntityValue dbForm = ecfi.entityFacade.fastFindOne("moqui.screen.form.DbForm", true, false, formId) if (dbForm == null) throw new BaseArtifactException("Could not find DbForm record with ID [${formId}]") dbFormNode = new MNode((dbForm.isListForm == "Y" ? "form-list" : "form-single"), null) EntityList dbFormFieldList = ecfi.entityFacade.find("moqui.screen.form.DbFormField").condition("formId", formId) .orderBy("layoutSequenceNum").useCache(true).list() for (EntityValue dbFormField in dbFormFieldList) { String fieldName = (String) dbFormField.fieldName MNode newFieldNode = new MNode("field", [name:fieldName]) if (dbFormField.entryName) newFieldNode.attributes.put("from", (String) dbFormField.entryName) // create the sub-field node; if DbFormField.condition use conditional-field instead of default-field MNode subFieldNode if (dbFormField.condition) { subFieldNode = newFieldNode.append("conditional-field", [condition:dbFormField.condition] as Map) } else { subFieldNode = newFieldNode.append("default-field", null) } if (dbFormField.title) subFieldNode.attributes.put("title", (String) dbFormField.title) if (dbFormField.tooltip) subFieldNode.attributes.put("tooltip", (String) dbFormField.tooltip) String fieldType = dbFormField.fieldTypeEnumId if (!fieldType) throw new BaseArtifactException("DbFormField record with formId [${formId}] and fieldName [${fieldName}] has no fieldTypeEnumId") String widgetName = fieldType.substring(6) MNode widgetNode = subFieldNode.append(widgetName, null) EntityList dbFormFieldAttributeList = ecfi.entityFacade.find("moqui.screen.form.DbFormFieldAttribute") .condition([formId:formId, fieldName:fieldName] as Map).useCache(true).list() for (EntityValue dbFormFieldAttribute in dbFormFieldAttributeList) { String attributeName = dbFormFieldAttribute.attributeName if (fieldAttributeNames.contains(attributeName)) { newFieldNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value) } else if (subFieldAttributeNames.contains(attributeName)) { subFieldNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value) } else { widgetNode.attributes.put(attributeName, (String) dbFormFieldAttribute.value) } } // add option settings when applicable EntityList dbFormFieldOptionList = ecfi.entityFacade.find("moqui.screen.form.DbFormFieldOption") .condition([formId:formId, fieldName:fieldName] as Map).useCache(true).list() EntityList dbFormFieldEntOptsList = ecfi.entityFacade.find("moqui.screen.form.DbFormFieldEntOpts") .condition([formId:formId, fieldName:fieldName] as Map).useCache(true).list() EntityList combinedOptionList = new EntityListImpl(ecfi.entityFacade) combinedOptionList.addAll(dbFormFieldOptionList) combinedOptionList.addAll(dbFormFieldEntOptsList) combinedOptionList.orderByFields(["sequenceNum"]) for (EntityValue optionValue in combinedOptionList) { if (optionValue.resolveEntityName() == "moqui.screen.form.DbFormFieldOption") { widgetNode.append("option", [key:(String) optionValue.keyValue, text:(String) optionValue.text]) } else { MNode entityOptionsNode = widgetNode.append("entity-options", [text:((String) optionValue.text ?: "\${description}")]) MNode entityFindNode = entityOptionsNode.append("entity-find", ["entity-name":optionValue.getString("entityName")]) EntityList dbFormFieldEntOptsCondList = ecfi.entityFacade.find("moqui.screen.form.DbFormFieldEntOptsCond") .condition([formId:formId, fieldName:fieldName, sequenceNum:optionValue.sequenceNum]) .useCache(true).list() for (EntityValue dbFormFieldEntOptsCond in dbFormFieldEntOptsCondList) { entityFindNode.append("econdition", ["field-name":(String) dbFormFieldEntOptsCond.entityFieldName, value:(String) dbFormFieldEntOptsCond.value]) } EntityList dbFormFieldEntOptsOrderList = ecfi.entityFacade.find("moqui.screen.form.DbFormFieldEntOptsOrder") .condition([formId:formId, fieldName:fieldName, sequenceNum:optionValue.sequenceNum]) .orderBy("orderSequenceNum").useCache(true).list() for (EntityValue dbFormFieldEntOptsOrder in dbFormFieldEntOptsOrderList) { entityFindNode.append("order-by", ["field-name":(String) dbFormFieldEntOptsOrder.entityFieldName]) } } } // logger.warn("TOREMOVE Adding DbForm field [${fieldName}] widgetName [${widgetName}] at layout sequence [${dbFormField.getLong("layoutSequenceNum")}], node: ${newFieldNode}") if (dbFormField.getLong("layoutSequenceNum") != null) { newFieldNode.attributes.put("layoutSequenceNum", dbFormField.getString("layoutSequenceNum")) } mergeFieldNode(dbFormNode, newFieldNode, false) } ecfi.screenFacade.dbFormNodeByIdCache.put(formId, dbFormNode) } finally { if (!alreadyDisabled) ecfi.getEci().artifactExecutionFacade.enableAuthz() } } return dbFormNode } /** This is the main method for using an XML Form, the rendering is done based on the Node returned. */ MNode getOrCreateFormNode() { // NOTE: this is cached in the ScreenRenderImpl as it may be called multiple times for a single form render List dbFormNodeList = hasDbExtensions ? getDbFormNodeList() : null boolean displayOnly = isDisplayOnly() if (isDynamic) { MNode newFormNode = new MNode(internalFormNode.name, null) initForm(internalFormNode, newFormNode, extendFormNodes) if (dbFormNodeList != null) for (MNode dbFormNode in dbFormNodeList) mergeFormNodes(newFormNode, dbFormNode, false, true) return newFormNode } else if ((dbFormNodeList != null && dbFormNodeList.size() > 0) || displayOnly) { MNode newFormNode = new MNode(internalFormNode.name, null) // deep copy true to avoid bleed over of new fields and such mergeFormNodes(newFormNode, internalFormNode, true, true) // logger.warn("========== merging in dbFormNodeList: ${dbFormNodeList}", new BaseException("getOrCreateFormNode call location")) if (dbFormNodeList != null) for (MNode dbFormNode in dbFormNodeList) mergeFormNodes(newFormNode, dbFormNode, false, true) if (displayOnly) { // change all non-display fields to simple display elements for (MNode fieldNode in newFormNode.children("field")) { // don't replace header form, should be just for searching: if (fieldNode."header-field") fieldSubNodeToDisplay(newFormNode, fieldNode, (Node) fieldNode."header-field"[0]) for (MNode conditionalFieldNode in fieldNode.children("conditional-field")) fieldSubNodeToDisplay(conditionalFieldNode) if (fieldNode.hasChild("default-field")) fieldSubNodeToDisplay(fieldNode.first("default-field")) } } return newFormNode } else { return internalFormNode } } MNode getAutoCleanedNode() { MNode outNode = getOrCreateFormNode().deepCopy(null) outNode.attributes.remove("dynamic") outNode.attributes.remove("multi") for (int i = 0; i < outNode.children.size(); ) { MNode fn = outNode.children.get(i) if (fn.attribute("name") in ["aen", "den", "lastUpdatedStamp"]) { outNode.children.remove(i) } else { for (MNode subFn in fn.getChildren()) { subFn.attributes.remove("validate-entity") subFn.attributes.remove("validate-field") } i++ } } return outNode } static Set displayOnlyIgnoreNodeNames = ["hidden", "ignored", "label", "image"] as Set protected void fieldSubNodeToDisplay(MNode fieldSubNode) { MNode widgetNode = fieldSubNode.children ? fieldSubNode.children.first() : null if (widgetNode == null) return if (widgetNode.name.contains("display") || displayOnlyIgnoreNodeNames.contains(widgetNode.name)) return if ("reset".equalsIgnoreCase(widgetNode.name) || "submit".equalsIgnoreCase(widgetNode.name)) { fieldSubNode.children.remove(0) return } if ("link".equalsIgnoreCase(widgetNode.name)) { // if it goes to a transition with service-call or actions then remove it, otherwise leave it String urlType = widgetNode.attribute('url-type') if ((urlType == null || urlType.isEmpty() || "transition".equals(urlType)) && sd.getTransitionItem(widgetNode.attribute('url'), null)?.hasActionsOrSingleService()) { fieldSubNode.children.remove(0) } return } // otherwise change it to a display Node fieldSubNode.replace(0, "display", null) // not as good, puts it after other child Nodes: fieldSubNode.remove(widgetNode); fieldSubNode.appendNode("display") } void addAutoServiceField(EntityDefinition nounEd, MNode parameterNode, String fieldType, String serviceVerb, MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) { // if the parameter corresponds to an entity field, we can do better with that EntityDefinition fieldEd = nounEd if (parameterNode.attribute("entity-name")) fieldEd = ecfi.entityFacade.getEntityDefinition(parameterNode.attribute("entity-name")) String fieldName = parameterNode.attribute("field-name") ?: parameterNode.attribute("name") if (fieldEd != null && fieldEd.getFieldNode(fieldName) != null) { addAutoEntityField(fieldEd, fieldName, fieldType, newFieldNode, subFieldNode, baseFormNode) return } // otherwise use the old approach and do what we can with the service def String spType = parameterNode.attribute("type") ?: "String" String efType = fieldEd != null ? fieldEd.getFieldInfo(parameterNode.attribute("name"))?.type : null switch (fieldType) { case "edit": // lastUpdatedStamp is always hidden for edit (needed for optimistic lock) if (parameterNode.attribute("name") == "lastUpdatedStamp") { subFieldNode.append("hidden", null) break } /* NOTE: used to do this but doesn't make sense for main use of this in ServiceRun/etc screens; for app forms should separates pks and use display or hidden instead of edit: if (parameterNode.attribute("required") == "true" && serviceVerb.startsWith("update")) { subFieldNode.append("hidden", null) } else { } */ if (spType.endsWith("Date") && spType != "java.util.Date") { subFieldNode.append("date-time", [type:"date", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Time")) { subFieldNode.append("date-time", [type:"time", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Timestamp") || spType == "java.util.Date") { subFieldNode.append("date-time", [type:"date-time", format:parameterNode.attribute("format")]) } else { if (efType == "text-long" || efType == "text-very-long") { subFieldNode.append("text-area", null) } else { subFieldNode.append("text-line", ['default-value':parameterNode.attribute("default-value")]) } } break case "find": if (spType.endsWith("Date") && spType != "java.util.Date") { subFieldNode.append("date-find", [type:"date", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Time")) { subFieldNode.append("date-find", [type:"time", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Timestamp") || spType == "java.util.Date") { subFieldNode.append("date-find", [type:"date-time", format:parameterNode.attribute("format")]) } else if (spType.endsWith("BigDecimal") || spType.endsWith("BigInteger") || spType.endsWith("Long") || spType.endsWith("Integer") || spType.endsWith("Double") || spType.endsWith("Float") || spType.endsWith("Number")) { subFieldNode.append("range-find", null) } else { subFieldNode.append("text-find", null) } break case "display": subFieldNode.append("display", [format:parameterNode.attribute("format")]) break case "find-display": MNode headerFieldNode = newFieldNode.append("header-field", null) if (spType.endsWith("Date") && spType != "java.util.Date") { headerFieldNode.append("date-find", [type:"date", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Time")) { headerFieldNode.append("date-find", [type:"time", format:parameterNode.attribute("format")]) } else if (spType.endsWith("Timestamp") || spType == "java.util.Date") { headerFieldNode.append("date-find", [type:"date-time", format:parameterNode.attribute("format")]) } else if (spType.endsWith("BigDecimal") || spType.endsWith("BigInteger") || spType.endsWith("Long") || spType.endsWith("Integer") || spType.endsWith("Double") || spType.endsWith("Float") || spType.endsWith("Number")) { headerFieldNode.append("range-find", null) } else { headerFieldNode.append("text-find", null) } subFieldNode.append("display", [format:parameterNode.attribute("format")]) break case "hidden": subFieldNode.append("hidden", null) break } } void addServiceFields(ServiceDefinition sd, String include, String fieldType, Set excludes, MNode baseFormNode, ExecutionContextFactoryImpl ecfi) { String serviceVerb = sd.verb //String serviceType = sd.serviceNode."@type" EntityDefinition nounEd = null try { nounEd = ecfi.entityFacade.getEntityDefinition(sd.noun) } catch (EntityException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring entity exception, may not be real entity name: ${e.toString()}") } List parameterNodes = [] if (include == "in" || include == "all") parameterNodes.addAll(sd.serviceNode.first("in-parameters").children("parameter")) if (include == "out" || include == "all") parameterNodes.addAll(sd.serviceNode.first("out-parameters").children("parameter")) for (MNode parameterNode in parameterNodes) { String parameterName = parameterNode.attribute("name") if ((excludes != null && excludes.contains(parameterName)) || "lastUpdatedStamp".equals(parameterName)) continue MNode newFieldNode = new MNode("field", [name:parameterName]) MNode subFieldNode = newFieldNode.append("default-field", ["validate-service":sd.serviceName, "validate-parameter":parameterName]) addAutoServiceField(nounEd, parameterNode, fieldType, serviceVerb, newFieldNode, subFieldNode, baseFormNode) mergeFieldNode(baseFormNode, newFieldNode, false) } } void addEntityFields(EntityDefinition ed, String include, String fieldType, Set excludes, MNode baseFormNode, LinkedHashMap> fieldColumnInfo) { ArrayList fieldNames = ed.getFieldNames("all".equals(include) || "pk".equals(include), "all".equals(include) || "nonpk".equals(include)) int fieldNamesSize = fieldNames.size() ArrayList displayFieldNames = new ArrayList<>(fieldNamesSize) for (int i = 0; i < fieldNamesSize; i++) { String fieldName = (String) fieldNames.get(i) if ((excludes != null && excludes.contains(fieldName)) || "lastUpdatedStamp".equals(fieldName)) continue FieldInfo fi = ed.getFieldInfo(fieldName) String efType = fi.type ?: "text-long" boolean makeDefaultField = true if ("form-list".equals(baseFormNode.name)) { Boolean displayField = (Boolean) null String defaultDisplay = fi.fieldNode.attribute("default-display") if (defaultDisplay != null && !defaultDisplay.isEmpty()) displayField = "true".equals(defaultDisplay) if (displayField == null && efType in ['text-long', 'text-very-long', 'binary-very-long']) { // allow find by and display text-long even if not the default, but in form-list never do anything with text-very-long or binary-very-long // DEJ 20201120 changed set displayField to true instead of false so is display, change to false to not display if ("text-long".equals(efType)) { displayField = true } else { continue } } makeDefaultField = displayField == null || displayField.booleanValue() } displayFieldNames.add(fieldName) MNode newFieldNode = new MNode("field", [name:fieldName]) MNode subFieldNode = makeDefaultField ? newFieldNode.append("default-field", ["validate-entity":ed.getFullEntityName(), "validate-field":fieldName]) : null addAutoEntityField(ed, fieldName, fieldType, newFieldNode, subFieldNode, baseFormNode) // logger.info("Adding form auto entity field [${fieldName}] of type [${efType}], fieldType [${fieldType}] serviceVerb [${serviceVerb}], node: ${newFieldNode}") mergeFieldNode(baseFormNode, newFieldNode, false) } // separate handling for view-entity with aliases using pq-expression if (ed.isViewEntity) { Map pqExpressionNodeMap = ed.getPqExpressionNodeMap() if (pqExpressionNodeMap != null) { for (MNode pqExprNode in pqExpressionNodeMap.values()) { String defaultDisplay = pqExprNode.attribute("default-display") if (!"true".equals(defaultDisplay)) continue String fieldName = pqExprNode.attribute("name") MNode newFieldNode = new MNode("field", [name:fieldName]) MNode subFieldNode = newFieldNode.append("default-field", ["validate-entity":ed.getFullEntityName(), "validate-field":fieldName]) addAutoEntityField(ed, fieldName, "display", newFieldNode, subFieldNode, baseFormNode) mergeFieldNode(baseFormNode, newFieldNode, false) } } } if (fieldColumnInfo != null) fieldColumnInfo.put(ed.getFullEntityName(), displayFieldNames) // logger.info("TOREMOVE: after addEntityFields formNode is: ${baseFormNode}") } void addAutoEntityColumns(MNode newFormNode, MNode initFieldsFormNode, String entityName, ArrayList displayFieldNames) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) // if more than 6 fields auto stack in columns int displayFieldNamesSize = displayFieldNames.size() int fieldsPerCol = (displayFieldNamesSize / 6.0).setScale(0, BigDecimal.ROUND_HALF_UP).intValue() ArrayList idDateFields = new ArrayList<>() ArrayList numberFields = new ArrayList<>() ArrayList shortFields = new ArrayList<>() ArrayList longFields = new ArrayList<>() for (int i = 0; i < displayFieldNamesSize; i++) { String fieldName = (String) displayFieldNames.get(i) FieldInfo fi = ed.getFieldInfo(fieldName) String efType = fi.type ?: "text-long" if (efType.startsWith("id") || efType.startsWith("date") || efType.equals("time")) { idDateFields.add(fieldName) } else if (efType.startsWith("number") || efType.startsWith("currency") || efType.equals("text-indicator")) { numberFields.add(fieldName) } else if ("text-short".equals(efType) || "text-medium".equals(efType)) { shortFields.add(fieldName) } else { longFields.add(fieldName) } } ArrayList sortedFields = new ArrayList<>(displayFieldNamesSize) sortedFields.addAll(idDateFields); sortedFields.addAll(numberFields) sortedFields.addAll(shortFields); sortedFields.addAll(longFields) MNode columnsNode = newFormNode.first("columns") if (columnsNode == null) { columnsNode = newFormNode.append("columns", null) if (initFieldsFormNode != null) { ArrayList fieldNodeList = initFieldsFormNode.children("field") for (int i = 0; i < fieldNodeList.size(); i++) { MNode fieldNode = (MNode) fieldNodeList.get(i) ArrayList defCondChildList = new ArrayList(fieldNode.children("default-field")) defCondChildList.addAll(fieldNode.children("conditional-field")) HashSet fieldsUsed = new HashSet<>() for (int dci = 0; dci < defCondChildList.size(); dci++) { MNode defCondChild = (MNode) defCondChildList.get(dci) String fieldName = fieldNode.attribute("name") if (fieldsUsed.contains(fieldName)) continue if (defCondChild.children.size() == 1 && (defCondChild.hasChild("hidden") || defCondChild.hasChild("ignored"))) continue MNode columnNode = columnsNode.append("column", null) columnNode.append("field-ref", [name:fieldName]) fieldsUsed.add(fieldName) } } } } ArrayList curColumnList = new ArrayList<>(fieldsPerCol) for (int i = 0; i < displayFieldNamesSize; i++) { String fieldName = (String) sortedFields.get(i) curColumnList.add(fieldName) if (curColumnList.size() == fieldsPerCol || (i + 1 == displayFieldNamesSize)) { MNode columnNode = columnsNode.append("column", null) for (int ci = 0; ci < curColumnList.size(); ci++) { String curFieldName = (String) curColumnList.get(ci) columnNode.append("field-ref", [name:curFieldName]) } curColumnList.clear() } } } void addAutoEntityField(EntityDefinition ed, String fieldName, String fieldType, MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) { // NOTE: in some cases this may be null FieldInfo fieldInfo = ed.getFieldInfo(fieldName) String efType = fieldInfo?.type ?: "text-long" // to see if this should be a drop-down with data from another entity, // find first relationship that has this field as the only key map and is not a many relationship MNode oneRelNode = null Map oneRelKeyMap = null String relatedEntityName = null EntityDefinition relatedEd = null for (RelationshipInfo relInfo in ed.getRelationshipsInfo(false)) { String relEntityName = relInfo.relatedEntityName EntityDefinition relEd = relInfo.relatedEd Map km = relInfo.keyMap if (km.size() == 1 && km.containsKey(fieldName) && relInfo.type == "one" && relInfo.relNode.attribute("is-auto-reverse") != "true") { oneRelNode = relInfo.relNode oneRelKeyMap = km relatedEntityName = relEntityName relatedEd = relEd break } } String keyField = (String) oneRelKeyMap?.keySet()?.iterator()?.next() String relKeyField = (String) oneRelKeyMap?.values()?.iterator()?.next() String relDefaultDescriptionField = relatedEd?.getDefaultDescriptionField() switch (fieldType) { case "edit": // lastUpdatedStamp is always hidden for edit (needed for optimistic lock) if (fieldName == "lastUpdatedStamp") { subFieldNode.append("hidden", null) break } // handle header-field if (baseFormNode.name == "form-list" && !newFieldNode.hasChild("header-field")) newFieldNode.append("header-field", ["show-order-by":"true"]) // handle sub field (default-field) if (subFieldNode == null) break /* NOTE: used to do this but doesn't make sense for main use of this in ServiceRun/etc screens; for app forms should separates pks and use display or hidden instead of edit: List pkFieldNameSet = ed.getPkFieldNames() if (pkFieldNameSet.contains(fieldName) && serviceVerb == "update") { subFieldNode.append("hidden", null) } else { } */ if (efType.startsWith("date") || efType.startsWith("time")) { MNode dateTimeNode = subFieldNode.append("date-time", [type:efType]) if (fieldName == "fromDate") dateTimeNode.attributes.put("default-value", "\${ec.l10n.format(ec.user.nowTimestamp, 'yyyy-MM-dd HH:mm')}") } else if ("text-long".equals(efType) || "text-very-long".equals(efType)) { subFieldNode.append("text-area", null) } else if ("text-indicator".equals(efType)) { MNode dropDownNode = subFieldNode.append("drop-down", ["allow-empty":"true"]) dropDownNode.append("option", ["key":"Y"]) dropDownNode.append("option", ["key":"N"]) } else if ("binary-very-long".equals(efType)) { // would be nice to have something better for this, like a download somehow subFieldNode.append("display", null) } else { if (oneRelNode != null) { addEntityFieldDropDown(oneRelNode, subFieldNode, relatedEd, relKeyField, "") } else { if (efType.startsWith("number-") || efType.startsWith("currency-")) { subFieldNode.append("text-line", [size:"10"]) } else { subFieldNode.append("text-line", [size:"30"]) } } } break case "find": // handle header-field if (baseFormNode.name == "form-list" && !newFieldNode.hasChild("header-field")) newFieldNode.append("header-field", ["show-order-by":"case-insensitive"]) // handle sub field (default-field) if (subFieldNode == null) break if (efType.startsWith("date") || efType.startsWith("time")) { subFieldNode.append("date-find", [type:efType]) } else if (efType.startsWith("number-") || efType.startsWith("currency-")) { subFieldNode.append("range-find", null) } else { if (oneRelNode != null) { addEntityFieldDropDown(oneRelNode, subFieldNode, relatedEd, relKeyField, "") } else { subFieldNode.append("text-find", null) } } break case "display": // handle header-field if (baseFormNode.name == "form-list" && !newFieldNode.hasChild("header-field")) newFieldNode.append("header-field", ["show-order-by":"case-insensitive"]) // handle sub field (default-field) if (subFieldNode == null) break String textStr if (relDefaultDescriptionField) textStr = "\${" + relDefaultDescriptionField + " ?: ''} [\${" + relKeyField + "}]" else textStr = "[\${" + relKeyField + "}]" if (oneRelNode != null) { subFieldNode.append("display-entity", ["entity-name":(oneRelNode.attribute("related") ?: oneRelNode.attribute("related-entity-name")), "text":textStr]) } else { Map attrs = (Map) null if (efType.equals("currency-amount")) { attrs = [format:"#,##0.00"] } else if (efType.equals("currency-precise")) { attrs = [format:"#,##0.000"] } subFieldNode.append("display", attrs) } break case "find-display": // handle header-field if (baseFormNode.name == "form-list" && !newFieldNode.hasChild("header-field")) newFieldNode.append("header-field", ["show-order-by":"case-insensitive"]) MNode headerFieldNode = newFieldNode.hasChild("header-field") ? newFieldNode.first("header-field") : newFieldNode.append("header-field", null) if ("date".equals(efType) || "time".equals(efType)) { headerFieldNode.append("date-find", [type:efType]) } else if ("date-time".equals(efType)) { headerFieldNode.append("date-period", [time:"true"]) } else if (efType.startsWith("number-") || efType.startsWith("currency-")) { headerFieldNode.append("range-find", [size:'10']) newFieldNode.attributes.put("align", "right") String function = fieldInfo?.fieldNode?.attribute("function") if (function != null && function in ['min', 'max', 'avg']) { newFieldNode.attributes.put("show-total", function) } else { newFieldNode.attributes.put("show-total", "sum") } } else { if (oneRelNode != null) { addEntityFieldDropDown(oneRelNode, headerFieldNode, relatedEd, relKeyField, "") } else { headerFieldNode.append("text-find", [size:'30', "default-operator":"begins", "ignore-case":"false"]) } } // handle sub field (default-field) if (subFieldNode == null) break if (oneRelNode != null) { String textStr if (relDefaultDescriptionField) textStr = "\${" + relDefaultDescriptionField + " ?: ''} [\${" + relKeyField + "}]" else textStr = "[\${" + relKeyField + "}]" subFieldNode.append("display-entity", ["text":textStr, "entity-name":(oneRelNode.attribute("related") ?: oneRelNode.attribute("related-entity-name"))]) } else { Map attrs = (Map) null if (efType.equals("currency-amount")) { attrs = [format:"#,##0.00"] } else if (efType.equals("currency-precise")) { attrs = [format:"#,##0.000"] } subFieldNode.append("display", attrs) } break case "hidden": subFieldNode.append("hidden", null) break } // NOTE: don't like where this is located, would be nice to have a generic way for forms to add this sort of thing if (oneRelNode != null && subFieldNode != null) { if (internalFormNode.attribute("name") == "UpdateMasterEntityValue") { MNode linkNode = subFieldNode.append("link", [url:"edit", text:("Edit ${relatedEd.getPrettyName(null, null)} [\${fieldValues." + keyField + "}]").toString(), condition:keyField, 'link-type':'anchor'] as Map) linkNode.append("parameter", [name:"aen", value:relatedEntityName]) linkNode.append("parameter", [name:relKeyField, from:"fieldValues.${keyField}".toString()]) } } } protected void addEntityFieldDropDown(MNode oneRelNode, MNode subFieldNode, EntityDefinition relatedEd, String relKeyField, String dropDownStyle) { String title = oneRelNode.attribute("title") if (relatedEd == null) { subFieldNode.append("text-line", null) return } String relatedEntityName = relatedEd.getFullEntityName() String relDefaultDescriptionField = relatedEd.getDefaultDescriptionField() // NOTE: combo-box not currently supported, so only show drop-down if less than 200 records long recordCount if (relatedEntityName == "moqui.basic.Enumeration") { recordCount = ecfi.entityFacade.find("moqui.basic.Enumeration").condition("enumTypeId", title).disableAuthz().count() } else if (relatedEntityName == "moqui.basic.StatusItem") { recordCount = ecfi.entityFacade.find("moqui.basic.StatusItem").condition("statusTypeId", title).disableAuthz().count() } else { recordCount = ecfi.entityFacade.find(relatedEntityName).disableAuthz().count() } if (recordCount > 0 && recordCount <= 200) { // FOR FUTURE: use the combo-box just in case the drop-down as a default is over-constrained MNode dropDownNode = subFieldNode.append("drop-down", ["allow-empty":"true", style:(dropDownStyle ?: "")]) MNode entityOptionsNode = dropDownNode.append("entity-options", null) MNode entityFindNode = entityOptionsNode.append("entity-find", ["entity-name":relatedEntityName, "offset":"0", "limit":"200"]) if (relatedEntityName == "moqui.basic.Enumeration") { // recordCount will be > 0 so we know there are records with this type entityFindNode.append("econdition", ["field-name":"enumTypeId", "value":title]) } else if (relatedEntityName == "moqui.basic.StatusItem") { // recordCount will be > 0 so we know there are records with this type entityFindNode.append("econdition", ["field-name":"statusTypeId", "value":title]) } if (relDefaultDescriptionField) { entityOptionsNode.attributes.put("text", "\${" + relDefaultDescriptionField + " ?: ''} [\${" + relKeyField + "}]") entityFindNode.append("order-by", ["field-name":relDefaultDescriptionField]) } } else { subFieldNode.append("text-line", null) } } protected void expandFieldNode(MNode baseFormNode, MNode fieldNode) { if (fieldNode.hasChild("header-field")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first("header-field")) if (fieldNode.hasChild("first-row-field")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first("first-row-field")) if (fieldNode.hasChild("second-row-field")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first("second-row-field")) for (MNode conditionalFieldNode in fieldNode.children("conditional-field")) expandFieldSubNode(baseFormNode, fieldNode, conditionalFieldNode) if (fieldNode.hasChild("default-field")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first("default-field")) if (fieldNode.hasChild("last-row-field")) expandFieldSubNode(baseFormNode, fieldNode, fieldNode.first("last-row-field")) } protected void expandFieldSubNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode) { MNode widgetNode = fieldSubNode.children ? fieldSubNode.children.get(0) : null if (widgetNode == null) return if (widgetNode.name == "auto-widget-service") { fieldSubNode.children.remove(0) addAutoWidgetServiceNode(baseFormNode, fieldNode, fieldSubNode, widgetNode) } else if (widgetNode.name == "auto-widget-entity") { fieldSubNode.children.remove(0) addAutoWidgetEntityNode(baseFormNode, fieldNode, fieldSubNode, widgetNode) } else if (widgetNode.name == "widget-template-include") { List setNodeList = widgetNode.children("set") String templateLocation = widgetNode.attribute("location") if (!templateLocation) throw new BaseArtifactException("widget-template-include.@location cannot be empty") if (!templateLocation.contains("#")) throw new BaseArtifactException("widget-template-include.@location must contain a hash/pound sign to separate the file location and widget-template.@name: [${templateLocation}]") String fileLocation = templateLocation.substring(0, templateLocation.indexOf("#")) String widgetTemplateName = templateLocation.substring(templateLocation.indexOf("#") + 1) MNode widgetTemplatesNode = ecfi.screenFacade.getWidgetTemplatesNodeByLocation(fileLocation) MNode widgetTemplateNode = widgetTemplatesNode?.first({ MNode it -> it.attribute("name") == widgetTemplateName }) if (widgetTemplateNode == null) throw new BaseArtifactException("Could not find widget-template [${widgetTemplateName}] in [${fileLocation}]") // remove the widget-template-include node fieldSubNode.children.remove(0) // remove other nodes and append them back so they are after (we allow arbitrary other widget nodes as field sub-nodes) List otherNodes = [] otherNodes.addAll(fieldSubNode.children) fieldSubNode.children.clear() for (MNode widgetChildNode in widgetTemplateNode.children) fieldSubNode.append(widgetChildNode.deepCopy(null)) for (MNode otherNode in otherNodes) fieldSubNode.append(otherNode) for (MNode setNode in setNodeList) fieldSubNode.append(setNode.deepCopy(null)) } } protected void addAutoWidgetServiceNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode, MNode widgetNode) { String serviceName = widgetNode.attribute("service-name") if (isDynamic) serviceName = ecfi.resourceFacade.expand(serviceName, "") ServiceDefinition serviceDef = ecfi.serviceFacade.getServiceDefinition(serviceName) if (serviceDef != null) { addAutoServiceField(serviceDef, widgetNode.attribute("parameter-name") ?: fieldNode.attribute("name"), widgetNode.attribute("field-type") ?: "edit", fieldNode, fieldSubNode, baseFormNode) return } if (serviceName.contains("#")) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(serviceName.substring(serviceName.indexOf("#")+1)) if (ed != null) { addAutoEntityField(ed, widgetNode.attribute("parameter-name")?:fieldNode.attribute("name"), widgetNode.attribute("field-type")?:"edit", fieldNode, fieldSubNode, baseFormNode) return } } throw new BaseArtifactException("Cound not find service [${serviceName}] or entity noun referred to in auto-fields-service of form [${baseFormNode.attribute("name")}] of screen [${sd.location}]") } void addAutoServiceField(ServiceDefinition sd, String parameterName, String fieldType, MNode newFieldNode, MNode subFieldNode, MNode baseFormNode) { EntityDefinition nounEd = null try { nounEd = ecfi.entityFacade.getEntityDefinition(sd.noun) } catch (EntityException e) { // ignore, anticipating there may be no entity def if (logger.isTraceEnabled()) logger.trace("Ignoring entity exception, not necessarily an entity name: ${e.toString()}") } MNode parameterNode = sd.serviceNode.first({ MNode it -> it.name == "in-parameters" && it.attribute("name") == parameterName }) if (parameterNode == null) throw new BaseArtifactException("Cound not find parameter [${parameterName}] in service [${sd.serviceName}] referred to in auto-widget-service of form [${baseFormNode.attribute("name")}] of screen [${sd.location}]") addAutoServiceField(nounEd, parameterNode, fieldType, sd.verb, newFieldNode, subFieldNode, baseFormNode) } protected void addAutoWidgetEntityNode(MNode baseFormNode, MNode fieldNode, MNode fieldSubNode, MNode widgetNode) { String entityName = widgetNode.attribute("entity-name") if (isDynamic) entityName = ecfi.resourceFacade.expand(entityName, "") EntityDefinition ed = null try { ed = ecfi.entityFacade.getEntityDefinition(entityName) } catch (EntityException e) { // ignore, anticipating there may be no entity def if (logger.isTraceEnabled()) logger.trace("Ignoring entity exception, not necessarily an entity name: ${e.toString()}") } if (ed == null) throw new BaseArtifactException("Cound not find entity [${entityName}] referred to in auto-widget-entity of form [${baseFormNode.attribute("name")}] of screen [${sd.location}]") addAutoEntityField(ed, widgetNode.attribute("field-name")?:fieldNode.attribute("name"), widgetNode.attribute("field-type")?:"find-display", fieldNode, fieldSubNode, baseFormNode) } protected static void mergeFormNodes(MNode baseFormNode, MNode overrideFormNode, boolean deepCopy, boolean copyFields) { if (overrideFormNode.attributes) baseFormNode.attributes.putAll(overrideFormNode.attributes) if (overrideFormNode.hasChild("entity-find")) { int efIndex = baseFormNode.firstIndex("entity-find") if (efIndex >= 0) baseFormNode.replace(efIndex, overrideFormNode.first("entity-find")) else baseFormNode.append(overrideFormNode.first("entity-find"), 0) } // if overrideFormNode has any row-actions add them all to the ones of the baseFormNode, ie both will run if (overrideFormNode.hasChild("row-actions")) { if (!baseFormNode.hasChild("row-actions")) baseFormNode.append("row-actions", null) MNode baseRowActionsNode = baseFormNode.first("row-actions") for (MNode actionNode in overrideFormNode.first("row-actions").children) baseRowActionsNode.append(actionNode) } if (overrideFormNode.hasChild("row-selection")) { if (!baseFormNode.hasChild("row-selection")) baseFormNode.append("row-selection", null) MNode baseRowSelNode = baseFormNode.first("row-selection") MNode overrideRowSelNode = overrideFormNode.first("row-selection") baseRowSelNode.attributes.putAll(overrideRowSelNode.attributes) for (MNode actionNode in overrideRowSelNode.children) baseRowSelNode.append(actionNode) } if (overrideFormNode.hasChild("hidden-parameters")) { int hpIndex = baseFormNode.firstIndex("hidden-parameters") if (hpIndex >= 0) baseFormNode.replace(hpIndex, overrideFormNode.first("hidden-parameters")) else baseFormNode.append(overrideFormNode.first("hidden-parameters")) } if (copyFields) { for (MNode overrideFieldNode in overrideFormNode.children("field")) mergeFieldNode(baseFormNode, overrideFieldNode, deepCopy) } if (overrideFormNode.hasChild("field-layout")) { // just use entire override field-layout, don't try to merge baseFormNode.remove("field-layout") baseFormNode.append(overrideFormNode.first("field-layout").deepCopy(null)) } if (overrideFormNode.hasChild("form-list-column")) { // if there are any form-list-column remove all from base and copy all from override baseFormNode.remove("form-list-column") for (MNode flcNode in overrideFormNode.children("form-list-column")) baseFormNode.append(flcNode.deepCopy(null)) } if (overrideFormNode.hasChild("columns")) { // each columns element by @type attribute overrides corresponding type for (MNode columnsNode in overrideFormNode.children("columns")) { String type = columnsNode.attribute("type") // remove base node children with matching type value baseFormNode.remove({ MNode it -> "columns".equals(it.name) && it.attribute("type") == type }) baseFormNode.append(columnsNode.deepCopy(null)) } } } protected static void mergeFieldNode(MNode baseFormNode, MNode overrideFieldNode, boolean deepCopy) { int baseFieldIndex = baseFormNode.firstIndex({ MNode it -> "field".equals(it.name) && it.attribute("name") == overrideFieldNode.attribute("name") }) if (baseFieldIndex >= 0) { MNode baseFieldNode = baseFormNode.child(baseFieldIndex) baseFieldNode.attributes.putAll(overrideFieldNode.attributes) baseFieldNode.mergeSingleChild(overrideFieldNode, "header-field") baseFieldNode.mergeSingleChild(overrideFieldNode, "first-row-field") baseFieldNode.mergeSingleChild(overrideFieldNode, "second-row-field") baseFieldNode.mergeChildrenByKey(overrideFieldNode, "conditional-field", "condition", null) baseFieldNode.mergeSingleChild(overrideFieldNode, "default-field") baseFieldNode.mergeSingleChild(overrideFieldNode, "last-row-field") // put new node where old was baseFormNode.remove(baseFieldIndex) baseFormNode.append(baseFieldNode, baseFieldIndex) } else { baseFormNode.append(deepCopy ? overrideFieldNode.deepCopy(null) : overrideFieldNode) // this is a new field... if the form has a field-layout element add a reference under that too if (baseFormNode.hasChild("field-layout")) addFieldToFieldLayout(baseFormNode, overrideFieldNode) } } static void addFieldToFieldLayout(MNode formNode, MNode fieldNode) { MNode fieldLayoutNode = formNode.first("field-layout") Integer layoutSequenceNum = fieldNode.attribute("layoutSequenceNum") as Integer if (layoutSequenceNum == null) { fieldLayoutNode.append("field-ref", [name:fieldNode.attribute("name")]) } else { formNode.remove("field-layout") MNode newFieldLayoutNode = formNode.append("field-layout", fieldLayoutNode.attributes) int index = 0 boolean addedNode = false for (MNode child in fieldLayoutNode.children) { if (index == layoutSequenceNum) { newFieldLayoutNode.append("field-ref", [name:fieldNode.attribute("name")]) addedNode = true } newFieldLayoutNode.append(child) index++ } if (!addedNode) { newFieldLayoutNode.append("field-ref", [name:fieldNode.attribute("name")]) } } } static LinkedHashMap getFieldOptions(MNode widgetNode, ExecutionContext ec) { MNode fieldNode = widgetNode.parent.parent LinkedHashMap options = new LinkedHashMap<>() ArrayList widgetChildren = widgetNode.children int widgetChildrenSize = widgetChildren.size() for (int wci = 0; wci < widgetChildrenSize; wci++) { MNode childNode = (MNode) widgetChildren.get(wci) if ("entity-options".equals(childNode.name)) { MNode entityFindNode = childNode.first("entity-find") EntityFind ef = ec.entity.find(entityFindNode) EntityList eli = ef.list() if (ef.shouldCache()) { // do the date filtering after the query ArrayList dateFilterList = entityFindNode.children("date-filter") int dateFilterListSize = dateFilterList.size() for (int k = 0; k < dateFilterListSize; k++) { MNode df = (MNode) dateFilterList.get(k) EntityCondition dateEc = ec.entity.conditionFactory.makeConditionDate(df.attribute("from-field-name") ?: "fromDate", df.attribute("thru-field-name") ?: "thruDate", (df.attribute("valid-date") ? ec.resource.expression(df.attribute("valid-date"), null) as Timestamp : ec.user.nowTimestamp)) // logger.warn("TOREMOVE getFieldOptions cache=${ef.getUseCache()}, dateEc=${dateEc} list before=${eli}") eli = eli.filterByCondition(dateEc, true) } } int eliSize = eli.size() for (int i = 0; i < eliSize; i++) { EntityValue ev = (EntityValue) eli.get(i) addFieldOption(options, fieldNode, childNode, ev, ec) } } else if ("list-options".equals(childNode.name)) { Object listObject = ec.resource.expression(childNode.attribute('list'), null) if (listObject instanceof EntityListIterator) { EntityListIterator eli try { eli = (EntityListIterator) listObject EntityValue ev while ((ev = eli.next()) != null) addFieldOption(options, fieldNode, childNode, ev, ec) } finally { eli.close() } } else { String keyAttr = childNode.attribute("key") String textAttr = childNode.attribute("text") for (Object listOption in listObject) { if (listOption instanceof Map) { addFieldOption(options, fieldNode, childNode, (Map) listOption, ec) } else { if (keyAttr != null || textAttr != null) { addFieldOption(options, fieldNode, childNode, [entry:listOption], ec) } else { String loString = ObjectUtilities.toPlainString(listOption) if (loString != null) options.put(loString, ec.l10n.localize(loString)) } } } } } else if ("option".equals(childNode.name)) { String key = childNode.attribute('key') if (key != null && key.contains('${')) key = ec.resource.expandNoL10n(key, null) String text = childNode.attribute('text') if (text != null) text = ec.resource.expand(text, null) options.put(key, text ?: ec.l10n.localize(key)) } } return options } static void addFieldOption(LinkedHashMap options, MNode fieldNode, MNode childNode, Map listOption, ExecutionContext ec) { EntityValueBase listOptionEvb = listOption instanceof EntityValueBase ? (EntityValueBase) listOption : (EntityValueBase) null if (listOptionEvb != null) { ec.context.push(listOptionEvb.getMap()) } else { ec.context.push(listOption) } try { String key = null String keyAttr = childNode.attribute('key') if (keyAttr != null && keyAttr.length() > 0) { key = ec.resource.expandNoL10n(keyAttr, null) // we just did a string expand, if it evaluates to a literal "null" then there was no value if (key == "null") key = null } else if (listOptionEvb != null) { String keyFieldName = listOptionEvb.getEntityDefinition().getPkFieldNames().get(0) if (keyFieldName != null && keyFieldName.length() > 0) key = ec.context.getByString(keyFieldName) } if (key == null) key = ec.context.getByString(fieldNode.attribute('name')) if (key == null) return String text = childNode.attribute('text') if (text == null || text.length() == 0) { if (listOptionEvb == null || listOptionEvb.getEntityDefinition().isField("description")) { Object desc = listOption.get("description") options.put(key, desc != null ? (String) desc : ec.l10n.localize(key)) } else { options.put(key, ec.l10n.localize(key)) } } else { String value = ec.resource.expand(text, null) if ("null".equals(value)) value = ec.l10n.localize(key) options.put(key, value) } } finally { ec.context.pop() } } // ========== FormInstance Class/etc ========== FormInstance getFormInstance() { if (isDynamic || hasDbExtensions || isDisplayOnly()) { return new FormInstance(this) } else { if (internalFormInstance == null) internalFormInstance = new FormInstance(this) return internalFormInstance } } @CompileStatic static class FormInstance { private ScreenForm screenForm private ExecutionContextFactoryImpl ecfi private MNode formNode private boolean isListForm = false protected Set serverStatic = null private ArrayList allFieldNodes private ArrayList allFieldNames private Map fieldNodeMap = new LinkedHashMap<>() private boolean isUploadForm = false private boolean isFormHeaderFormVal = false private boolean isFormFirstRowFormVal = false private boolean isFormSecondRowFormVal = false private boolean isFormLastRowFormVal = false private boolean hasFirstRow = false private boolean hasSecondRow = false private boolean hasLastRow = false private ArrayList nonReferencedFieldList = (ArrayList) null private ArrayList hiddenFieldList = (ArrayList) null private ArrayList hiddenFieldNameList = (ArrayList) null private Set hiddenFieldNameSet = (Set) null private ArrayList hiddenHeaderFieldList = (ArrayList) null private ArrayList hiddenFirstRowFieldList = (ArrayList) null private ArrayList hiddenSecondRowFieldList = (ArrayList) null private ArrayList hiddenLastRowFieldList = (ArrayList) null private HashMap>> formListColInfoListMap = (HashMap>>) null private boolean hasFieldHideAttrs = false boolean hasAggregate = false private String[] aggregateGroupFields = (String[]) null private AggregateField[] aggregateFields = (AggregateField[]) null private Map aggregateFieldMap = new HashMap<>() private HashMap showTotalFields = (HashMap) null private AggregationUtil aggregationUtil = (AggregationUtil) null FormInstance(ScreenForm screenForm) { this.screenForm = screenForm ecfi = screenForm.ecfi formNode = screenForm.getOrCreateFormNode() isListForm = "form-list".equals(formNode.getName()) String serverStaticStr = formNode.attribute("server-static") if (serverStaticStr) serverStatic = new HashSet(Arrays.asList(serverStaticStr.split(","))) else serverStatic = screenForm.sd.serverStatic allFieldNodes = formNode.children("field") int afnSize = allFieldNodes.size() allFieldNames = new ArrayList<>(afnSize) if (isListForm) { hiddenFieldList = new ArrayList<>() hiddenFieldNameList = new ArrayList<>() hiddenFieldNameSet = new HashSet<>() hiddenHeaderFieldList = new ArrayList<>() hiddenFirstRowFieldList = new ArrayList<>() hiddenSecondRowFieldList = new ArrayList<>() hiddenLastRowFieldList = new ArrayList<>() } // populate fieldNodeMap, get aggregation details ArrayList aggregateGroupFieldList = (ArrayList) null for (int i = 0; i < afnSize; i++) { MNode fieldNode = (MNode) allFieldNodes.get(i) String fieldName = fieldNode.attribute("name") fieldNodeMap.put(fieldName, fieldNode) allFieldNames.add(fieldName) if (isListForm) { if (isListFieldHiddenWidget(fieldNode)) { hiddenFieldList.add(fieldNode) if (!hiddenFieldNameSet.contains(fieldName)) { hiddenFieldNameList.add(fieldName) hiddenFieldNameSet.add(fieldName) } } MNode headerField = fieldNode.first("header-field") if (headerField != null && headerField.hasChild("hidden")) hiddenHeaderFieldList.add(fieldNode) MNode firstRowField = fieldNode.first("first-row-field") if (firstRowField != null && firstRowField.hasChild("hidden")) hiddenFirstRowFieldList.add(fieldNode) MNode secondRowField = fieldNode.first("second-row-field") if (secondRowField != null && secondRowField.hasChild("hidden")) hiddenSecondRowFieldList.add(fieldNode) MNode lastRowField = fieldNode.first("last-row-field") if (lastRowField != null && lastRowField.hasChild("hidden")) hiddenLastRowFieldList.add(fieldNode) if (fieldNode.attribute("hide")) hasFieldHideAttrs = true String showTotal = fieldNode.attribute("show-total") if ("false".equals(showTotal)) { showTotal = null } else if ("true".equals(showTotal)) { showTotal = "sum" } if (showTotal != null && !showTotal.isEmpty()) { if (showTotalFields == null) showTotalFields = new HashMap<>() showTotalFields.put(fieldName, showTotal) } String aggregate = fieldNode.attribute("aggregate") if (aggregate != null && !aggregate.isEmpty()) { hasAggregate = true boolean isGroupBy = "group-by".equals(aggregate) boolean isSubList = !isGroupBy && "sub-list".equals(aggregate) AggregateFunction af = (AggregateFunction) null if (!isGroupBy && !isSubList) { af = AggregateFunction.valueOf(aggregate.toUpperCase()) if (af == null) logger.error("Ignoring aggregate ${aggregate} on field ${fieldName} in form ${formNode.attribute('name')}, not a valid function, group-by, or sub-list") } aggregateFieldMap.put(fieldName, new AggregateField(fieldName, af, isGroupBy, isSubList, showTotal, ecfi.resourceFacade.getGroovyClass(fieldNode.attribute("from")))) if (isGroupBy) { if (aggregateGroupFieldList == null) aggregateGroupFieldList = new ArrayList<>() aggregateGroupFieldList.add(fieldName) } } else { aggregateFieldMap.put(fieldName, new AggregateField(fieldName, null, false, false, showTotal, ecfi.resourceFacade.getGroovyClass(fieldNode.attribute("from")))) } } } // check aggregate defs if (hasAggregate) { if (aggregateGroupFieldList == null) { throw new BaseArtifactException("Form ${formNode.attribute('name')} has aggregate fields but no group-by field, must have at least one") } else { // make group fields array int groupFieldSize = aggregateGroupFieldList.size() aggregateGroupFields = new String[groupFieldSize] for (int i = 0; i < groupFieldSize; i++) aggregateGroupFields[i] = (String) aggregateGroupFieldList.get(i) } } // make AggregateField array for all fields aggregateFields = new AggregateField[afnSize] for (int i = 0; i < afnSize; i++) { String fieldName = (String) allFieldNames.get(i) AggregateField aggField = (AggregateField) aggregateFieldMap.get(fieldName) if (aggField == null) { MNode fieldNode = fieldNodeMap.get(fieldName) aggField = new AggregateField(fieldName, null, false, false, showTotalFields?.get(fieldName), ecfi.resourceFacade.getGroovyClass(fieldNode.attribute("from"))) } aggregateFields[i] = aggField } aggregationUtil = new AggregationUtil(formNode.attribute("list"), formNode.attribute("list-entry"), aggregateFields, aggregateGroupFields, screenForm.rowActions) // determine isUploadForm and isFormHeaderFormVal isUploadForm = formNode.depthFirst({ MNode it -> "file".equals(it.name) }).size() > 0 for (MNode hfNode in formNode.depthFirst({ MNode it -> "header-field".equals(it.name) })) { if (hfNode.children.size() > 0) { isFormHeaderFormVal = true; break } } // determine hasFirstRow, isFormFirstRowFormVal, hasLastRow, isFormLastRowFormVal for (MNode rfNode in formNode.depthFirst({ MNode it -> "first-row-field".equals(it.name) })) { if (rfNode.children.size() > 0) { hasFirstRow = true; break } } if (hasFirstRow && formNode.attribute("transition-first-row")) isFormFirstRowFormVal = true for (MNode rfNode in formNode.depthFirst({ MNode it -> "second-row-field".equals(it.name) })) { if (rfNode.children.size() > 0) { hasSecondRow = true; break } } if (hasSecondRow && formNode.attribute("transition-second-row")) isFormSecondRowFormVal = true for (MNode rfNode in formNode.depthFirst({ MNode it -> "last-row-field".equals(it.name) })) { if (rfNode.children.size() > 0) { hasLastRow = true; break } } if (hasLastRow && formNode.attribute("transition-last-row")) isFormLastRowFormVal = true // also populate fieldsInFormListColumns if (isListForm) { formListColInfoListMap = new HashMap<>() // iterate through columns elements and populate for each type for (MNode columnsNode in formNode.children("columns")) { String type = columnsNode.attribute("type") if (type != null && !type.isEmpty()) formListColInfoListMap.put(type, makeFormListColumnInfo(type)) } // always populate for null (default) type formListColInfoListMap.put(null, makeFormListColumnInfo(null)) } } MNode getFormNode() { formNode } MNode getRowSelectionNode() { formNode.first("row-selection") } MNode getFieldNode(String fieldName) { fieldNodeMap.get(fieldName) } String getFormLocation() { screenForm.location } String getSavedFindFullLocation() { screenForm.getSavedFindFullLocation() } FormListRenderInfo makeFormListRenderInfo() { new FormListRenderInfo(this) } boolean isUpload() { isUploadForm } boolean isList() { isListForm } boolean isServerStatic(String renderMode) { return serverStatic != null && (serverStatic.contains('all') || serverStatic.contains(renderMode)) } MNode getFieldValidateNode(MNode subFieldNode) { MNode fieldNode = subFieldNode.getParent() String fieldName = fieldNode.attribute("name") String validateService = subFieldNode.attribute('validate-service') String validateEntity = subFieldNode.attribute('validate-entity') if (validateService) { ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(validateService) if (sd == null) throw new BaseArtifactException("Invalid validate-service name [${validateService}] in field [${fieldName}] of form [${screenForm.location}]") MNode parameterNode = sd.getInParameter((String) subFieldNode.attribute('validate-parameter') ?: fieldName) return parameterNode } else if (validateEntity) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(validateEntity) if (ed == null) throw new BaseArtifactException("Invalid validate-entity name [${validateEntity}] in field [${fieldName}] of form [${screenForm.location}]") MNode efNode = ed.getFieldNode((String) subFieldNode.attribute('validate-field') ?: fieldName) return efNode } return null } String getFieldValidationClasses(MNode subFieldNode) { MNode validateNode = getFieldValidateNode(subFieldNode) if (validateNode == null) return "" Set vcs = new HashSet() if (validateNode.name == "parameter") { MNode parameterNode = validateNode if (parameterNode.attribute('required') == "true") vcs.add("required") if (parameterNode.hasChild("number-integer")) vcs.add("number") if (parameterNode.hasChild("number-decimal")) vcs.add("number") if (parameterNode.hasChild("text-email")) vcs.add("email") if (parameterNode.hasChild("text-url")) vcs.add("url") if (parameterNode.hasChild("text-digits")) vcs.add("digits") if (parameterNode.hasChild("credit-card")) vcs.add("creditcard") String type = parameterNode.attribute('type') if (type !=null && (type.endsWith("BigDecimal") || type.endsWith("BigInteger") || type.endsWith("Long") || type.endsWith("Integer") || type.endsWith("Double") || type.endsWith("Float") || type.endsWith("Number"))) vcs.add("number") } else if (validateNode.name == "field") { MNode fieldNode = validateNode String type = fieldNode.attribute('type') if (type != null && (type.startsWith("number-") || type.startsWith("currency-"))) vcs.add("number") // bad idea, for create forms with optional PK messes it up: if (fieldNode."@is-pk" == "true") vcs.add("required") } StringBuilder sb = new StringBuilder() for (String vc in vcs) { if (sb) sb.append(" "); sb.append(vc); } return sb.toString() } Map getFieldValidationRegexpInfo(MNode subFieldNode) { MNode validateNode = getFieldValidateNode(subFieldNode) if (validateNode?.hasChild("matches")) { MNode matchesNode = validateNode.first("matches") return [regexp:matchesNode.attribute('regexp'), message:matchesNode.attribute('message')] } return null } static String MSG_REQUIRED = "Please enter a value" static String MSG_NUMBER = "Please enter a valid number" static String MSG_NUMBER_INT = "Please enter a valid whole number" static String MSG_DIGITS = "Please enter only numbers (digits)" static String MSG_LETTERS = "Please enter only letters" static String MSG_EMAIL = "Please enter a valid email address" static String MSG_URL = "Please enter a valid URL" static String VALIDATE_NUMBER = '!value||$root.moqui.isStringNumber(value)' static String VALIDATE_NUMBER_INT = '!value||$root.moqui.isStringInteger(value)' ArrayList> getFieldValidationJsRules(MNode subFieldNode) { MNode validateNode = getFieldValidateNode(subFieldNode) if (validateNode == null) return null ExecutionContextImpl eci = ecfi.getEci() ArrayList> ruleList = new ArrayList<>(5) if (validateNode.name == "parameter") { if ("true".equals(validateNode.attribute('required'))) ruleList.add([expr:"!!value", message:eci.l10nFacade.localize(MSG_REQUIRED)]) boolean foundNumber = false ArrayList children = validateNode.getChildren() int childrenSize = children.size() for (int i = 0; i < childrenSize; i++) { MNode child = (MNode) children.get(i) if ("number-integer".equals(child.getName())) { if (!foundNumber) { ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)]) foundNumber = true } } else if ("number-decimal".equals(child.getName())) { if (!foundNumber) { ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)]) foundNumber = true } } else if ("text-digits".equals(child.getName())) { if (!foundNumber) { ruleList.add([expr:'!value || /^\\d*$/.test(value)', message:eci.l10nFacade.localize(MSG_DIGITS)]) foundNumber = true } } else if ("text-letters".equals(child.getName())) { // TODO: how to handle UTF-8 letters? ruleList.add([expr:'!value || /^[a-zA-Z]*$/.test(value)', message:eci.l10nFacade.localize(MSG_LETTERS)]) } else if ("text-email".equals(child.getName())) { // from https://emailregex.com/ - could be looser/simpler for this purpose ruleList.add([expr:'!value || /^(([^<>()\\[\\]\\\\.,;:\\s@"]+(\\.[^<>()\\[\\]\\\\.,;:\\s@"]+)*)|(".+"))@((\\[[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}\\.[0-9]{1,3}])|(([a-zA-Z\\-0-9]+\\.)+[a-zA-Z]{2,}))$/.test(value)', message:eci.l10nFacade.localize(MSG_EMAIL)]) } else if ("text-url".equals(child.getName())) { // from https://urlregex.com/ - could be looser/simpler for this purpose ruleList.add([expr:'!value || /((([A-Za-z]{3,9}:(?:\\/\\/)?)(?:[\\-;:&=\\+\\$,\\w]+@)?[A-Za-z0-9\\.\\-]+|(?:www\\.|[\\-;:&=\\+\\$,\\w]+@)[A-Za-z0-9\\.\\-]+)((?:\\/[\\+~%\\/\\.\\w\\-_]*)?\\??(?:[\\-\\+=&;%@\\.\\w_]*)#?(?:[\\.\\!\\/\\\\\\w]*))?)/.test(value)', message:eci.l10nFacade.localize(MSG_URL)]) } else if ("matches".equals(child.getName())) { ruleList.add([expr:'!value || /' + child.attribute("regexp") + '/.test(value)', message:eci.l10nFacade.localize(child.attribute("message"))]) } else if ("number-range".equals(child.getName())) { String minStr = child.attribute("min") String maxStr = child.attribute("max") boolean minEquals = !"false".equals(child.attribute("min-include-equals")) boolean maxEquals = "true".equals(child.attribute("max-include-equals")) String message = child.attribute("message") if (message == null || message.isEmpty()) { if (minStr && maxStr) message = "Enter a number between ${minStr} and ${maxStr}" else if (minStr) message = "Enter a number greater than ${minStr}" else if (maxStr) message = "Enter a number less than ${maxStr}" } String compareStr = ""; if (minStr) compareStr += ' && $root.moqui.parseNumber(value) ' + (minEquals ? '>= ' : '> ') + minStr if (maxStr) compareStr += ' && $root.moqui.parseNumber(value) ' + (maxEquals ? '<= ' : '< ') + maxStr ruleList.add([expr:'!value || (!Number.isNaN($root.moqui.parseNumber(value))' + compareStr + ')', message:message]) } } // TODO: val-or, val-and, val-not // TODO: text-letters, time-range // TODO: credit-card with types? // fallback to type attribute for numbers String type = validateNode.attribute('type') if (!foundNumber && type != null) { if (type.endsWith("BigInteger") || type.endsWith("Long") || type.endsWith("Integer")) { ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)]) } else if (type.endsWith("BigDecimal") || type.endsWith("Double") || type.endsWith("Float") || type.endsWith("Number")) { ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)]) } } } else if (validateNode.name == "field") { String type = validateNode.attribute('type') if (type != null && (type.startsWith("number-") || type.startsWith("currency-"))) { if (type.endsWith("integer")) { ruleList.add([expr:VALIDATE_NUMBER_INT, message:eci.l10nFacade.localize(MSG_NUMBER_INT)]) } else { ruleList.add([expr:VALIDATE_NUMBER, message:eci.l10nFacade.localize(MSG_NUMBER)]) } } // bad idea, for create forms with optional PK messes it up: if (fieldNode."@is-pk" == "true") vcs.add("required") } return ruleList.size() > 0 ? ruleList : null } ArrayList getFieldLayoutNonReferencedFieldList() { if (nonReferencedFieldList != null) return nonReferencedFieldList ArrayList fieldList = new ArrayList<>() if (formNode.hasChild("field-layout")) for (MNode fieldNode in formNode.children("field")) { MNode fieldLayoutNode = formNode.first("field-layout") String fieldName = fieldNode.attribute("name") if (!fieldLayoutNode.depthFirst({ MNode it -> it.name == "field-ref" && it.attribute("name") == fieldName })) fieldList.add(fieldNodeMap.get(fieldName)) } nonReferencedFieldList = fieldList return fieldList } static boolean isHeaderSubmitField(MNode fieldNode) { MNode headerField = fieldNode.first("header-field") if (headerField == null) return false return headerField.hasChild("submit") } boolean isListFieldHiddenAttr(MNode fieldNode) { String hideAttr = fieldNode.attribute("hide") if (hideAttr != null && hideAttr.length() > 0) { return ecfi.getEci().resource.condition(hideAttr, "") } return false } static boolean isListFieldHiddenWidget(MNode fieldNode) { // if default-field or any conditional-field don't have hidden or ignored elements then it's not hidden MNode defaultField = fieldNode.first("default-field") if (defaultField != null && !defaultField.hasChild("hidden") && !defaultField.hasChild("ignored")) return false List condFieldList = fieldNode.children("conditional-field") for (MNode condField in condFieldList) if (!condField.hasChild("hidden") && !condField.hasChild("ignored")) return false return true } ArrayList getListHiddenFieldList() { return hiddenFieldList } ArrayList getListHiddenFieldNameList() { return hiddenFieldNameList } Set getListHiddenFieldNameSet() { return hiddenFieldNameSet } boolean hasFormListColumns() { return formNode.hasChild("form-list-column") || formNode.hasChild("columns") } String getUserActiveFormConfigId(ExecutionContext ec) { String columnsType = ecfi.getEci().contextStack.getByString("_uiType") if (columnsType != null && columnsType.isEmpty()) columnsType = null if (columnsType != null) { // look up Enumeration record by enumCode (_uiType value) and enumTypeId to get enumId String configTypeEnumId = ecfi.entityFacade.find("moqui.basic.Enumeration") .condition("enumTypeId", "FormConfigType").condition("enumCode", columnsType) .useCache(true).one()?.get("enumId") if (configTypeEnumId != null) { EntityValue fcut = ecfi.entityFacade.fastFindOne("moqui.screen.form.FormConfigUserType", true, false, screenForm.location, ec.user.userId, configTypeEnumId) if (fcut != null) return (String) fcut.getNoCheckSimple("formConfigId") } // if a columnsType is specified and there is no matching saved FormConfig then don't default to saved general config, // defer to screen def columns config by type or screen def default columns config, so return null return null } EntityValue fcu = ecfi.entityFacade.fastFindOne("moqui.screen.form.FormConfigUser", true, false, screenForm.location, ec.user.userId) if (fcu != null) return (String) fcu.getNoCheckSimple("formConfigId") // Maybe not do this at all and let it be a future thing where the user selects an active one from options available through groups EntityList fcugvList = ecfi.entityFacade.find("moqui.screen.form.FormConfigUserGroupView") .condition("userGroupId", EntityCondition.IN, ec.user.userGroupIdSet) .condition("formLocation", screenForm.location).useCache(true).list() if (fcugvList.size() > 0) { // FUTURE: somehow make a better choice than just the first? see note above too... return (String) fcugvList.get(0).getNoCheckSimple("formConfigId") } return null } EntityValue getActiveFormListFind(ExecutionContextImpl ec) { String formListFindId = (String) ec.contextStack.get("formListFindId") if (formListFindId == null || formListFindId.isEmpty()) return null EntityValue formListFind = ec.entityFacade.fastFindOne("moqui.screen.form.FormListFind", true, false, formListFindId) // see if this applies to this form-list, may be multiple on the screen String fullLocation = screenForm.getSavedFindFullLocation() if (formListFind != null && fullLocation != formListFind.get("formLocation")) formListFind = null return formListFind } ArrayList> getFormListColumnInfo() { ExecutionContextImpl eci = ecfi.getEci() String formConfigId = (String) null EntityValue activeFormListFind = getActiveFormListFind(eci) if (activeFormListFind != null) formConfigId = activeFormListFind.getNoCheckSimple("formConfigId") if (formConfigId == null || formConfigId.isEmpty()) formConfigId = getUserActiveFormConfigId(eci) if (formConfigId != null && !formConfigId.isEmpty()) { // don't remember the results of this, is per-user so good only once (FormInstance is NOT per user!) return makeDbFormListColumnInfo(formConfigId, eci) } String columnsType = ecfi.getEci().contextStack.getByString("_uiType") if (columnsType != null && columnsType.isEmpty()) columnsType == null if (formListColInfoListMap.containsKey(columnsType)) { return formListColInfoListMap.get(columnsType) } else { return formListColInfoListMap.get(null) } } /** convert form-list-column elements into a list, if there are no form-list-column elements uses fields limiting * by logic about what actually gets rendered (so result can be used for display regardless of form def) */ private ArrayList> makeFormListColumnInfo(String columnsType) { ArrayList formListColumnList = (ArrayList) null ArrayList columnsNodeList = formNode.children("columns") int columnsNodesSize = columnsNodeList.size() // look for matching columns by specified type first if (columnsType != null && !columnsType.isEmpty()) { for (int i = 0; i < columnsNodesSize; i++) { MNode columnsNode = (MNode) columnsNodeList.get(i) if (columnsType.equals(columnsNode.attribute("type"))) { formListColumnList = columnsNode.children("column") break } } } // if nothing found (or no columnsType) look for columns with no type if (formListColumnList == null) { for (int i = 0; i < columnsNodesSize; i++) { MNode columnsNode = (MNode) columnsNodeList.get(i) String type = columnsNode.attribute("type") if (type == null || type.isEmpty()) { formListColumnList = columnsNode.children("column") break } } } // default to old form-list-column elements if (formListColumnList == null) formListColumnList = formNode.children("form-list-column") int flcListSize = formListColumnList != null ? formListColumnList.size() : 0 ArrayList> colInfoList = new ArrayList<>() if (flcListSize > 0) { // populate fields under columns for (int ci = 0; ci < flcListSize; ci++) { MNode flcNode = (MNode) formListColumnList.get(ci) ArrayList colFieldNodes = new ArrayList<>() ArrayList fieldRefNodes = flcNode.children("field-ref") int fieldRefSize = fieldRefNodes.size() for (int fi = 0; fi < fieldRefSize; fi++) { MNode frNode = (MNode) fieldRefNodes.get(fi) String fieldName = frNode.attribute("name") MNode fieldNode = (MNode) fieldNodeMap.get(fieldName) if (fieldNode == null) throw new BaseArtifactException("Could not find field ${fieldName} referenced in form-list-column.field-ref in form at ${screenForm.location}") // skip hidden fields, they are handled separately if (isListFieldHiddenWidget(fieldNode)) continue colFieldNodes.add(fieldNode) } if (colFieldNodes.size() > 0) colInfoList.add(colFieldNodes) } } else { // create a column for each displayed field int afnSize = allFieldNodes.size() for (int i = 0; i < afnSize; i++) { MNode fieldNode = (MNode) allFieldNodes.get(i) // skip hidden fields, they are handled separately if (isListFieldHiddenWidget(fieldNode)) continue ArrayList singleFieldColList = new ArrayList<>() singleFieldColList.add(fieldNode) colInfoList.add(singleFieldColList) } } return colInfoList } private ArrayList> makeDbFormListColumnInfo(String formConfigId, ExecutionContextImpl eci) { EntityList formConfigFieldList = ecfi.entityFacade.find("moqui.screen.form.FormConfigField") .condition("formConfigId", formConfigId).orderBy("positionIndex").orderBy("positionSequence").useCache(true).list() // NOTE: calling code checks to see if this is not empty int fcfListSize = formConfigFieldList.size() ArrayList> colInfoList = new ArrayList<>() Set tempFieldsInFormListColumns = new HashSet() // populate fields under columns int curColIndex = -1; ArrayList colFieldNodes = null for (int ci = 0; ci < fcfListSize; ci++) { EntityValue fcfValue = (EntityValue) formConfigFieldList.get(ci) int columnIndex = fcfValue.getNoCheckSimple("positionIndex") as int if (columnIndex > curColIndex) { if (colFieldNodes != null && colFieldNodes.size() > 0) colInfoList.add(colFieldNodes) curColIndex = columnIndex colFieldNodes = new ArrayList<>() } String fieldName = (String) fcfValue.getNoCheckSimple("fieldName") MNode fieldNode = (MNode) fieldNodeMap.get(fieldName) if (fieldNode == null) { //throw new BaseArtifactException("Could not find field ${fieldName} referenced in FormConfigField record for ID ${fcfValue.formConfigId} user ${eci.user.userId}, form at ${screenForm.location}") logger.warn("Could not find field ${fieldName} referenced in FormConfigField record for ID ${fcfValue.formConfigId} user ${eci.user.userId}, form at ${screenForm.location}. removing it") fcfValue.delete() continue } // skip hidden fields, they are handled separately if (isListFieldHiddenWidget(fieldNode)) continue tempFieldsInFormListColumns.add(fieldName) colFieldNodes.add(fieldNode) } // Add the final field (if defined) if (colFieldNodes != null && colFieldNodes.size() > 0) colInfoList.add(colFieldNodes) return colInfoList } ArrayList makeFormListFindFields(String formListFindId, ExecutionContext ec) { ContextStack cs = ec.context Set skipSet = null MNode entityFindNode = screenForm.entityFindNode if (entityFindNode != null) { MNode sfiNode = entityFindNode.first("search-form-inputs") String skipFields = sfiNode?.attribute("skip-fields") if (skipFields != null && !skipFields.isEmpty()) skipSet = new HashSet<>(Arrays.asList(skipFields.split(",")).collect({ it.trim() })) } List valueList = new ArrayList<>() for (MNode fieldNode in allFieldNodes) { // skip submit if (isHeaderSubmitField(fieldNode)) continue String fn = fieldNode.attribute("name") if (skipSet != null && skipSet.contains(fn)) continue if (cs.containsKey(fn) || cs.containsKey(fn + "_op")) { // this will handle text-line, text-find, etc Object value = cs.get(fn) if (value != null && ObjectUtilities.isEmpty(value)) value = null String op = cs.get(fn + "_op") ?: "equals" boolean not = (cs.get(fn + "_not") == "Y" || cs.get(fn + "_not") == "true") boolean ic = (cs.get(fn + "_ic") == "Y" || cs.get(fn + "_ic") == "true") // for all operators other than empty skip this if there is no value if (value == null && op != "empty") continue EntityValue ev = ec.entity.makeValue("moqui.screen.form.FormListFindField") ev.formListFindId = formListFindId ev.fieldName = fn ev.fieldValue = value ev.fieldOperator = op ev.fieldNot = not ? "Y" : "N" ev.fieldIgnoreCase = ic ? "Y" : "N" valueList.add(ev) } else if (cs.get(fn + "_period")) { EntityValue ev = ec.entity.makeValue("moqui.screen.form.FormListFindField") ev.formListFindId = formListFindId ev.fieldName = fn ev.fieldPeriod = cs.get(fn + "_period") ev.fieldPerOffset = (cs.get(fn + "_poffset") ?: "0") as Long valueList.add(ev) } else { // these will handle range-find and date-find String fromValue = ObjectUtilities.toPlainString(cs.get(fn + "_from")) String thruValue = ObjectUtilities.toPlainString(cs.get(fn + "_thru")) if (fromValue || thruValue) { EntityValue ev = ec.entity.makeValue("moqui.screen.form.FormListFindField") ev.formListFindId = formListFindId ev.fieldName = fn ev.fieldFrom = fromValue ev.fieldThru = thruValue valueList.add(ev) } } } /* always look for an orderByField parameter too String orderByString = cs?.get("orderByField") ?: defaultOrderBy if (orderByString != null && orderByString.length() > 0) { ec.context.put("orderByField", orderByString) this.orderBy(orderByString) } */ return valueList } } @CompileStatic static class FormListRenderInfo { private final FormInstance formInstance private final ScreenForm screenForm private ExecutionContextFactoryImpl ecfi private ArrayList> allColInfo private ArrayList> mainColInfo = (ArrayList>) null private ArrayList> subColInfo = (ArrayList>) null private LinkedHashSet displayedFieldSet FormListRenderInfo(FormInstance formInstance) { this.formInstance = formInstance screenForm = formInstance.screenForm ecfi = formInstance.ecfi // NOTE: this can be different for each form rendering depending on user settings allColInfo = formInstance.getFormListColumnInfo() if (formInstance.hasFieldHideAttrs) { int tempAciSize = allColInfo.size() ArrayList> newColInfo = new ArrayList<>(tempAciSize) for (int oi = 0; oi < tempAciSize; oi++) { ArrayList innerList = (ArrayList) allColInfo.get(oi) if (innerList == null) continue int innerSize = innerList.size() ArrayList newInnerList = new ArrayList<>(innerSize) for (int ii = 0; ii < innerSize; ii++) { MNode fieldNode = (MNode) innerList.get(ii) if (!formInstance.isListFieldHiddenAttr(fieldNode)) newInnerList.add(fieldNode) } if (newInnerList.size() > 0) newColInfo.add(newInnerList) } allColInfo = newColInfo } // make a set of fields actually displayed displayedFieldSet = new LinkedHashSet<>() int outerSize = allColInfo.size() for (int oi = 0; oi < outerSize; oi++) { ArrayList innerList = (ArrayList) allColInfo.get(oi) if (innerList == null) { logger.warn("Null column field list at index ${oi} in form ${screenForm.location}"); continue } int innerSize = innerList.size() for (int ii = 0; ii < innerSize; ii++) { MNode fieldNode = (MNode) innerList.get(ii) if (fieldNode != null) displayedFieldSet.add(fieldNode.attribute("name")) } } if (formInstance.hasAggregate) { subColInfo = new ArrayList<>() int flciSize = allColInfo.size() mainColInfo = new ArrayList<>(flciSize) for (int i = 0; i < flciSize; i++) { ArrayList fieldList = (ArrayList) allColInfo.get(i) ArrayList newFieldList = new ArrayList<>() ArrayList subFieldList = (ArrayList) null int fieldListSize = fieldList.size() for (int fi = 0; fi < fieldListSize; fi++) { MNode fieldNode = (MNode) fieldList.get(fi) String fieldName = fieldNode.attribute("name") AggregateField aggField = formInstance.aggregateFieldMap.get(fieldName) if (aggField != null && aggField.subList) { if (subFieldList == null) subFieldList = new ArrayList<>() subFieldList.add(fieldNode) } else { newFieldList.add(fieldNode) } } // if fieldList is not empty add to tempFormListColInfo if (newFieldList.size() > 0) mainColInfo.add(newFieldList) if (subFieldList != null) subColInfo.add(subFieldList) } } } MNode getFormNode() { return formInstance.formNode } MNode getFieldNode(String fieldName) { return formInstance.fieldNodeMap.get(fieldName) } boolean isHeaderForm() { return formInstance.isFormHeaderFormVal } boolean isFirstRowForm() { return formInstance.isFormFirstRowFormVal } boolean isSecondRowForm() { return formInstance.isFormSecondRowFormVal } boolean isLastRowForm() { return formInstance.isFormLastRowFormVal } boolean hasFirstRow() { return formInstance.hasFirstRow } boolean hasSecondRow() { return formInstance.hasSecondRow } boolean hasLastRow() { return formInstance.hasLastRow } String getFormLocation() { return screenForm.location } String getSavedFindFullLocation() { return screenForm.getSavedFindFullLocation() } List> getUserFormListFinds(ExecutionContextImpl ec) { return screenForm.getUserFormListFinds(ec) } String getUserDefaultFormListFindId(ExecutionContextImpl ec) { return screenForm.getUserDefaultFormListFindId(ec) } FormInstance getFormInstance() { return formInstance } ScreenForm getScreenForm() { return screenForm } ArrayList> getAllColInfo() { return allColInfo } ArrayList> getMainColInfo() { return mainColInfo ?: allColInfo } ArrayList> getSubColInfo() { return subColInfo } ArrayList getListHiddenFieldList() { return formInstance.getListHiddenFieldList() } ArrayList getListHeaderHiddenFieldList() { return formInstance.hiddenHeaderFieldList } ArrayList getListFirstRowHiddenFieldList() { return formInstance.hiddenFirstRowFieldList } ArrayList getListSecondRowHiddenFieldList() { return formInstance.hiddenSecondRowFieldList } ArrayList getListLastRowHiddenFieldList() { return formInstance.hiddenLastRowFieldList } LinkedHashSet getDisplayedFields() { return displayedFieldSet } ArrayList> getListObject(boolean aggregateList) { ContextStack context = ecfi.getEci().contextStack Object listObject String listName = formInstance.formNode.attribute("list") Set includeFields = new HashSet<>(displayedFieldSet) MNode entityFindNode = screenForm.entityFindNode if (entityFindNode != null) { EntityFindBase ef = (EntityFindBase) ecfi.entityFacade.find(entityFindNode) // don't do this, use explicit select-field fields plus display/hidden fields: if (ef.getSelectFields() == null || ef.getSelectFields().size() == 0) { // always do this even if there are some entity-find.select-field elements, support specifying some fields that are always selected for (String fieldName in displayedFieldSet) ef.selectField(fieldName) List selFields = ef.getSelectFields() // don't order by fields not in displayedFieldSet ArrayList orderByFields = ef.orderByFields if (orderByFields != null) for (int i = 0; i < orderByFields.size(); ) { String obfString = (String) orderByFields.get(i) EntityJavaUtil.FieldOrderOptions foo = EntityJavaUtil.makeFieldOrderOptions(obfString) if (displayedFieldSet.contains(foo.fieldName) || selFields.contains(foo.fieldName)) { i++ } else { orderByFields.remove(i) } } // always select hidden fields ArrayList hiddenNames = formInstance.getListHiddenFieldNameList() int hiddenNamesSize = hiddenNames.size() for (int i = 0; i < hiddenNamesSize; i++) { String fn = (String) hiddenNames.get(i) MNode fieldNode = formInstance.getFieldNode(fn) if (!fieldNode.hasChild("default-field")) continue ef.selectField(fn) includeFields.add(fn) } // logger.warn("TOREMOVE form-list.entity-find: ${ef.toString()}\ndisplayedFieldSet: ${displayedFieldSet}") // run the query EntityList efList = ef.list() // if cached do the date filter after query boolean useCache = ef.shouldCache() if (useCache) for (MNode df in entityFindNode.children("date-filter")) { Timestamp validDate = (Timestamp) null String validDateAttr = df.attribute("valid-date") if (validDateAttr != null && !validDateAttr.isEmpty()) validDate = ecfi.resourceFacade.expression(validDateAttr, "") as Timestamp efList.filterByDate(df.attribute("from-field-name") ?: "fromDate", df.attribute("thru-field-name") ?: "thruDate", validDate, "true".equals(df.attribute("ignore-if-empty"))) } // put in context for external use context.put(listName, efList) context.put(listName.concat("_xafind"), ef) // handle pagination, etc parameters like XML Actions entity-find MNode sfiNode = entityFindNode.first("search-form-inputs") boolean doPaginate = sfiNode != null && !"false".equals(sfiNode.attribute("paginate")) if (doPaginate) { long count, pageSize, pageIndex if (ef.getLimit() == null) { count = efList.size() pageSize = count > 20 ? count : 20 pageIndex = efList.getPageIndex() } else if (useCache) { count = efList.size() efList.filterByLimit(sfiNode.attribute("input-fields-map"), true) pageSize = efList.getPageSize() pageIndex = efList.getPageIndex() } else { pageIndex = ef.pageIndex pageSize = ef.pageSize // this can be expensive, only get count if efList size is equal to pageSize (can skip if no paginate needed) if (efList.size() < pageSize) count = efList.size() + pageSize * pageIndex else count = ef.count() } long maxIndex = (new BigDecimal(count-1)).divide(new BigDecimal(pageSize), 0, RoundingMode.DOWN).longValue() long pageRangeLow = (pageIndex * pageSize) + 1 long pageRangeHigh = (pageIndex * pageSize) + pageSize if (pageRangeHigh > count) pageRangeHigh = count // logger.info("count ${count} pageSize ${pageSize} maxIndex ${maxIndex} pageRangeLow ${pageRangeLow} pageRangeHigh ${pageRangeHigh}") context.put(listName.concat("Count"), count) context.put(listName.concat("PageIndex"), pageIndex) context.put(listName.concat("PageSize"), pageSize) context.put(listName.concat("PageMaxIndex"), maxIndex) context.put(listName.concat("PageRangeLow"), pageRangeLow) context.put(listName.concat("PageRangeHigh"), pageRangeHigh) } listObject = efList } else { listObject = ecfi.resourceFacade.expression(listName, "") } // NOTE: always call AggregationUtil.aggregateList, passing aggregateList to tell it to do sub-lists or not // this does the pre-processing for all form-list renders, handles row-actions, field.@from, etc ArrayList> aggList = formInstance.aggregationUtil.aggregateList(listObject, includeFields, aggregateList, ecfi.getEci()) // set _formListRendered and _formListResultCount so code running later on knows what happened during the screen render context.getSharedMap().put("_formListRendered", true) int aggListSize = aggList.size() Object curResultCount = context.getSharedMap().get("_formListResultCount") if (curResultCount instanceof Number) aggListSize += ((Number) curResultCount).intValue() context.getSharedMap().put("_formListResultCount", aggListSize) return aggList } String getOrderByActualJsString(String originalOrderBy) { if (originalOrderBy == null || originalOrderBy.length() == 0) return ""; // strip square braces if there are any if (originalOrderBy.startsWith("[")) originalOrderBy = originalOrderBy.substring(1, originalOrderBy.length() - 1) originalOrderBy = originalOrderBy.replace(" ", "") List orderByList = Arrays.asList(originalOrderBy.split(",")) StringBuilder sb = new StringBuilder() for (String obf in orderByList) { if (sb.length() > 0) sb.append(",") EntityJavaUtil.FieldOrderOptions foo = EntityJavaUtil.makeFieldOrderOptions(obf) MNode curFieldNode = formInstance.fieldNodeMap.get(foo.getFieldName()) if (curFieldNode == null) continue MNode headerFieldNode = curFieldNode.first("header-field") if (headerFieldNode == null) continue String showOrderBy = headerFieldNode.attribute("show-order-by") sb.append("'").append(foo.descending ? "-" : "") if ("case-insensitive".equals(showOrderBy)) sb.append("^") sb.append(foo.getFieldName()).append("'") } if (sb.length() == 0) return "" return "[" + sb.toString() + "]" } ArrayList getFieldsNotReferencedInFormListColumn() { ArrayList colFieldNodes = new ArrayList<>() ArrayList allFieldNodes = formInstance.allFieldNodes int afnSize = allFieldNodes.size() for (int i = 0; i < afnSize; i++) { MNode fieldNode = (MNode) allFieldNodes.get(i) // skip hidden fields, they are handled separately if (formInstance.isListFieldHiddenWidget(fieldNode) || (formInstance.hasFieldHideAttrs && formInstance.isListFieldHiddenAttr(fieldNode))) continue String fieldName = fieldNode.attribute("name") if (!displayedFieldSet.contains(fieldName)) colFieldNodes.add(formInstance.fieldNodeMap.get(fieldName)) } return colFieldNodes } ArrayList getFormListColumnCharWidths(int originalLineWidth) { int numCols = allColInfo.size() ArrayList charWidths = new ArrayList<>(numCols) for (int i = 0; i < numCols; i++) charWidths.add(null) if (originalLineWidth == 0) originalLineWidth = 132 int lineWidth = originalLineWidth // leave room for 1 space between each column lineWidth -= (numCols - 1) // set fixed column widths and get a total of fixed columns, remaining characters to be split among percent width cols ArrayList percentWidths = new ArrayList<>(numCols) for (int i = 0; i < numCols; i++) percentWidths.add(null) int fixedColsWidth = 0 int fixedColsCount = 0 for (int i = 0; i < numCols; i++) { ArrayList colNodes = (ArrayList) allColInfo.get(i) int charWidth = -1 BigDecimal percentWidth = null for (int j = 0; j < colNodes.size(); j++) { MNode fieldNode = (MNode) colNodes.get(j) String pwAttr = fieldNode.attribute("print-width") if (pwAttr == null || pwAttr.isEmpty()) continue BigDecimal curWidth = new BigDecimal(pwAttr) if (curWidth == BigDecimal.ZERO) { charWidth = 0 // no separator char needed for columns not displayed so add back to lineWidth lineWidth++ continue } if ("characters".equals(fieldNode.attribute("print-width-type"))) { if (curWidth.intValue() > charWidth) charWidth = curWidth.intValue() } else { if (percentWidth == null || curWidth > percentWidth) percentWidth = curWidth } } if (charWidth >= 0) { if (percentWidth != null) { // if we have char and percent widths, calculate effective chars of percent width and if greater use that int percentChars = ((percentWidth / 100) * lineWidth).intValue() if (percentChars < charWidth) { charWidths.set(i, charWidth) fixedColsWidth += charWidth fixedColsCount++ } else { percentWidths.set(i, percentWidth) } } else { charWidths.set(i, charWidth) fixedColsWidth += charWidth fixedColsCount++ } } else { if (percentWidth != null) percentWidths.set(i, percentWidth) } } // now we have all fixed widths, calculate and set percent widths int widthForPercentCols = lineWidth - fixedColsWidth if (widthForPercentCols < 0) throw new BaseArtifactException("In form ${screenForm.formName} fixed width columns exceeded total line characters ${originalLineWidth} by ${-widthForPercentCols} characters") int percentColsCount = numCols - fixedColsCount // scale column percents to 100, fill in missing BigDecimal percentTotal = 0 for (int i = 0; i < numCols; i++) { BigDecimal colPercent = (BigDecimal) percentWidths.get(i) if (colPercent == null) { if (charWidths.get(i) != null) continue BigDecimal percentWidth = (1 / percentColsCount) * 100 percentWidths.set(i, percentWidth) percentTotal += percentWidth } else { percentTotal += colPercent } } int percentColsUsed = 0 BigDecimal percentScale = 100 / percentTotal for (int i = 0; i < numCols; i++) { BigDecimal colPercent = (BigDecimal) percentWidths.get(i) if (colPercent == null) continue BigDecimal actualPercent = colPercent * percentScale percentWidths.set(i, actualPercent) int percentChars = ((actualPercent / 100.0) * widthForPercentCols).setScale(0, RoundingMode.HALF_EVEN).intValue() charWidths.set(i, percentChars) percentColsUsed += percentChars } // adjust for over/underflow if (percentColsUsed != widthForPercentCols) { int diffRemaining = widthForPercentCols - percentColsUsed int diffPerCol = (diffRemaining / percentColsCount).setScale(0, RoundingMode.UP).intValue() for (int i = 0; i < numCols; i++) { if (percentWidths.get(i) == null) continue Integer curChars = charWidths.get(i) int adjustAmount = Math.abs(diffRemaining) > Math.abs(diffPerCol) ? diffPerCol : diffRemaining int newChars = curChars + adjustAmount if (newChars > 0) { charWidths.set(i, newChars) diffRemaining -= adjustAmount if (diffRemaining == 0) break } } } logger.info("Text mode form-list: numCols=${numCols}, percentColsUsed=${percentColsUsed}, widthForPercentCols=${widthForPercentCols}, percentColsCount=${percentColsCount}\npercentWidths: ${percentWidths}\ncharWidths: ${charWidths}") return charWidths } } static Map makeFormListFindParameters(String formListFindId, ExecutionContext ec) { EntityList flffList = ec.entity.find("moqui.screen.form.FormListFindField") .condition("formListFindId", formListFindId).useCache(true).disableAuthz().list() Map parmMap = new LinkedHashMap<>() parmMap.put("formListFindId", formListFindId) int flffSize = flffList.size() for (int i = 0; i < flffSize; i++) { EntityValue flff = (EntityValue) flffList.get(i) String fn = (String) flff.getNoCheckSimple("fieldName") String fieldValue = (String) flff.getNoCheckSimple("fieldValue") if (fieldValue != null && !fieldValue.isEmpty()) { parmMap.put(fn, fieldValue) String op = (String) flff.getNoCheckSimple("fieldOperator") if (op && !"equals".equals(op)) parmMap.put(fn + "_op", op) String not = (String) flff.getNoCheckSimple("fieldNot") if ("Y".equals(not)) parmMap.put(fn + "_not", "Y") String ic = (String) flff.getNoCheckSimple("fieldIgnoreCase") if ("Y".equals(ic)) parmMap.put(fn + "_ic", "Y") } else if (flff.getNoCheckSimple("fieldPeriod")) { parmMap.put(fn + "_period", (String) flff.getNoCheckSimple("fieldPeriod")) parmMap.put(fn + "_poffset", flff.getNoCheckSimple("fieldPerOffset") as String) } else if (flff.getNoCheckSimple("fieldFrom") || flff.getNoCheckSimple("fieldThru")) { if (flff.fieldFrom) parmMap.put(fn + "_from", (String) flff.getNoCheckSimple("fieldFrom")) if (flff.fieldThru) parmMap.put(fn + "_thru", (String) flff.getNoCheckSimple("fieldThru")) } } return parmMap } static EntityValue getFormListFindScreenScheduled(String formListFindId, ExecutionContextImpl ec) { EntityList screenScheduledList = ec.entityFacade.find("moqui.screen.ScreenScheduled") .condition("formListFindId", formListFindId).condition("userId", ec.userFacade.userId) .orderBy("-screenScheduledId").useCache(true).disableAuthz().list() if (screenScheduledList.size() == 0) { Set userGroupIdSet = ec.userFacade.getUserGroupIdSet() screenScheduledList = ec.entityFacade.find("moqui.screen.ScreenScheduled") .condition("formListFindId", formListFindId).condition("userGroupId", "in", userGroupIdSet) .orderBy("-screenScheduledId").useCache(true).disableAuthz().list() } return screenScheduledList.getFirst() } static Map getFormListFindInfo(String formListFindId, ExecutionContextImpl ec, Set userOnlyFlfIdSet) { EntityValue formListFind = ec.entityFacade.fastFindOne("moqui.screen.form.FormListFind", true, true, formListFindId) Map flfParameters = makeFormListFindParameters(formListFindId, ec) flfParameters.put("formListFindId", formListFindId) if (formListFind.orderByField) flfParameters.put("orderByField", (String) formListFind.orderByField) return [description:formListFind.description, formListFind:formListFind, findParameters:flfParameters, isByUserId:(userOnlyFlfIdSet?.contains(formListFindId) ? "true" : "false")] } static String processFormSavedFind(ExecutionContextImpl ec) { // disable authz to allow saved finds for users with view only authz ec.artifactExecutionFacade.disableAuthz() try { return processFormSavedFindInternal(ec) } finally { ec.artifactExecutionFacade.enableAuthz() } } static String processFormSavedFindInternal(ExecutionContextImpl ec) { String userId = ec.userFacade.userId ContextStack cs = ec.contextStack String formListFindId = (String) cs.getByString("formListFindId") EntityValue flf = formListFindId != null && !formListFindId.isEmpty() ? ec.entity.find("moqui.screen.form.FormListFind") .condition("formListFindId", formListFindId).useCache(false).one() : null if (cs.containsKey("DeleteFind")) { if (flf == null) { ec.messageFacade.addError("Saved find with ID ${formListFindId} not found, not deleting"); return null } // delete FormListFindUserDefault that reference this formListFindId for this user ec.entity.find("moqui.screen.form.FormListFindUserDefault").condition("userId", userId) .condition("formListFindId", formListFindId).deleteAll() // delete FormListFindUser record; if there are no other FormListFindUser records or FormListFindUserGroup // records, delete the FormListFind EntityValue flfu = ec.entity.find("moqui.screen.form.FormListFindUser").condition("userId", userId) .condition("formListFindId", formListFindId).useCache(false).one() // NOTE: if no FormListFindUser nothing to delete... consider removing form from all groups the user is in? best not to, affects other users especially for ALL_USERS if (flfu == null) return null flfu.delete() long userCount = ec.entity.find("moqui.screen.form.FormListFindUser") .condition("formListFindId", formListFindId).useCache(false).count() if (userCount == 0L) { long groupCount = ec.entity.find("moqui.screen.form.FormListFindUserGroup") .condition("formListFindId", formListFindId).useCache(false).count() if (groupCount == 0L) { ec.entity.find("moqui.screen.form.FormListFindField") .condition("formListFindId", formListFindId).deleteAll() ec.entity.find("moqui.screen.form.FormListFind") .condition("formListFindId", formListFindId).deleteAll() } } return null } if (cs.containsKey("ScheduleFind")) { if (flf == null) { ec.messageFacade.addError("Saved find with ID ${formListFindId} not found, not scheduling"); return null } if (!userId) { ec.messageFacade.addError("No user logged in, not scheduling saved find with ID ${formListFindId}"); return formListFindId } String renderMode = (String) cs.getByString("renderMode") ?: "csv" String screenPath = (String) cs.getByString("screenPath") String cronSelected = (String) cs.getByString("cronSelected") if (!screenPath) { ec.messageFacade.addError("Screen Path not specified, not scheduling saved find with ID ${formListFindId}"); return formListFindId } if (!cronSelected) { ec.messageFacade.addError("Cron Schedule not specified, not scheduling saved find with ID ${formListFindId}"); return formListFindId } String emailSubject = flf.getString("description") + ' ${ec.l10n.format(ec.user.nowTimestamp, null)}' Map screenScheduledMap = [screenPath:screenPath, formListFindId:formListFindId, renderMode:renderMode, noResultsAbort:"Y", cronExpression:cronSelected, emailTemplateId:"SCREEN_RENDER", emailSubject:emailSubject, userId:userId] as Map ec.serviceFacade.sync().name("create#moqui.screen.ScreenScheduled").parameters(screenScheduledMap).disableAuthz().call() ec.messageFacade.addMessage("Saved find scheduled to send by email") return formListFindId } if (cs.containsKey("ClearDefault")) { ec.entity.find("moqui.screen.form.FormListFindUserDefault").condition("userId", userId) .condition("formListFindId", formListFindId).deleteAll() return null } String formLocation = cs.getByString("formLocation") if (!formLocation) { ec.message.addError("No form location specified, cannot process saved find"); return null; } // example location: component://SimpleScreens/screen/SimpleScreens/Accounting/Reports/InvoiceAgingDetail.xml.form_list$InvoiceAgingList // example location with extension: component://SimpleScreens/screen/SimpleScreens/Accounting/Reports/InvoiceAgingDetail.xml.form_list$InvoiceAgingList#123456 // pull out location extension if there is a '#' in the location (optional) String formLocationTemp = formLocation int lastHashIndex = formLocationTemp.lastIndexOf('#') String locationExtension = null if (lastHashIndex > 0) { locationExtension = formLocationTemp.substring(lastHashIndex + 1) formLocationTemp = formLocationTemp.substring(0, lastHashIndex) } // save this to the context for FormInstance init which for dynamic=true is called per form-instance and uses this for auto-fields-service or auto-fields-entity if (locationExtension) cs.put("formLocationExtension", locationExtension) // separate formName and screenLocation int lastDotIndex = formLocationTemp.lastIndexOf(".") if (lastDotIndex < 0) { ec.message.addError("Form location invalid, cannot process saved find"); return null; } String screenLocation = formLocationTemp.substring(0, lastDotIndex) int lastDollarIndex = formLocationTemp.lastIndexOf('$') if (lastDollarIndex < 0) { ec.message.addError("Form location invalid, cannot process saved find"); return null; } String formName = formLocationTemp.substring(lastDollarIndex + 1) ScreenDefinition screenDef = ec.screenFacade.getScreenDefinition(screenLocation) if (screenDef == null) { ec.message.addError("Screen not found at ${screenLocation}, cannot process saved find"); return null; } // MakeDefault needs the screenLocation, do here just after validated if (cs.containsKey("MakeDefault")) { if (flf == null) { ec.messageFacade.addError("Saved find with ID ${formListFindId} not found, not making default"); return null } // FUTURE: consider some sort of check to make sure associated with user or a group user is in? is it a big deal? EntityValue curUserDefault = ec.entityFacade.find("moqui.screen.form.FormListFindUserDefault") .condition("userId", userId).condition("screenLocation", screenLocation).one() if (curUserDefault == null) { ec.entityFacade.makeValue("moqui.screen.form.FormListFindUserDefault").set("userId", userId) .set("screenLocation", screenLocation).set("formListFindId", formListFindId).create() } else { curUserDefault.set("formListFindId", formListFindId) curUserDefault.update() } return null } ScreenForm screenForm = screenDef.getForm(formName) if (screenForm == null) { ec.message.addError("Form ${formName} not found in screen at ${screenLocation}, cannot process saved find"); return null; } FormInstance formInstance = screenForm.getFormInstance() String formConfigId = formInstance.getUserActiveFormConfigId(ec) if ((formConfigId == null || formConfigId.isEmpty()) && flf != null) formConfigId = flf.formConfigId EntityList formConfigFieldList = null if (formConfigId != null && !formConfigId.isEmpty()) { formConfigFieldList = ec.entityFacade.find("moqui.screen.form.FormConfigField") .condition("formConfigId", formConfigId).useCache(true).list() } // see if there is an existing FormListFind record if (flf != null) { // make sure the FormListFind.formLocation matches the current formLocation if (!formLocation.equals(flf.getNoCheckSimple("formLocation"))) { ec.message.addError("Specified form location did not match form on Saved Find ${formListFindId}, not updating") return null } // make sure the user or group the user is in is associated with the FormListFind EntityValue flfu = ec.entity.find("moqui.screen.form.FormListFindUser").condition("userId", userId) .condition("formListFindId", formListFindId).useCache(false).one() if (flfu == null) { long groupCount = ec.entity.find("moqui.screen.form.FormListFindUserGroup") .condition("userGroupId", EntityCondition.IN, ec.user.userGroupIdSet) .condition("formListFindId", formListFindId).useCache(false).count() if (groupCount == 0L) { ec.message.addError("You are not associated with Saved Find ${formListFindId}, cannot update") return formListFindId } // is associated with a group but we want to only update for a user, so treat this as if it is not based on existing flf = null formListFindId = null } } if (flf != null) { // save the FormConfig fields if needed, create a new FormConfig for the FormListFind or removing existing as needed if (formConfigFieldList != null && formConfigFieldList.size() > 0) { String flfFormConfigId = (String) flf.getNoCheckSimple("formConfigId") if (flfFormConfigId != null && !flfFormConfigId.isEmpty()) { ec.entity.find("moqui.screen.form.FormConfigField").condition("formConfigId", flfFormConfigId).deleteAll() } else { EntityValue formConfig = ec.entity.makeValue("moqui.screen.form.FormConfig").set("formLocation", formLocation) .setSequencedIdPrimary().create() flfFormConfigId = (String) formConfig.getNoCheckSimple("formConfigId") flf.formConfigId = flfFormConfigId } for (EntityValue fcf in formConfigFieldList) fcf.cloneValue().set("formConfigId", flfFormConfigId).create() } else { // clear previous FormConfig String flfFormConfigId = (String) flf.getNoCheckSimple("formConfigId") flf.formConfigId = null if (flfFormConfigId != null && !flfFormConfigId.isEmpty()) ec.entity.find("moqui.screen.form.FormConfigField").condition("formConfigId", flfFormConfigId).deleteAll() ec.entity.find("moqui.screen.form.FormConfigField").condition("formConfigId", flfFormConfigId).deleteAll() } if (cs._findDescription) flf.description = cs._findDescription if (cs.orderByField) flf.orderByField = cs.orderByField if (flf.isModified()) flf.update() // remove all FormListFindField records and create new ones ec.entity.find("moqui.screen.form.FormListFindField").condition("formListFindId", formListFindId).deleteAll() ArrayList flffList = formInstance.makeFormListFindFields(formListFindId, ec) for (EntityValue flff in flffList) flff.create() } else { // if there are FormConfig fields save in a new FormConfig first so we can set the formConfigId later EntityValue formConfig = null if (formConfigFieldList != null && formConfigFieldList.size() > 0) { formConfig = ec.entity.makeValue("moqui.screen.form.FormConfig").set("formLocation", formLocation) .setSequencedIdPrimary().create() for (EntityValue fcf in formConfigFieldList) fcf.cloneValue().set("formConfigId", formConfig.formConfigId).create() } flf = ec.entity.makeValue("moqui.screen.form.FormListFind") flf.formLocation = formLocation flf.description = cs._findDescription ?: "${ec.user.username} - ${ec.l10n.format(ec.user.nowTimestamp, "yyyy-MM-dd HH:mm")}" if (cs.orderByField) flf.orderByField = cs.orderByField if (formConfig != null) flf.formConfigId = formConfig.formConfigId flf.setSequencedIdPrimary() flf.create() formListFindId = (String) flf.formListFindId EntityValue flfu = ec.entity.makeValue("moqui.screen.form.FormListFindUser") flfu.formListFindId = formListFindId flfu.userId = userId flfu.create() ArrayList flffList = formInstance.makeFormListFindFields(formListFindId, ec) for (EntityValue flff in flffList) flff.create() } return formListFindId } static void saveFormConfig(ExecutionContextImpl ec) { String userId = ec.userFacade.userId ContextStack cs = ec.contextStack String formLocation = cs.get("formLocation") if (!formLocation) { ec.messageFacade.addError("No form location specified, cannot save form configuration"); return; } // get formConfigId String formConfigId = cs.get("formConfigId") // get configTypeEnumId String configTypeEnumId = ec.contextStack.getByString("configTypeEnumId") if (configTypeEnumId != null && configTypeEnumId.isEmpty()) configTypeEnumId = null if (configTypeEnumId == null) { String columnsType = ec.contextStack.getByString("_uiType") if (columnsType != null && columnsType.isEmpty()) columnsType = null if (columnsType != null) { // look up Enumeration record by enumCode (_uiType value) and enumTypeId to get enumId configTypeEnumId = ec.entityFacade.find("moqui.basic.Enumeration") .condition("enumTypeId", "FormConfigType").condition("enumCode", columnsType) .useCache(true).one()?.get("enumId") } } // logger.warn("formConfigId ${formConfigId} configTypeEnumId ${configTypeEnumId}") // see if there is an existing FormConfig record if (formConfigId == null || formConfigId.isEmpty()) { // if configTypeEnumId then use with FormConfigUserType, else use FormConfigUser to defer to screen def columns // config by type or screen def default columns config if (configTypeEnumId != null) { EntityValue fcut = ec.entityFacade.fastFindOne("moqui.screen.form.FormConfigUserType", true, false, formLocation, userId, configTypeEnumId) formConfigId = (String) fcut?.getNoCheckSimple("formConfigId") } else { EntityValue fcu = ec.entity.find("moqui.screen.form.FormConfigUser") .condition("userId", userId).condition("formLocation", formLocation).useCache(false).one() formConfigId = (String) fcu?.getNoCheckSimple("formConfigId") } } String userCurrentFormConfigId = formConfigId // if FormConfig associated with this user but no other users or groups delete its FormConfigField // records and remember its ID for create FormConfigField if (formConfigId) { long userCount = configTypeEnumId != null ? ec.entity.find("moqui.screen.form.FormConfigUserType").condition("formConfigId", formConfigId) .condition("configTypeEnumId", configTypeEnumId).useCache(false).count() : ec.entity.find("moqui.screen.form.FormConfigUser").condition("formConfigId", formConfigId) .useCache(false).count() if (userCount > 1) { formConfigId = null } else { long groupCount = ec.entity.find("moqui.screen.form.FormConfigUserGroup") .condition("formConfigId", formConfigId).useCache(false).count() if (groupCount > 0) formConfigId = null } } // clear out existing records if (formConfigId) { ec.entity.find("moqui.screen.form.FormConfigField").condition("formConfigId", formConfigId).deleteAll() } // are we resetting columns? if (cs.get("ResetColumns")) { if (formConfigId) { // no other users on this form, and now being reset, so delete FormConfig ec.entity.find("moqui.screen.form.FormConfigUser").condition("formConfigId", formConfigId).deleteAll() ec.entity.find("moqui.screen.form.FormConfigUserType").condition("formConfigId", formConfigId).deleteAll() ec.entity.find("moqui.screen.form.FormConfig").condition("formConfigId", formConfigId).deleteAll() } else if (userCurrentFormConfigId) { // there is a FormConfig but other users are using it, so just remove this user ec.entity.find("moqui.screen.form.FormConfigUser").condition("formConfigId", userCurrentFormConfigId) .condition("userId", userId).deleteAll() ec.entity.find("moqui.screen.form.FormConfigUserType").condition("formConfigId", userCurrentFormConfigId) .condition("userId", userId).deleteAll() } // to reset columns don't save new ones, just return after clearing out existing records return } // if there is no FormConfig or found record is associated with other users or groups // create a new FormConfig record to use if (!formConfigId) { Map createResult = ec.service.sync().name("create#moqui.screen.form.FormConfig") .parameters([userId:userId, formLocation:formLocation, description:"For user ${userId}"]).call() formConfigId = createResult.formConfigId if (configTypeEnumId != null) { ec.service.sync().name("create#moqui.screen.form.FormConfigUserType") .parameters([formConfigId:formConfigId, userId:userId, formLocation:formLocation, configTypeEnumId:configTypeEnumId]).call() } else { ec.service.sync().name("create#moqui.screen.form.FormConfigUser") .parameters([formConfigId:formConfigId, userId:userId, formLocation:formLocation]).call() } } // save changes to DB String columnsTreeStr = cs.get("columnsTree") as String // logger.info("columnsTreeStr: ${columnsTreeStr}") // if columnsTree empty there were no changes if (!columnsTreeStr) return JsonSlurper slurper = new JsonSlurper() List columnsTree = (List) slurper.parseText(columnsTreeStr) CollectionUtilities.orderMapList(columnsTree, ['order']) int columnIndex = 0 for (Map columnMap in (List) columnsTree) { if (columnMap.get("id") == "hidden") continue List children = (List) columnMap.get("children") CollectionUtilities.orderMapList(children, ['order']) int columnSequence = 0 for (Map fieldMap in (List) children) { String fieldName = (String) fieldMap.get("id") // logger.info("Adding field ${fieldName} to column ${columnIndex} at sequence ${columnSequence}") ec.service.sync().name("create#moqui.screen.form.FormConfigField") .parameters([formConfigId:formConfigId, fieldName:fieldName, positionIndex:columnIndex, positionSequence:columnSequence]).call() columnSequence++ } columnIndex++ } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenRenderImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import freemarker.template.Template import groovy.json.JsonOutput import groovy.json.JsonSlurper import groovy.transform.CompileStatic import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.moqui.BaseArtifactException import org.moqui.BaseException import org.moqui.context.* import org.moqui.context.MessageFacade.MessageInfo import org.moqui.entity.EntityCondition.ComparisonOperator import org.moqui.entity.EntityException import org.moqui.entity.EntityList import org.moqui.entity.EntityListIterator import org.moqui.entity.EntityValue import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ContextJavaUtil import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.ResourceFacadeImpl import org.moqui.impl.context.WebFacadeImpl import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.entity.EntityValueBase import org.moqui.impl.screen.ScreenDefinition.ResponseItem import org.moqui.impl.screen.ScreenDefinition.SubscreensItem import org.moqui.impl.screen.ScreenForm.FormInstance import org.moqui.impl.screen.ScreenUrlInfo.UrlInstance import org.moqui.resource.ResourceReference import org.moqui.screen.ScreenRender import org.moqui.screen.ScreenTest import org.moqui.util.ContextStack import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.moqui.util.WebUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ScreenRenderImpl implements ScreenRender { protected final static Logger logger = LoggerFactory.getLogger(ScreenRenderImpl.class) protected final static boolean isTraceEnabled = logger.isTraceEnabled() public final ScreenFacadeImpl sfi public final ExecutionContextImpl ec protected boolean rendering = false protected String rootScreenLocation = (String) null protected ScreenDefinition rootScreenDef = (ScreenDefinition) null protected ScreenDefinition overrideActiveScreenDef = (ScreenDefinition) null protected ArrayList originalScreenPathNameList = new ArrayList() protected ScreenUrlInfo screenUrlInfo = (ScreenUrlInfo) null protected UrlInstance screenUrlInstance = (UrlInstance) null protected Map subscreenUrlInfos = new HashMap() protected int screenPathIndex = 0 protected Set stopRenderScreenLocations = new HashSet() protected String lastStandalone = (String) null protected String baseLinkUrl = (String) null protected String servletContextPath = (String) null protected String webappName = (String) null protected String renderMode = (String) null protected String characterEncoding = "UTF-8" /** For HttpServletRequest/Response renders this will be set on the response either as this default or a value * determined during render, especially for screen sub-content based on the extension of the filename. */ protected String outputContentType = (String) null protected String macroTemplateLocation = (String) null protected Boolean boundaryComments = (Boolean) null protected HttpServletRequest request = (HttpServletRequest) null protected HttpServletResponse response = (HttpServletResponse) null protected Writer internalWriter = (Writer) null protected Writer afterScreenWriter = (Writer) null protected Writer scriptWriter = (Writer) null protected OutputStream internalOutputStream = (OutputStream) null protected boolean dontDoRender = false protected boolean saveHistory = false protected Map screenFormCache = new HashMap<>() protected String curThemeId = (String) null protected Map> curThemeValuesByType = new HashMap<>() ScreenRenderImpl(ScreenFacadeImpl sfi) { this.sfi = sfi ec = sfi.ecfi.getEci() } Writer getWriter() { if (internalWriter != null) return internalWriter if (internalOutputStream != null) { if (characterEncoding == null || characterEncoding.length() == 0) characterEncoding = "UTF-8" internalWriter = new OutputStreamWriter(internalOutputStream, characterEncoding) return internalWriter } if (response != null) { internalWriter = response.getWriter() return internalWriter } throw new BaseArtifactException("Could not render screen, no writer available") } OutputStream getOutputStream() { if (internalOutputStream != null) return internalOutputStream if (response != null) { internalOutputStream = response.getOutputStream() return internalOutputStream } throw new BaseArtifactException("Could not render screen, no output stream available") } ScreenUrlInfo getScreenUrlInfo() { return screenUrlInfo } UrlInstance getScreenUrlInstance() { return screenUrlInstance } @Override ScreenRender rootScreen(String rsLocation) { rootScreenLocation = rsLocation; return this } ScreenRender rootScreenFromHost(String host) { return rootScreen(sfi.rootScreenFromHost(host, webappName)) } @Override ScreenRender screenPath(List screenNameList) { originalScreenPathNameList.addAll(screenNameList); return this } @Override ScreenRender screenPath(String path) { screenPath(StringUtilities.pathStringToList(path, 0)); return this } @Override ScreenRender lastStandalone(String ls) { lastStandalone = ls; return this } @Override ScreenRender renderMode(String renderMode) { this.renderMode = renderMode; return this } String getRenderMode() { return renderMode } @Override ScreenRender encoding(String characterEncoding) { this.characterEncoding = characterEncoding; return this } @Override ScreenRender macroTemplate(String mtl) { this.macroTemplateLocation = mtl; return this } @Override ScreenRender baseLinkUrl(String blu) { this.baseLinkUrl = blu; return this } @Override ScreenRender servletContextPath(String scp) { this.servletContextPath = scp; return this } @Override ScreenRender webappName(String wan) { this.webappName = wan; return this } @Override ScreenRender saveHistory(boolean sh) { this.saveHistory = sh; return this } @Override void render(HttpServletRequest request, HttpServletResponse response) { if (rendering) throw new IllegalStateException("This screen render has already been used") rendering = true this.request = request this.response = response // NOTE: don't get the writer at this point, we don't yet know if we're writing text or binary if (webappName == null || webappName.length() == 0) webappName = request.servletContext.getInitParameter("moqui-name") if (webappName != null && webappName.length() > 0 && (rootScreenLocation == null || rootScreenLocation.length() == 0)) rootScreenFromHost(request.getServerName()) if (originalScreenPathNameList == null || originalScreenPathNameList.size() == 0) { ArrayList pathList = ec.web.getPathInfoList() screenPath(pathList) } if (servletContextPath == null || servletContextPath.isEmpty()) servletContextPath = request.getServletContext()?.getContextPath() // now render internalRender() } @Override void render(Writer writer) { if (rendering) throw new IllegalStateException("This screen render has already been used") rendering = true internalWriter = writer internalRender() } @Override void render(OutputStream os) { if (rendering) throw new IllegalStateException("This screen render has already been used") rendering = true internalOutputStream = os internalRender() } @Override String render() { if (rendering) throw new IllegalStateException("This screen render has already been used") rendering = true internalWriter = new StringWriter() internalRender() return internalWriter.toString() } /** this should be called as part of a always-actions or pre-actions block to stop rendering before it starts */ void sendRedirectAndStopRender(String redirectUrl) { if (response != null) { if (servletContextPath != null && !servletContextPath.isEmpty() && redirectUrl.startsWith("/")) redirectUrl = servletContextPath + redirectUrl MNode stoNode = sfi.ecfi.getConfXmlRoot().first("screen-facade") .first("screen-text-output", "type", renderMode) if (stoNode != null && "true".equals(stoNode.attribute("always-standalone"))) { if (logger.isInfoEnabled()) logger.info("Redirecting with 205 and X-Redirect-To ${redirectUrl} instead of rendering ${this.getScreenUrlInfo().getFullPathNameList()}") response.setHeader("X-Redirect-To", redirectUrl) // use code 205 (Reset Content) for client router handled redirect response.setStatus(HttpServletResponse.SC_RESET_CONTENT) } else { if (logger.isInfoEnabled()) logger.info("Redirecting to ${redirectUrl} instead of rendering ${this.getScreenUrlInfo().getFullPathNameList()}") // add Cache-Control: no-store header since this is often in actions after screen render has started and a Cache-Control header has been set, so replace it here response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, private") response.sendRedirect(redirectUrl) } dontDoRender = true } } boolean sendJsonRedirect(UrlInstance fullUrl, Long renderStartTime) { if ("json".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader("Accept")?.contains("application/json")) { String pathWithParams = fullUrl.getPathWithParams() Map responseMap = getBasicResponseMap() // add screen path, parameters from fullUrl responseMap.put("screenPathList", fullUrl.sui.fullPathNameList) responseMap.put("screenParameters", fullUrl.getParameterMap()) responseMap.put("screenUrl", pathWithParams) // send it ec.web.sendJsonResponse(responseMap) if (logger.isInfoEnabled()) logger.info("Transition ${screenUrlInfo.getFullPathNameList().join("/")}${renderStartTime != null ? ' in ' + (System.currentTimeMillis() - renderStartTime) + 'ms' : ''}, JSON redirect to: ${pathWithParams}") return true } else { return false } } boolean sendJsonRedirect(String plainUrl) { if ("json".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader("Accept")?.contains("application/json")) { Map responseMap = getBasicResponseMap() // the plain URL, send as redirect URL responseMap.put("redirectUrl", plainUrl) // send it ec.web.sendJsonResponse(responseMap) return true } else { return false } } Map getBasicResponseMap() { Map responseMap = new HashMap<>() // add saveMessagesToSession, saveRequestParametersToSession/saveErrorParametersToSession data // add all plain object data from session? List messageInfos = ec.message.getMessageInfos() int messageInfosSize = messageInfos.size() if (messageInfosSize > 0) { List miMapList = new ArrayList<>(messageInfosSize) for (int i = 0; i < messageInfosSize; i++) { MessageInfo messageInfo = (MessageInfo) messageInfos.get(i) miMapList.add([message:messageInfo.message, type:messageInfo.typeString]) } responseMap.put("messageInfos", miMapList) } if (ec.message.getErrors().size() > 0) responseMap.put("errors", ec.message.errors) if (ec.message.getValidationErrors().size() > 0) { List valErrorList = ec.message.getValidationErrors() int valErrorListSize = valErrorList.size() ArrayList valErrMapList = new ArrayList<>(valErrorListSize) for (int i = 0; i < valErrorListSize; i++) valErrMapList.add(valErrorList.get(i).getMap()) responseMap.put("validationErrors", valErrMapList) } Map parms = new HashMap() if (ec.web.requestParameters != null) parms.putAll(ec.web.requestParameters) if (ec.web.requestAttributes != null) parms.putAll(ec.web.requestAttributes) responseMap.put("currentParameters", ContextJavaUtil.unwrapMap(parms)) return responseMap } protected void internalRender() { // make sure this (sri) is in the context before running actions or rendering screens ec.contextStack.put("sri", this) long renderStartTime = System.currentTimeMillis() rootScreenDef = sfi.getScreenDefinition(rootScreenLocation) if (rootScreenDef == null) throw new BaseArtifactException("Could not find root screen at location ${rootScreenLocation}") if (logger.traceEnabled) logger.trace("Rendering screen ${rootScreenLocation} with path list ${originalScreenPathNameList}") // logger.info("Rendering screen [${rootScreenLocation}] with path list [${originalScreenPathNameList}]") WebFacade web = ec.getWeb() if ((lastStandalone == null || lastStandalone.isEmpty()) && web != null) lastStandalone = (String) web.requestParameters.lastStandalone ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName) screenUrlInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, originalScreenPathNameList, null, ScreenUrlInfo.parseLastStandalone(lastStandalone, 0)) // if the target of the url doesn't exist throw exception screenUrlInfo.checkExists() screenUrlInstance = screenUrlInfo.getInstance(this, false) // if there is a formListFindId parameter see if any matching parameters are set otherwise set all configured params // NOTE: needs to be done very early in screen rendering so that parameters are available for actions, etc // NOTE: this should allow override of parameters along with a formListFindId while defaulting to configured ones, // but is far from ideal in detecting whether configured parms should be used String formListFindId = ec.contextStack.getByString("formListFindId") if ((formListFindId == null || formListFindId.isEmpty()) && screenUrlInfo.targetScreen != null) { // get user's default saved find if there is one String userId = ec.userFacade.getUserId() if (userId != null) { EntityValue formListFindUserDefault = ec.entityFacade.find("moqui.screen.form.FormListFindUserDefault") .condition("userId", userId).condition("screenLocation", screenUrlInfo.targetScreen.location) .disableAuthz().useCache(true).one() if (formListFindUserDefault != null) { formListFindId = (String) formListFindUserDefault.get("formListFindId") ec.contextStack.put("formListFindId", formListFindId) } } } if ("_clear".equals(formListFindId)) { formListFindId = null ec.contextStack.put("formListFindId", null) if (web != null) web.requestParameters.put("formListFindId", "") } if (formListFindId != null && !formListFindId.isEmpty()) { Set targetScreenParmNames = screenUrlInfo.targetScreen?.getParameterMap()?.keySet() Map flfParameters = ScreenForm.makeFormListFindParameters(formListFindId, ec) boolean foundMatchingParm = false for (String flfParmName in flfParameters.keySet()) { if ("formListFindId".equals(flfParmName)) continue if (targetScreenParmNames != null && targetScreenParmNames.contains(flfParmName)) continue Object parmValue = ec.contextStack.getByString(flfParmName) if (!ObjectUtilities.isEmpty(parmValue)) { foundMatchingParm = true break } } if (!foundMatchingParm) { EntityValue formListFind = ec.entityFacade.fastFindOne("moqui.screen.form.FormListFind", true, true, formListFindId) if (formListFind?.orderByField && !ec.contextStack.getByString("orderByField")) ec.contextStack.put("orderByField", formListFind.orderByField) ec.contextStack.putAll(flfParameters) // logger.warn("Found formListFindId and no matching parameters, orderByField [${formListFind?.orderByField}], added paramters: ${flfParameters}") } } if (web != null) { // clear out the parameters used for special screen URL config if (web.requestParameters.lastStandalone) web.requestParameters.lastStandalone = "" // if screenUrlInfo has any parameters add them to the request (probably came from a transition acting as an alias) Map suiParameterMap = screenUrlInstance.getTransitionAliasParameters() if (suiParameterMap != null) web.requestParameters.putAll(suiParameterMap) // add URL parameters, if there were any in the URL (in path info or after ?) screenUrlInstance.addParameters(web.requestParameters) // check for pageSize parameter, if set save in current user's preference, if not look up from user pref if (ec.userFacade.userId != null) { String pageSize = web.requestParameters.get("pageSize") String userPageSize = ec.userFacade.getPreference("screen.user.page.size") if (pageSize != null && pageSize.isInteger()) { if (!pageSize.equals(userPageSize)) ec.userFacade.setPreference("screen.user.page.size", pageSize) } else { if (userPageSize != null && userPageSize.isInteger()) { // don't add to parameters, just set internally: web.requestParameters.put("pageSize", userPageSize) ec.contextStack.put("pageSize", userPageSize) } } } } // check webapp settings for each screen in the path ArrayList screenPathDefList = screenUrlInfo.screenPathDefList int screenPathDefListSize = screenPathDefList.size() for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) if (!checkWebappSettings(sd)) return } // check this here after the ScreenUrlInfo (with transition alias, etc) has already been handled String localRenderMode = web != null ? web.requestParameters.renderMode : null if ((renderMode == null || renderMode.length() == 0) && localRenderMode != null && localRenderMode.length() > 0) renderMode = localRenderMode // if no renderMode get from target screen extension in URL if ((renderMode == null || renderMode.length() == 0) && screenUrlInfo.targetScreenRenderMode != null) renderMode = screenUrlInfo.targetScreenRenderMode // if no outputContentType but there is a renderMode get outputContentType based on renderMode if ((outputContentType == null || outputContentType.length() == 0) && renderMode != null && renderMode.length() > 0) { String mimeType = sfi.getMimeTypeByMode(renderMode) if (mimeType != null && mimeType.length() > 0) outputContentType = mimeType } // if these aren't set yet then set to basic defaults if (renderMode == null || renderMode.length() == 0) renderMode = "html" if (characterEncoding == null || characterEncoding.length() == 0) characterEncoding = "UTF-8" if (outputContentType == null || outputContentType.length() == 0) outputContentType = "text/html" // before we render, set the character encoding (set the content type later, after we see if there is sub-content with a different type) if (response != null) response.setCharacterEncoding(characterEncoding) // if there is a transition run that INSTEAD of the screen to render ScreenDefinition.TransitionItem targetTransition = screenUrlInstance.getTargetTransition() // logger.warn("============ Rendering screen ${screenUrlInfo.getTargetScreen().getLocation()} transition ${screenUrlInfo.getTargetTransitionActualName()} has transition ${targetTransition != null}") if (targetTransition != null) { // if this transition has actions and request was not secure or any parameters were not in the body // return an error, helps prevent CSRF/XSRF attacks if (request != null && targetTransition.hasActionsOrSingleService()) { String queryString = request.getQueryString() // NOTE: We decode path parameter ourselves, so use getRequestURI instead of getPathInfo Map pathInfoParameterMap = WebUtilities.getPathInfoParameterMap(request.getRequestURI()) if (!targetTransition.isReadOnly() && ( (!request.isSecure() && webappInfo != null && webappInfo.httpsEnabled) || (queryString != null && queryString.length() > 0) || (pathInfoParameterMap != null && pathInfoParameterMap.size() > 0))) { throw new BaseArtifactException( """Cannot run screen transition with actions from non-secure request or with URL parameters for security reasons (they are not encrypted and need to be for data protection and source validation). Change the link this came from to be a form with hidden input fields instead, or declare the transition as read-only.""") } // require a moquiSessionToken parameter for all but get if (request.getMethod().toLowerCase() != "get" && webappInfo != null && webappInfo.requireSessionToken && targetTransition.getRequireSessionToken() && !"true".equals(request.getAttribute("moqui.session.token.created")) && !"true".equals(request.getAttribute("moqui.request.authenticated"))) { String passedToken = (String) ec.web.getParameters().get("moquiSessionToken") if (!passedToken) passedToken = request.getHeader("moquiSessionToken") ?: request.getHeader("SessionToken") ?: request.getHeader("X-CSRF-Token") String curToken = ec.web.getSessionToken() if (curToken != null && curToken.length() > 0) { if (passedToken == null || passedToken.length() == 0) { throw new AuthenticationRequiredException("Session token required (in X-CSRF-Token) for URL ${screenUrlInstance.url}") } else if (!curToken.equals(passedToken)) { throw new AuthenticationRequiredException("Session token does not match (in X-CSRF-Token) for URL ${screenUrlInstance.url}") } } } } long startTimeNanos = System.nanoTime() TransactionFacade transactionFacade = sfi.getEcfi().transactionFacade boolean beginTransaction = targetTransition.getBeginTransaction() boolean beganTransaction = beginTransaction ? transactionFacade.begin(null) : false ResponseItem ri = null try { boolean runPreActions = targetTransition instanceof ScreenDefinition.ActionsTransitionItem screenPathIndex = 0 ri = recursiveRunTransition(runPreActions) screenPathIndex = 0 } catch (Throwable t) { transactionFacade.rollback(beganTransaction, "Error running transition in [${screenUrlInstance.url}]", t) throw t } finally { try { if (transactionFacade.isTransactionInPlace()) { if (ec.getMessage().hasError()) { transactionFacade.rollback(beganTransaction, ec.getMessage().getErrorsString(), null) } else { transactionFacade.commit(beganTransaction) } } } catch (Exception e) { logger.error("Error ending screen transition transaction", e) } if (!"false".equals(screenUrlInfo.targetScreen.screenNode.attribute("track-artifact-hit"))) { String riType = ri != null ? ri.type : null sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, riType != null ? riType : "", targetTransition.parentScreen.getLocation() + "#" + targetTransition.name, (web != null ? web.requestParameters : null), renderStartTime, (System.nanoTime() - startTimeNanos)/1000000.0D, null) } } if (ri == null) throw new BaseArtifactException("No response found for transition [${screenUrlInstance.targetTransition.name}] on screen ${screenUrlInfo.targetScreen.location}") WebFacadeImpl wfi = (WebFacadeImpl) null if (web != null && web instanceof WebFacadeImpl) wfi = (WebFacadeImpl) web if (ri.saveCurrentScreen && wfi != null) { StringBuilder screenPath = new StringBuilder() for (String pn in screenUrlInfo.fullPathNameList) screenPath.append("/").append(pn) ((WebFacadeImpl) web).saveScreenLastInfo(screenPath.toString(), null) } if (this.response != null && webappInfo != null) { webappInfo.addHeaders("screen-transition", this.response) } if ("none".equals(ri.type)) { // for response type none also save parameters if configured to do so, and save errors if there are any if (ri.saveParameters) wfi.saveRequestParametersToSession() if (ec.message.hasError()) wfi.saveErrorParametersToSession() if (logger.isTraceEnabled()) logger.trace("Transition ${screenUrlInfo.getFullPathNameList().join("/")} in ${System.currentTimeMillis() - renderStartTime}ms, type none response") return } String url = ri.url != null ? ri.url : "" String urlType = ri.urlType != null && ri.urlType.length() > 0 ? ri.urlType : "screen-path" boolean isScreenLast = "screen-last".equals(ri.type) if (wfi != null) { // handle screen-last, etc if (isScreenLast || "screen-last-noparam".equals(ri.type)) { String savedUrl = wfi.getRemoveScreenLastPath() urlType = "screen-path" if (savedUrl != null && savedUrl.length() > 0) { if (savedUrl.startsWith("http")) urlType = "plain" url = savedUrl wfi.removeScreenLastParameters(isScreenLast) // logger.warn("going to screen-last from screen last path ${url}") } else { // try screen history when no last was saved List historyList = wfi.getScreenHistory() Map historyMap = historyList != null && historyList.size() > 0 ? historyList.first() : (Map) null if (historyMap != null) { url = isScreenLast ? historyMap.pathWithParams : historyMap.path // logger.warn("going to screen-last from screen history ${url}") } else { // if no saved URL, just go to root/default; avoid getting stuck on Login screen, etc url = "/" // logger.warn("going to screen-last no last path or history to going to root") } } } // save messages in session before redirecting so they can be displayed on the next screen wfi.saveMessagesToSession() if (ri.saveParameters) wfi.saveRequestParametersToSession() if (ec.message.hasError()) wfi.saveErrorParametersToSession() } // either send a redirect for the response, if possible, or just render the response now if (this.response != null) { if ("plain".equals(urlType)) { StringBuilder ps = new StringBuilder() Map pm = (Map) ri.expandParameters(screenUrlInfo.getExtraPathNameList(), ec) if (pm != null && pm.size() > 0) { for (Map.Entry pme in pm.entrySet()) { if (!pme.value) continue if (ps.length() > 0) ps.append("&") ps.append(URLEncoder.encode(pme.key, "UTF-8")).append("=").append(URLEncoder.encode(pme.value, "UTF-8")) } } String fullUrl = url if (ps.length() > 0) { if (url.contains("?")) fullUrl += "&" else fullUrl += "?" fullUrl += ps.toString() } // NOTE: even if transition extension is json still send redirect when we just have a plain url if (logger.isInfoEnabled()) logger.info("Transition ${screenUrlInfo.getFullPathNameList().join("/")} in ${System.currentTimeMillis() - renderStartTime}ms, redirecting to plain URL: ${fullUrl}") if (!sendJsonRedirect(fullUrl)) { response.sendRedirect(fullUrl) } } else { // default is screen-path UrlInstance fullUrl = buildUrl(rootScreenDef, screenUrlInfo.preTransitionPathNameList, url) // copy through pageIndex if passed so in form-list with multiple pages we stay on same page if (web.requestParameters.containsKey("pageIndex")) fullUrl.addParameter("pageIndex", (String) web.parameters.get("pageIndex")) // copy through orderByField if passed so in form-list with multiple pages we retain the sort order if (web.requestParameters.containsKey("orderByField")) fullUrl.addParameter("orderByField", (String) web.parameters.get("orderByField")) fullUrl.addParameters(ri.expandParameters(screenUrlInfo.getExtraPathNameList(), ec)) // if this was a screen-last and the screen has declared parameters include them in the URL Map savedParameters = wfi?.getSavedParameters() UrlInstance.copySpecialParameters(savedParameters, fullUrl.getOtherParameterMap()) // screen parameters Map parameterItemMap = fullUrl.sui.pathParameterItems if (isScreenLast && savedParameters != null && savedParameters.size() > 0) { if (parameterItemMap != null && parameterItemMap.size() > 0) { for (String parmName in parameterItemMap.keySet()) { if (savedParameters.get(parmName)) fullUrl.addParameter(parmName, savedParameters.get(parmName)) } } else { fullUrl.addParameters(savedParameters) } } // transition parameters Map transParameterItemMap = fullUrl.getTargetTransition()?.getParameterMap() if (isScreenLast && savedParameters != null && savedParameters.size() > 0 && transParameterItemMap != null && transParameterItemMap.size() > 0) { for (String parmName in transParameterItemMap.keySet()) { if (savedParameters.get(parmName)) fullUrl.addParameter(parmName, savedParameters.get(parmName)) } } if (!sendJsonRedirect(fullUrl, renderStartTime)) { String fullUrlString = fullUrl.getUrlWithParams(screenUrlInfo.targetTransitionExtension) if (logger.isInfoEnabled()) logger.info("Transition ${screenUrlInfo.getFullPathNameList().join("/")} in ${System.currentTimeMillis() - renderStartTime}ms, redirecting to screen path URL: ${fullUrlString}") response.sendRedirect(fullUrlString) } } } else { ArrayList pathElements = new ArrayList<>(Arrays.asList(url.split("/"))) if (url.startsWith("/")) { this.originalScreenPathNameList = pathElements } else { this.originalScreenPathNameList = new ArrayList<>(screenUrlInfo.preTransitionPathNameList) this.originalScreenPathNameList.addAll(pathElements) } // reset screenUrlInfo and call this again to start over with the new target screenUrlInfo = (ScreenUrlInfo) null internalRender() } } else if (screenUrlInfo.fileResourceRef != null) { ResourceReference fileResourceRef = screenUrlInfo.fileResourceRef long resourceStartTime = System.currentTimeMillis() long startTimeNanos = System.nanoTime() TemplateRenderer tr = sfi.ecfi.resourceFacade.getTemplateRendererByLocation(fileResourceRef.location) // use the fileName to determine the content/mime type String fileName = fileResourceRef.fileName // strip template extension(s) to avoid problems with trying to find content types based on them String fileContentType = sfi.ecfi.resourceFacade.getContentType(tr != null ? tr.stripTemplateExtension(fileName) : fileName) boolean isBinary = tr == null && ResourceReference.isBinaryContentType(fileContentType) // if (isTraceEnabled) logger.trace("Content type for screen sub-content filename [${fileName}] is [${fileContentType}], default [${this.outputContentType}], is binary? ${isBinary}") if (isBinary) { if (response != null) { this.outputContentType = fileContentType response.setContentType(this.outputContentType) // static binary, tell the browser to cache it if (webappInfo != null) { webappInfo.addHeaders("screen-resource-binary", response) } else { response.setHeader("Cache-Control", "max-age=86400, must-revalidate, public") } InputStream is try { is = fileResourceRef.openStream() OutputStream os = response.outputStream int totalLen = ObjectUtilities.copyStream(is, os) if (screenUrlInfo.targetScreen.screenNode.attribute("track-artifact-hit") != "false") { sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT, fileContentType, fileResourceRef.location, (web != null ? web.requestParameters : null), resourceStartTime, (System.nanoTime() - startTimeNanos)/1000000.0D, (long) totalLen) } if (isTraceEnabled) logger.trace("Sent binary response of length ${totalLen} from file ${fileResourceRef.location} for request to ${screenUrlInstance.url}") } finally { if (is != null) is.close() } } else { throw new BaseArtifactException("Tried to get binary content at ${screenUrlInfo.fileResourcePathList} under screen ${screenUrlInfo.targetScreen.location}, but there is no HTTP response available") } } else if (!"true".equals(screenUrlInfo.targetScreen.screenNode.attribute("include-child-content"))) { // not a binary object (hopefully), read it and write it to the writer if (fileContentType != null && fileContentType.length() > 0) this.outputContentType = fileContentType if (response != null) { response.setContentType(this.outputContentType) response.setCharacterEncoding(this.characterEncoding) } if (tr != null) { // if requires a render, don't cache and make it private if (response != null) { if (webappInfo != null) { webappInfo.addHeaders("screen-resource-template", response) } else { response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, private") } } tr.render(fileResourceRef.location, writer) } else { // static text, tell the browser to cache it if (response != null) { if (webappInfo != null) { webappInfo.addHeaders("screen-resource-text", response) } else { response.setHeader("Cache-Control", "max-age=86400, must-revalidate, public") } } // no renderer found, just grab the text (cached) and throw it to the writer String text = sfi.ecfi.resourceFacade.getLocationText(fileResourceRef.location, true) if (text != null && text.length() > 0) { // NOTE: String.length not correct for byte length String charset = response?.getCharacterEncoding() ?: "UTF-8" // getBytes() is pretty slow, seems to be only way to get accurate length, perhaps better without it (definitely faster) // int length = text.getBytes(charset).length // if (response != null) response.setContentLength(length) if (isTraceEnabled) logger.trace("Sending text response with ${charset} encoding from file ${fileResourceRef.location} for request to ${screenUrlInstance.url}") writer.write(text) if (!"false".equals(screenUrlInfo.targetScreen.screenNode.attribute("track-artifact-hit"))) { sfi.ecfi.countArtifactHit(ArtifactExecutionInfo.AT_XML_SCREEN_CONTENT, fileContentType, fileResourceRef.location, (web != null ? web.requestParameters : null), resourceStartTime, (System.nanoTime() - startTimeNanos)/1000000.0D, (long) text.length()) } } else { logger.warn("Not sending text response from file [${fileResourceRef.location}] for request to [${screenUrlInstance.url}] because no text was found in the file.") } } } else { // render the root screen as normal, and when that is to the targetScreen include the content doActualRender() } } else { doActualRender() if (response != null && logger.isInfoEnabled()) { Map reqParms = web?.getRequestParameters() logger.info("${screenUrlInfo.getFullPathNameList().join("/")} ${reqParms != null && reqParms.size() > 0 ? reqParms : '[]'} in ${(System.currentTimeMillis()-renderStartTime)}ms (${response.getContentType()}) session ${request.session.id}") } } } protected ResponseItem recursiveRunTransition(boolean runPreActions) { ScreenDefinition sd = getActiveScreenDef() // for these authz is not required, as long as something authorizes on the way to the transition, or // the transition itself, it's fine ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(sd.location, ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, null) ec.artifactExecutionFacade.pushInternal(aei, false, false) boolean loggedInAnonymous = false ResponseItem ri = (ResponseItem) null try { MNode screenNode = sd.getScreenNode() String requireAuthentication = screenNode.attribute("require-authentication") if ("anonymous-all".equals(requireAuthentication)) { ec.artifactExecutionFacade.setAnonymousAuthorizedAll() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } else if ("anonymous-view".equals(requireAuthentication)) { ec.artifactExecutionFacade.setAnonymousAuthorizedView() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } if (sd.alwaysActions != null) sd.alwaysActions.run(ec) if (runPreActions && sd.preActions != null) sd.preActions.run(ec) if (getActiveScreenHasNext()) { screenPathIndex++ try { ri = recursiveRunTransition(runPreActions) } finally { screenPathIndex-- } } else { // run the transition ri = screenUrlInstance.targetTransition.run(this) } } finally { ec.artifactExecutionFacade.pop(aei) if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly() } return ri } protected void recursiveRunActions(boolean runAlwaysActions, boolean runPreActions) { ScreenDefinition sd = getActiveScreenDef() boolean activeScreenHasNext = getActiveScreenHasNext() // check authz first, including anonymous-* handling so that permissions and auth are in place // NOTE: don't require authz if the screen doesn't require auth MNode screenNode = sd.getScreenNode() String requireAuthentication = screenNode.attribute("require-authentication") ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(sd.location, ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, outputContentType).setTrackArtifactHit(false) ec.artifactExecutionFacade.pushInternal(aei, !activeScreenHasNext ? (!requireAuthentication || requireAuthentication == "true") : false, false) boolean loggedInAnonymous = false try { if (requireAuthentication == "anonymous-all") { ec.artifactExecutionFacade.setAnonymousAuthorizedAll() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } else if (requireAuthentication == "anonymous-view") { ec.artifactExecutionFacade.setAnonymousAuthorizedView() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } if (runAlwaysActions && sd.alwaysActions != null) sd.alwaysActions.run(ec) if (runPreActions && sd.preActions != null) sd.preActions.run(ec) if (activeScreenHasNext) { screenPathIndex++ try { recursiveRunActions(runAlwaysActions, runPreActions) } finally { screenPathIndex-- } } } finally { // all done so pop the artifact info; don't bother making sure this is done on errors/etc like in a finally clause because if there is an error this will help us know how we got there ec.artifactExecutionFacade.pop(aei) if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly() } } void doActualRender() { ArrayList screenPathDefList = screenUrlInfo.screenPathDefList int screenPathDefListSize = screenPathDefList.size() ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName) boolean isServerStatic = screenUrlInfo.targetScreen.isServerStatic(renderMode) // TODO: consider server caching of rendered screen, this is the place to do it boolean beganTransaction = screenUrlInfo.beginTransaction ? sfi.ecfi.transactionFacade.begin(screenUrlInfo.transactionTimeout) : false try { // run always-actions for all screens in path boolean hasAlwaysActions = false for (int i = 0; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) if (sd.alwaysActions != null) { hasAlwaysActions = true; break } } if (hasAlwaysActions) { screenPathIndex = 0 recursiveRunActions(true, false) screenPathIndex = 0 } if (response != null) { response.setContentType(this.outputContentType) response.setCharacterEncoding(this.characterEncoding) if (isServerStatic) { if (webappInfo != null) { webappInfo.addHeaders("screen-server-static", response) } else { response.setHeader("Cache-Control", "max-age=86400, must-revalidate, public") } } else { if (webappInfo != null) { webappInfo.addHeaders("screen-render", response) } else { // if requires a render, don't cache and make it private response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate, private") // add Content-Security-Policy by default to not allow use in iframe or allow form actions on different host // see https://content-security-policy.com/ // TODO make this configurable for different screen paths? maybe a screen.web-settings attribute to exclude or add to? response.setHeader("Content-Security-Policy", "frame-ancestors 'none'; form-action 'self';") response.setHeader("X-Frame-Options", "deny") } } // if the request is secure add HSTS Strict-Transport-Security header with one leap year age (in seconds) if (request.isSecure()) { if (webappInfo != null) { webappInfo.addHeaders("screen-secure", response) } else { response.setHeader("Strict-Transport-Security", "max-age=31536000") } } String filename = ec.context.saveFilename as String if (filename) { String utfFilename = StringUtilities.encodeAsciiFilename(filename) response.setHeader("Content-Disposition", "attachment; filename=\"${filename}\"; filename*=utf-8''${utfFilename}") } } // for inherited permissions to work, walk the screen list before the screens to render and artifact push // them, then pop after ArrayList aeiList = null if (screenUrlInfo.renderPathDifference > 0) { aeiList = new ArrayList(screenUrlInfo.renderPathDifference) for (int i = 0; i < screenUrlInfo.renderPathDifference; i++) { ScreenDefinition permSd = screenPathDefList.get(i) // check the subscreens item for this screen (valid in context) if (i > 0) { String curPathName = screenUrlInfo.fullPathNameList.get(i - 1) // one lower in path as it doesn't have root screen ScreenDefinition parentScreen = screenPathDefList.get(i - 1) SubscreensItem ssi = parentScreen.getSubscreensItem(curPathName) if (ssi == null) { logger.warn("Couldn't find SubscreenItem: parent ${parentScreen.getScreenName()}, curPathName ${curPathName}, current ${permSd.getScreenName()}\npath list: ${screenUrlInfo.fullPathNameList}\nscreen list: ${screenUrlInfo.screenPathDefList}") } else { if (!ssi.isValidInCurrentContext()) throw new ArtifactAuthorizationException("The screen ${permSd.getScreenName()} is not available") } } ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(permSd.location, ArtifactExecutionInfo.AT_XML_SCREEN, ArtifactExecutionInfo.AUTHZA_VIEW, outputContentType) ec.artifactExecutionFacade.pushInternal(aei, false, false) aeiList.add(aei) } } try { int preActionStartIndex if (screenUrlInfo.targetScreenRenderMode != null && sfi.isRenderModeAlwaysStandalone(screenUrlInfo.targetScreenRenderMode) && screenPathDefListSize > 2) { // special case for render modes that are always standalone: run pre-actions for all screens in path except first 2 (generally webroot, apps) preActionStartIndex = 2 } else { // run pre-actions for just the screens that will be rendered preActionStartIndex = screenUrlInfo.renderPathDifference } boolean hasPreActions = false for (int i = preActionStartIndex; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) if (sd.preActions != null) { hasPreActions = true; break } } if (hasPreActions) { screenPathIndex = preActionStartIndex recursiveRunActions(false, true) screenPathIndex = 0 } // if dontDoRender then quit now; this should be set during always-actions or pre-actions if (dontDoRender) { return } // we've run always and pre actions, it's now or never for required parameters so check them if (!sfi.isRenderModeSkipActions(renderMode)) { for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) for (ScreenDefinition.ParameterItem pi in sd.getParameterMap().values()) { if (!pi.required) continue Object parmValue = ec.context.getByString(pi.name) if (ObjectUtilities.isEmpty(parmValue)) { ec.message.addError(ec.resource.expand("Required parameter missing (${pi.name})","",[pi:pi])) logger.warn("Tried to render screen [${sd.getLocation()}] without required parameter [${pi.name}], error message added and adding to stop list to not render") stopRenderScreenLocations.add(sd.getLocation()) } } } } // start rendering at the root section of the first screen to render screenPathIndex = screenUrlInfo.renderPathDifference ScreenDefinition renderStartDef = getActiveScreenDef() // if there is no next screen to render then it is the target screen, otherwise it's not renderStartDef.render(this, !getActiveScreenHasNext()) // if these aren't already cleared it out means they haven't been included in the output, so add them here if (afterScreenWriter != null) internalWriter.write(afterScreenWriter.toString()) if (scriptWriter != null) { internalWriter.write("\n\n") } } finally { // pop all screens, then good to go if (aeiList) for (int i = (aeiList.size() - 1); i >= 0; i--) ec.artifactExecution.pop(aeiList.get(i)) } // save the screen history if (saveHistory && screenUrlInfo.targetExists) { WebFacade webFacade = ec.getWeb() if (webFacade != null && webFacade instanceof WebFacadeImpl) ((WebFacadeImpl) webFacade).saveScreenHistory(screenUrlInstance) } } catch (ArtifactAuthorizationException e) { throw e } catch (ArtifactTarpitException e) { throw e } catch (Throwable t) { String errMsg = "Error rendering screen [${getActiveScreenDef().location}]" sfi.ecfi.transactionFacade.rollback(beganTransaction, errMsg, t) throw new RuntimeException(errMsg, t) } finally { // if we began a tx commit it if (beganTransaction && sfi.ecfi.transactionFacade.isTransactionInPlace()) sfi.ecfi.transactionFacade.commit() } } boolean checkWebappSettings(ScreenDefinition currentSd) { if (request == null) return true MNode webSettingsNode = currentSd.webSettingsNode if (webSettingsNode != null && "false".equals(webSettingsNode.attribute("allow-web-request"))) throw new BaseArtifactException("The screen [${currentSd.location}] cannot be used in a web request (allow-web-request=false).") String mimeType = webSettingsNode != null ? webSettingsNode.attribute("mime-type") : null if (mimeType != null && mimeType.length() > 0) this.outputContentType = mimeType String characterEncoding = webSettingsNode != null ? webSettingsNode.attribute("character-encoding") : null if (characterEncoding != null && characterEncoding.length() > 0) this.characterEncoding = characterEncoding // if screen requires auth and there is not active user redirect to login screen, save this request // if (isTraceEnabled) logger.trace("Checking screen [${currentSd.location}] for require-authentication, current user is [${ec.user.userId}]") WebFacadeImpl wfi = ec.getWebImpl() String requireAuthentication = currentSd.screenNode?.attribute("require-authentication") String userId = ec.getUser().getUserId() if ((requireAuthentication == null || requireAuthentication.length() == 0 || requireAuthentication == "true") && (userId == null || userId.length() == 0) && !ec.userFacade.getLoggedInAnonymous()) { if (logger.isInfoEnabled()) logger.info("Screen at location ${currentSd.location}, which is part of ${screenUrlInfo.fullPathNameList} under screen ${screenUrlInfo.fromSd.location} requires authentication but no user is currently logged in.") // save the request as a save-last to use after login if (wfi != null && screenUrlInfo.fileResourceRef == null) { StringBuilder screenPath = new StringBuilder() for (String pn in originalScreenPathNameList) if (pn) screenPath.append("/").append(pn) // logger.warn("saving screen last: ${screenPath.toString()}") wfi.saveScreenLastInfo(screenPath.toString(), null) // save messages in session before redirecting so they can be displayed on the next screen wfi.saveMessagesToSession() } // find the last login path from screens in path (whether rendered or not) String loginPath = "/Login" for (ScreenDefinition sd in screenUrlInfo.screenPathDefList) { String loginPathAttr = (String) sd.screenNode.attribute("login-path") if (loginPathAttr) loginPath = loginPathAttr } if (screenUrlInfo.lastStandalone != 0 || screenUrlInstance.getTargetTransition() != null) { // just send a 401 response, should always be for data submit, content rendering, JS AJAX requests, etc if (wfi != null) wfi.sendError(401, null, null) else if (response != null) response.sendError(401, "Authentication required") return false /* TODO: remove all of this, we don't need it ArrayList pathElements = new ArrayList<>() if (!loginPath.startsWith("/")) { pathElements.addAll(screenUrlInfo.preTransitionPathNameList) pathElements.addAll(Arrays.asList(loginPath.split("/"))) } else { pathElements.addAll(Arrays.asList(loginPath.substring(1).split("/"))) } // BEGIN what used to be only for requests for a json response Map responseMap = new HashMap<>() if (ec.message.getMessages().size() > 0) responseMap.put("messages", ec.message.messages) if (ec.message.getErrors().size() > 0) responseMap.put("errors", ec.message.errors) if (ec.message.getValidationErrors().size() > 0) { List valErrorList = ec.message.getValidationErrors() int valErrorListSize = valErrorList.size() ArrayList valErrMapList = new ArrayList<>(valErrorListSize) for (int i = 0; i < valErrorListSize; i++) valErrMapList.add(valErrorList.get(i).getMap()) responseMap.put("validationErrors", valErrMapList) } Map parms = new HashMap() if (ec.web.requestParameters != null) parms.putAll(ec.web.requestParameters) // if (ec.web.requestAttributes != null) parms.putAll(ec.web.requestAttributes) responseMap.put("currentParameters", ContextJavaUtil.unwrapMap(parms)) responseMap.put("redirectUrl", '/' + pathElements.join('/')) // logger.warn("Sending JSON no authc response: ${responseMap}") ec.web.sendJsonResponse(responseMap) // END what used to be only for requests for a json response */ /* better to always send a JSON response as above instead of sometimes sending the Login screen, other that status response usually ignored anyway if ("json".equals(screenUrlInfo.targetTransitionExtension) || request?.getHeader("Accept")?.contains("application/json")) { } else { // respond with 401 and the login screen instead of a redirect; JS client libraries handle this much better this.originalScreenPathNameList = pathElements // reset screenUrlInfo and call this again to start over with the new target screenUrlInfo = null internalRender() } return false */ } else { // now prepare and send the redirect ScreenUrlInfo suInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, new ArrayList(), loginPath, 0) UrlInstance urlInstance = suInfo.getInstance(this, false) response.sendRedirect(urlInstance.url) return false } } // if request not secure and screens requires secure redirect to https ExecutionContextFactoryImpl.WebappInfo webappInfo = ec.ecfi.getWebappInfo(webappName) if (!request.isSecure() && (webSettingsNode == null || webSettingsNode.attribute("require-encryption") != "false") && webappInfo != null && webappInfo.httpsEnabled) { if (logger.isInfoEnabled()) logger.info("Screen at location ${currentSd.location}, which is part of ${screenUrlInfo.fullPathNameList} under screen ${screenUrlInfo.fromSd.location} requires an encrypted/secure connection but the request is not secure, sending redirect to secure.") // save messages in session before redirecting so they can be displayed on the next screen if (wfi != null) wfi.saveMessagesToSession() // redirect to the same URL this came to response.sendRedirect(screenUrlInstance.getUrlWithParams()) return false } return true } boolean doBoundaryComments() { if (screenPathIndex == 0) return false if (boundaryComments != null) return boundaryComments.booleanValue() boundaryComments = "true".equals(sfi.ecfi.confXmlRoot.first("screen-facade").attribute("boundary-comments")) return boundaryComments } ScreenDefinition getRootScreenDef() { return rootScreenDef } ScreenDefinition getActiveScreenDef() { if (overrideActiveScreenDef != null) return overrideActiveScreenDef // no -1 here because the list includes the root screen return (ScreenDefinition) screenUrlInfo.screenPathDefList.get(screenPathIndex) } ScreenDefinition getNextScreenDef() { if (!getActiveScreenHasNext()) return null return (ScreenDefinition) screenUrlInfo.screenPathDefList.get(screenPathIndex + 1) } String getActiveScreenPathName() { if (screenPathIndex == 0) return "" // subtract 1 because path name list doesn't include root screen return screenUrlInfo.fullPathNameList.get(screenPathIndex - 1) } String getNextScreenPathName() { // would subtract 1 because path name list doesn't include root screen, but we want next so use current screenPathIndex return screenUrlInfo.fullPathNameList.get(screenPathIndex) } boolean getActiveScreenHasNext() { return (screenPathIndex + 1) < screenUrlInfo.screenPathDefList.size() } ArrayList getActiveScreenPath() { // handle case where root screen is first/zero in list versus a standalone screen if (screenPathIndex == 0) return new ArrayList() ArrayList activePath = new ArrayList<>(screenUrlInfo.fullPathNameList[0..screenPathIndex-1]) // logger.info("===== activePath=${activePath}, rpd=${screenUrlInfo.renderPathDifference}, spi=${screenPathIndex}, fpi=${fullPathIndex}\nroot: ${screenUrlInfo.rootSd.location}\ntarget: ${screenUrlInfo.targetScreen.location}\nfrom: ${screenUrlInfo.fromSd.location}\nfrom path: ${screenUrlInfo.fromPathList}") return activePath } // TODO: This may not be the actual place we decided on, but due to lost work this is my best guess // Get the first screen path of the parent screens with a transition specified of the currently rendered screen String getScreenPathHasTransition(String transitionName) { int screenPathDefListSize = screenUrlInfo.screenPathDefList.size() for (int i = 0; i < screenPathDefListSize; i++) { ScreenDefinition screenDef = (ScreenDefinition) screenUrlInfo.screenPathDefList.get(i) if (screenDef.hasTransition(transitionName)) { return '/' + screenUrlInfo.fullPathNameList.subList(0,i).join('/') + (i == 0 ? '' : '/') } } return null } String renderSubscreen() { // first see if there is another screen def in the list if (!getActiveScreenHasNext()) { if (screenUrlInfo.fileResourceRef != null) { // NOTE: don't set this.outputContentType, when including in a screen the screen determines the type sfi.ecfi.resourceFacade.template(screenUrlInfo.fileResourceRef.location, writer) return "" } else { // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection return WebUtilities.encodeHtml("Tried to render subscreen in screen [${getActiveScreenDef()?.location}] but there is no subscreens.@default-item, and no more valid subscreen names in the screen path [${screenUrlInfo.fullPathNameList}]".toString()) } } ScreenDefinition screenDef = getNextScreenDef() // check the subscreens item for this screen (valid in context) if (screenPathIndex > 0) { String curPathName = getNextScreenPathName() ScreenDefinition parentScreen = getActiveScreenDef() SubscreensItem ssi = parentScreen.getSubscreensItem(curPathName) if (ssi == null) { logger.warn("Couldn't find SubscreenItem (render): parent ${parentScreen.getScreenName()}, curPathName ${curPathName}, current ${screenDef.getScreenName()}\npath list: ${screenUrlInfo.fullPathNameList}\nscreen list: ${screenUrlInfo.screenPathDefList}") } else { if (!ssi.isValidInCurrentContext()) throw new ArtifactAuthorizationException("The screen ${screenDef.getScreenName()} is not available") } } screenPathIndex++ try { if (!stopRenderScreenLocations.contains(screenDef.getLocation())) { writer.flush() screenDef.render(this, !getActiveScreenHasNext()) writer.flush() } } catch (Throwable t) { logger.error("Error rendering screen [${screenDef.location}]", t) // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection return WebUtilities.encodeHtml("Error rendering screen [${screenDef.location}]: ${t.toString()}".toString()) } finally { screenPathIndex-- } // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } Template getTemplate() { if (macroTemplateLocation != null) { return sfi.getTemplateByLocation(macroTemplateLocation) } else { String overrideTemplateLocation = (String) null // go through entire screenPathDefList so that parent screen can override template even if it isn't rendered to decorate subscreen ArrayList screenPathDefList = screenUrlInfo.screenPathDefList int screenPathDefListSize = screenPathDefList.size() for (int i = 0; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) String curLocation = sd.getMacroTemplateLocation(renderMode) if (curLocation != null && curLocation.length() > 0) overrideTemplateLocation = curLocation } return overrideTemplateLocation != null ? sfi.getTemplateByLocation(overrideTemplateLocation) : sfi.getTemplateByMode(renderMode) } } ScreenWidgetRender getScreenWidgetRender() { ScreenWidgetRender swr = sfi.getWidgetRenderByMode(renderMode) if (swr == null) throw new BaseArtifactException("Could not find ScreenWidgerRender implementation for render mode ${renderMode}") return swr } String renderSection(String sectionName) { ScreenDefinition sd = getActiveScreenDef() try { ScreenSection section = sd.getSection(sectionName) if (section == null) throw new BaseArtifactException("No section with name [${sectionName}] in screen [${sd.location}]") writer.flush() section.render(this) writer.flush() } catch (Throwable t) { BaseException.filterStackTrace(t) logger.error("Error rendering section [${sectionName}] in screen [${sd.location}]: " + t.toString(), t) // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection return WebUtilities.encodeHtml("Error rendering section [${sectionName}] in screen [${sd.location}]: ${t.toString()}".toString()) } // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } MNode getSectionIncludedNode(MNode sectionIncludeNode) { ScreenDefinition sd = getActiveScreenDef() String sectionName = getSectionIncludeName(sectionIncludeNode) ScreenSection section = sd.getSection(sectionName) if (section == null) throw new BaseArtifactException("No section with name [${sectionName}] in screen [${sd.location}]") return section.sectionNode } String getSectionIncludeName(MNode sectionIncludeNode) { String sectionLocation = sectionIncludeNode.attribute("location") String sectionName = sectionIncludeNode.attribute("name") boolean isDynamic = (sectionLocation != null && sectionLocation.contains('${')) || (sectionName != null && sectionName.contains('${')) if (isDynamic) { ScreenDefinition sd = getActiveScreenDef() sectionLocation = sfi.ecfi.resourceFacade.expandNoL10n(sectionLocation, null) sectionName = sfi.ecfi.resourceFacade.expandNoL10n(sectionName, null) String cacheName = sectionLocation + "#" + sectionName if (sd.sectionByName.get(cacheName) == null) sd.pullSectionInclude(sectionIncludeNode) // logger.warn("sd.sectionByName ${sd.sectionByName}") return cacheName } else { return sectionName } } String renderSectionInclude(MNode sectionIncludeNode) { renderSection(getSectionIncludeName(sectionIncludeNode)) } MNode getFormNode(String formName) { FormInstance fi = getFormInstance(formName) if (fi == null) return null return fi.getFormNode() } FormInstance getFormInstance(String formName) { ScreenDefinition sd = getActiveScreenDef() String nodeCacheKey = sd.getLocation() + "#" + formName // NOTE: this is cached in the context of the renderer for multiple accesses; because of form overrides may not // be valid outside the scope of a single screen render FormInstance formNode = screenFormCache.get(nodeCacheKey) if (formNode == null) { ScreenForm form = sd.getForm(formName) if (!form) throw new BaseArtifactException("No form with name [${formName}] in screen [${sd.location}]") formNode = form.getFormInstance() screenFormCache.put(nodeCacheKey, formNode) } return formNode } String renderIncludeScreen(String location, String shareScopeStr) { boolean shareScope = shareScopeStr == "true" ContextStack cs = (ContextStack) ec.context ScreenDefinition oldOverrideActiveScreenDef = overrideActiveScreenDef try { if (!shareScope) cs.push() writer.flush() ScreenDefinition screenDef = sfi.getScreenDefinition(location) if (!screenDef) throw new BaseArtifactException("Could not find screen at location [${location}]") overrideActiveScreenDef = screenDef screenDef.render(this, false) // this way is more literal, but has issues with relative paths and such: // sfi.makeRender().rootScreen(location).renderMode(renderMode).encoding(characterEncoding) // .macroTemplate(macroTemplateLocation).render(writer) writer.flush() } catch (Throwable t) { logger.error("Error rendering screen [${location}]", t) // HTML encode by default, not ideal for non-html/xml/etc output but important for XSS protection return WebUtilities.encodeHtml("Error rendering screen [${location}]: ${t.toString()}".toString()) } finally { overrideActiveScreenDef = oldOverrideActiveScreenDef if (!shareScope) cs.pop() } // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } /** If isTemplateStr != "false" then render a template using renderer based on location extension, * or if no rendered found use isTemplateStr as an extension (like "ftl"), and if no template renderer found just write the text */ String renderText(String location, String isTemplateStr) { boolean isTemplate = !"false".equals(isTemplateStr) if (location == null || location.length() == 0 || "null".equals(location)) { logger.warn("Not rendering text in screen [${getActiveScreenDef().location}], location was empty") return "" } if (isTemplate) { writer.flush() // NOTE: run templates with their own variable space so we can add sri, and avoid getting anything added from within ContextStack cs = (ContextStack) ec.context cs.push() try { cs.put("sri", this) ec.resourceFacade.template(location, writer, isTemplateStr) } finally { cs.pop() } writer.flush() // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } else { return sfi.ecfi.resourceFacade.getLocationText(location, true) ?: "" } } String appendToAfterScreenWriter(String text) { if (afterScreenWriter == null) afterScreenWriter = new StringWriter() afterScreenWriter.append(text) // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } String getAfterScreenWriterText() { String outText = afterScreenWriter == null ? "" : afterScreenWriter.toString() afterScreenWriter = null return outText } String appendToScriptWriter(String text) { if (scriptWriter == null) scriptWriter = new StringWriter() scriptWriter.append(text) // NOTE: this returns a String so that it can be used in an FTL interpolation, but it always writes to the writer return "" } String getScriptWriterText() { String outText = scriptWriter == null ? "" : scriptWriter.toString() scriptWriter = null return outText } ScreenUrlInfo buildUrlInfo(String subscreenPathOrig) { String subscreenPath = subscreenPathOrig?.contains("\${") ? ec.resource.expand(subscreenPathOrig, "") : subscreenPathOrig List pathList = getActiveScreenPath() StringBuilder keyBuilder = new StringBuilder() for (String pathElem in pathList) keyBuilder.append(pathElem).append("/") String key = keyBuilder.append(subscreenPath).toString() ScreenUrlInfo csui = subscreenUrlInfos.get(key) if (csui != null) { // logger.warn("========== found cached ScreenUrlInfo ${key}") return csui } else { // logger.warn("========== DID NOT find cached ScreenUrlInfo ${key}") } ScreenUrlInfo sui = ScreenUrlInfo.getScreenUrlInfo(this, null, null, subscreenPath, 0) subscreenUrlInfos.put(key, sui) return sui } UrlInstance buildUrl(String subscreenPath) { return buildUrlInfo(subscreenPath).getInstance(this, null) } UrlInstance buildUrl(ScreenDefinition fromSd, ArrayList fromPathList, String subscreenPathOrig) { String subscreenPath = subscreenPathOrig?.contains("\${") ? ec.resource.expand(subscreenPathOrig, "") : subscreenPathOrig ScreenUrlInfo ui = ScreenUrlInfo.getScreenUrlInfo(this, fromSd, fromPathList, subscreenPath, 0) return ui.getInstance(this, null) } UrlInstance buildUrlFromTarget(String subscreenPathOrig) { String subscreenPath = subscreenPathOrig?.contains("\${") ? ec.resource.expand(subscreenPathOrig, "") : subscreenPathOrig ScreenUrlInfo ui = ScreenUrlInfo.getScreenUrlInfo(this, screenUrlInfo.targetScreen, screenUrlInfo.preTransitionPathNameList, subscreenPath, 0) return ui.getInstance(this, null) } UrlInstance makeUrlByType(String origUrl, String urlType, MNode parameterParentNode, String expandTransitionUrlString) { Boolean expandTransitionUrl = expandTransitionUrlString != null ? "true".equals(expandTransitionUrlString) : null /* TODO handle urlType=content: A content location (without the content://). URL will be one that can access that content. */ ScreenUrlInfo suInfo String urlTypeExpanded = ec.resource.expand(urlType, "") switch (urlTypeExpanded) { // for transition we want a URL relative to the current screen, so just pass that to buildUrl case "transition": suInfo = buildUrlInfo(origUrl); break case "screen": suInfo = buildUrlInfo(origUrl); break case "content": throw new BaseArtifactException("The url-type of content is not yet supported"); break case "plain": default: String url = ec.resource.expand(origUrl, "") suInfo = ScreenUrlInfo.getScreenUrlInfo(this, url) break } UrlInstance urli = suInfo.getInstance(this, expandTransitionUrl) if (parameterParentNode != null) { String parameterMapStr = (String) parameterParentNode.attribute("parameter-map") if (parameterMapStr != null && !parameterMapStr.isEmpty()) { Map ctxParameterMap = (Map) ec.resource.expression(parameterMapStr, "") if (ctxParameterMap) urli.addParameters(ctxParameterMap) } ArrayList parameterNodes = parameterParentNode.children("parameter") int parameterNodesSize = parameterNodes.size() for (int i = 0; i < parameterNodesSize; i++) { MNode parameterNode = (MNode) parameterNodes.get(i) String name = parameterNode.attribute("name") String from = parameterNode.attribute("from") if (from == null || from.isEmpty()) from = name urli.addParameter(name, getContextValue(from, parameterNode.attribute("value"))) } } return urli } Object getContextValue(String from, String value) { if (value) { return ec.resource.expand(value, getActiveScreenDef().location, (Map) ec.contextStack.get("_formMap")) } else if (from) { return ec.resource.expression(from, getActiveScreenDef().location, (Map) ec.contextStack.get("_formMap")) } else { return "" } } String setInContext(MNode setNode) { ((ResourceFacadeImpl) ec.resource).setInContext(setNode.attribute("field"), setNode.attribute("from"), setNode.attribute("value"), setNode.attribute("default-value"), setNode.attribute("type"), setNode.attribute("set-if-empty")) return "" } String pushContext() { ec.contextStack.push(); return "" } String popContext() { ec.contextStack.pop(); return "" } /** Call this at the beginning of a form-single or for form-list.@map-first-row and @map-last-row. Always call popContext() at the end of the form! */ String pushSingleFormMapContext(String mapExpr) { ContextStack cs = ec.contextStack Map valueMap = null if (mapExpr != null && !mapExpr.isEmpty()) valueMap = (Map) ec.resourceFacade.expression(mapExpr, null) if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap() if (valueMap == null) valueMap = new HashMap() cs.push() cs.putAll(valueMap) cs.put("_formMap", valueMap) return "" } Map getSingleFormMap(String mapExpr) { Map valueMap = null if (mapExpr != null && !mapExpr.isEmpty()) valueMap = (Map) ec.resourceFacade.expression(mapExpr, null) if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap() if (valueMap == null) valueMap = new HashMap() return valueMap } String startFormListRow(ScreenForm.FormListRenderInfo listRenderInfo, Object listEntry, int index, boolean hasNext) { ContextStack cs = ec.contextStack cs.push() if (listEntry instanceof Map) { Map valueMap = (Map) listEntry if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap() cs.putAll(valueMap) cs.put("_formMap", valueMap) } else { throw new BaseArtifactException("Found form-list ${listRenderInfo.getFormNode().attribute('name')} list entry that is not a Map, is a ${listEntry.class.name} which should never happen after running list through list pre-processor") } // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written return "" } String endFormListRow() { ec.contextStack.pop() // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written return "" } String startFormListSubRow(ScreenForm.FormListRenderInfo listRenderInfo, Object subListEntry, int index, boolean hasNext) { ContextStack cs = ec.contextStack cs.push() MNode formNode = listRenderInfo.formNode if (subListEntry instanceof Map) { Map valueMap = (Map) subListEntry if (valueMap instanceof EntityValue) valueMap = ((EntityValue) valueMap).getMap() cs.putAll(valueMap) cs.put("_formMap", valueMap) } else { throw new BaseArtifactException("Found form-list ${listRenderInfo.getFormNode().attribute('name')} sub-list entry that is not a Map, is a ${subListEntry.class.name} which should never happen after running list through list pre-processor") } String listStr = formNode.attribute('list') cs.put(listStr + "_sub_index", index) cs.put(listStr + "_sub_has_next", hasNext) cs.put(listStr + "_sub_entry", subListEntry) // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written return "" } String endFormListSubRow() { ec.contextStack.pop() // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written return "" } static String safeCloseList(Object listObject) { if (listObject instanceof EntityListIterator) ((EntityListIterator) listObject).close() // NOTE: this returns an empty String so that it can be used in an FTL interpolation, but nothing is written return "" } String getFieldValueString(MNode widgetNode) { MNode fieldNodeWrapper = widgetNode.parent.parent String defaultValue = widgetNode.attribute("default-value") if (defaultValue == null) defaultValue = "" String format = widgetNode.attribute("format") if ("text".equals(renderMode) || "csv".equals(renderMode)) { String textFormat = widgetNode.attribute("text-format") if (textFormat != null && !textFormat.isEmpty()) format = textFormat } Object obj = getFieldValue(fieldNodeWrapper, defaultValue) if (obj == null) return "" if (obj instanceof CharSequence) return obj.toString() String strValue = ec.l10nFacade.format(obj, format) return strValue } String getFieldValueString(MNode fieldNodeWrapper, String defaultValue, String format) { Object obj = getFieldValue(fieldNodeWrapper, defaultValue) if (obj == null) return "" if (obj instanceof String) return (String) obj String strValue = ec.l10nFacade.format(obj, format) return strValue } String getFieldValuePlainString(MNode fieldNodeWrapper, String defaultValue) { // NOTE: defaultValue is handled below so that for a plain string it is not run through expand Object obj = getFieldValue(fieldNodeWrapper, "") if (ObjectUtilities.isEmpty(obj) && defaultValue != null && defaultValue.length() > 0) return ec.resourceFacade.expandNoL10n(defaultValue, "") return ObjectUtilities.toPlainString(obj) } String getNamedValuePlain(String fieldName, MNode formNode) { Object value = null if ("form-single".equals(formNode.name)) { String mapAttr = formNode.attribute("map") String mapName = mapAttr != null && mapAttr.length() > 0 ? mapAttr : "fieldValues" Map valueMap = (Map) ec.resource.expression(mapName, "") if (valueMap != null) { try { if (valueMap instanceof EntityValueBase) { // if it is an EntityValueImpl, only get if the fieldName is a value EntityValueBase evb = (EntityValueBase) valueMap if (evb.getEntityDefinition().isField(fieldName)) value = evb.get(fieldName) } else { value = valueMap.get(fieldName) } } catch (EntityException e) { // do nothing, not necessarily an entity field if (isTraceEnabled) logger.trace("Ignoring entity exception for non-field: ${e.toString()}") } } } if (value == null) value = ec.contextStack.getByString(fieldName) return ObjectUtilities.toPlainString(value) } Object getFieldValue(MNode fieldNode, String defaultValue) { String fieldName = fieldNode.attribute("name") Object value = null MNode formNode = fieldNode.parent if ("form-single".equals(formNode.name)) { // if this is an error situation try error parameters first Map errorParameters = ec.getWeb()?.getErrorParameters() if (errorParameters != null && (errorParameters.moquiFormName == fieldNode.parent.attribute("name"))) { value = errorParameters.get(fieldName) if (!ObjectUtilities.isEmpty(value)) return value } // NOTE: field.@from attribute is handled for form-list in pre-processing done by AggregationUtil String fromAttr = fieldNode.attribute("from") if (fromAttr == null || fromAttr.isEmpty()) fromAttr = fieldNode.attribute("entry-name") if (fromAttr != null && fromAttr.length() > 0) return ec.resourceFacade.expression(fromAttr, null) String mapAttr = formNode.attribute("map") String mapName = mapAttr != null && mapAttr.length() > 0 ? mapAttr : "fieldValues" Map valueMap = (Map) ec.resourceFacade.expression(mapName, "") if (valueMap != null) { try { if (valueMap instanceof EntityValueBase) { // if it is an EntityValueImpl, only get if the fieldName is a value EntityValueBase evb = (EntityValueBase) valueMap if (evb.getEntityDefinition().isField(fieldName)) value = evb.get(fieldName) } else { value = valueMap.get(fieldName) } } catch (EntityException e) { // do nothing, not necessarily an entity field if (isTraceEnabled) logger.trace("Ignoring entity exception for non-field: ${e.toString()}") } } } // the value == null check here isn't necessary but is the most common case so if (value == null || ObjectUtilities.isEmpty(value)) { value = ec.contextStack.getByString(fieldName) if (!ObjectUtilities.isEmpty(value)) return value } else { return value } String defaultStr = ec.resourceFacade.expandNoL10n(defaultValue, null) if (defaultStr != null && defaultStr.length() > 0) return defaultStr return value } String getFieldValueClass(MNode fieldNodeWrapper) { Object fieldValue = getFieldValue(fieldNodeWrapper, null) return fieldValue != null ? fieldValue.getClass().getSimpleName() : "String" } String getFieldEntityValue(MNode widgetNode) { MNode fieldNode = widgetNode.parent.parent Object fieldValue = getFieldValue(fieldNode, "") if (fieldValue == null) return getDefaultText(widgetNode) String entityName = widgetNode.attribute("entity-name") EntityDefinition ed = sfi.ecfi.entityFacade.getEntityDefinition(entityName) // find the entity value String keyFieldName = widgetNode.attribute("key-field-name") if (keyFieldName == null || keyFieldName.isEmpty()) keyFieldName = widgetNode.attribute("entity-key-name") if ((keyFieldName == null || keyFieldName.isEmpty()) && ed != null) keyFieldName = ed.getPkFieldNames().get(0) String useCache = widgetNode.attribute("use-cache") ?: widgetNode.attribute("entity-use-cache") ?: "true" EntityValue ev = ec.entity.find(entityName).condition(keyFieldName, fieldValue) .useCache(useCache == "true").one() if (ev == null) return getDefaultText(widgetNode) String value = "" String text = (String) widgetNode.attribute("text") if (text != null && text.length() > 0) { // push onto the context and then expand the text ec.context.push(ev.getMap()) try { value = ec.resource.expand(text, null) } finally { ec.context.pop() } } else { // get the value of the default description field for the entity String defaultDescriptionField = ed.getDefaultDescriptionField() if (defaultDescriptionField) value = ev.get(defaultDescriptionField) } return value } protected String getDefaultText(MNode widgetNode) { String defaultText = widgetNode.attribute("default-text") if (defaultText != null && defaultText.length() > 0) { return ec.resource.expand(defaultText, null) } else { return "" } } Map getFormFieldValues(MNode formNode) { Map fieldValues = new LinkedHashMap<>() if ("true".equals(formNode.attribute("pass-through-parameters"))) fieldValues.putAll(getScreenUrlInstance().getPassThroughParameterMap()) fieldValues.put("moquiFormName", formNode.attribute("name")) String lastUpdatedString = getNamedValuePlain("lastUpdatedStamp", formNode) if (lastUpdatedString != null && !lastUpdatedString.isEmpty()) fieldValues.put("lastUpdatedStamp", lastUpdatedString) ArrayList allFieldNodes = formNode.children("field") int afnSize = allFieldNodes.size() for (int i = 0; i < afnSize; i++) { MNode fieldNode = (MNode) allFieldNodes.get(i) addFormFieldValue(fieldNode, fieldValues, (char) 'r') } return fieldValues } Map getFormListHeaderValues(MNode formNode) { Map fieldValues = new LinkedHashMap<>() // add hidden-parameters values fieldValues.putAll(getFormHiddenParameters(formNode)) ArrayList allFieldNodes = formNode.children("field") int afnSize = allFieldNodes.size() for (int i = 0; i < afnSize; i++) { MNode fieldNode = (MNode) allFieldNodes.get(i) addFormFieldValue(fieldNode, fieldValues, (char) 'h') } // add orderByField String orderByFieldAll = ec.contextStack.getByString("orderByField") if (orderByFieldAll != null && !orderByFieldAll.isEmpty()) { fieldValues.put("orderByField", new ArrayList(Arrays.asList(orderByFieldAll.split(",")))) } else { fieldValues.put("orderByField", new ArrayList()) } // formListFindId String formListFindId = ec.contextStack.getByString("formListFindId") if (formListFindId != null && !formListFindId.isEmpty()) fieldValues.put("formListFindId", formListFindId) // pageSize String listName = formNode.attribute("list") Object pageSize = ec.contextStack.getByString(listName + "PageSize") ?: ec.contextStack.getByString("pageSize") if (pageSize) fieldValues.put("pageSize", pageSize.toString()) return fieldValues } ArrayList> getFormListRowValues(ScreenForm.FormListRenderInfo renderInfo) { // get row data, aggregated if needed and row-actions run ArrayList> listObject = renderInfo.getListObject(true) return transformFormListRowList(renderInfo, listObject) } ArrayList> transformFormListRowList(ScreenForm.FormListRenderInfo renderInfo, ArrayList> listObject) { // convert raw data to formatted strings, fill in auxiliary values, etc int rowsSize = listObject.size() ArrayList> outRows = new ArrayList<>(rowsSize) for (int ri = 0; ri < rowsSize; ri++) { Map row = (Map) listObject.get(ri) outRows.add(transformFormListRow(renderInfo, row, (char) 'r')) } return outRows } Map transformFormListRow(ScreenForm.FormListRenderInfo renderInfo, Map row, char rowType) { ArrayList fieldNodeList = renderInfo.getFormNode().children("field") int fieldNodeListSize = fieldNodeList.size() Set displayedFields = renderInfo.getDisplayedFields() Set hiddenFields = renderInfo.getFormInstance().getListHiddenFieldNameSet() // logger.warn("form ${renderInfo.formNode.attribute('name')} displayed ${displayedFields} hidden ${hiddenFields}") ContextStack cs = ec.contextStack // NOTE: not using copy constructor (new LinkedHashMap<>(row)), only want relevant output fields used for client rendering and client managed form fields // this avoids _entry, _has_next, _index auto added fields, per row service output, and much more Map outRow = new LinkedHashMap<>() for (int fni = 0; fni < fieldNodeListSize; fni++) { MNode fieldNode = (MNode) fieldNodeList.get(fni) String fieldName = fieldNode.attribute("name") // logger.warn("form ${renderInfo.formNode.attribute( 'name')} field ${fieldName} raw val ${row.get(fieldName)}") if (displayedFields.contains(fieldName) || hiddenFields.contains(fieldName)) { // field values come from context so push current row, like SRI.startFormListRow() but slightly different approach with 2nd push to prevent potential writes cs.push(row) cs.push() try { addFormFieldValue(fieldNode, outRow, rowType) } finally { cs.pop() cs.pop() } } } // logger.warn("form-list row values\norig: ${JsonOutput.prettyPrint(JsonOutput.toJson(row))}\nout: ${JsonOutput.prettyPrint(JsonOutput.toJson(outRow))}") return outRow } // NOTE: this takes a fieldValues Map as a parameter to populate because a singe form field may have multiple values void addFormFieldValue(MNode fieldNode, Map fieldValues, char rowType) { String fieldName = fieldNode.attribute("name") MNode activeSubNode = (MNode) null if (rowType == (char) 'h') { activeSubNode = fieldNode.first("header-field") } else if (rowType == (char) 'f') { activeSubNode = fieldNode.first("first-row-field") } else if (rowType == (char) 's') { activeSubNode = fieldNode.first("second-row-field") } else if (rowType == (char) 'l') { activeSubNode = fieldNode.first("last-row-field") } else { ArrayList condFieldNodeList = fieldNode.children("conditional-field") for (int j = 0; j < condFieldNodeList.size(); j++) { MNode condFieldNode = (MNode) condFieldNodeList.get(j) String condition = condFieldNode.attribute("condition") if (condition == null || condition.isEmpty()) { logger.warn("Screen ${activeScreenDef.getScreenName()} field ${fieldName} conditional-field has no condition, skipping") continue } // logger.warn("condition ${condition}, eval: ${ec.resourceFacade.condition(condition, null)}") try { if (ec.resourceFacade.condition(condition, null)) { activeSubNode = condFieldNode // use first conditional-field with passing condition break } } catch (Throwable t) { logger.warn("Error evaluating condition ${condition} on field ${fieldName} on screen ${this.getActiveScreenDef().getLocation()}", t) } } if (activeSubNode == null) activeSubNode = fieldNode.first("default-field") } // logger.warn("field ${fieldName} activeSubNode ${activeSubNode?.toString()}") if (activeSubNode == null) return ArrayList childNodeList = activeSubNode.getChildren() int childNodeListSize = childNodeList.size() // check 'set' elements used with widget-template-include ArrayList setNodeList = new ArrayList<>(childNodeListSize) for (int k = 0; k < childNodeListSize; k++) { MNode widgetNode = (MNode) childNodeList.get(k) if ("set".equals(widgetNode.getName())) setNodeList.add(widgetNode) } if (setNodeList.size() > 0) { ec.contextStack.push() for (int si = 0; si < setNodeList.size(); si++) { setInContext((MNode) setNodeList.get(si)) } } for (int k = 0; k < childNodeListSize; k++) { MNode widgetNode = (MNode) childNodeList.get(k) String widgetName = widgetNode.getName() // set element used with widget-template-include, skip here if ("set".equals(widgetName)) continue String valuePlainString = getFieldValuePlainString(fieldNode, "") if (valuePlainString == null || valuePlainString.isEmpty()) valuePlainString = ec.resourceFacade.expandNoL10n(widgetNode.attribute("no-current-selected-key"), null) if (valuePlainString != null && !valuePlainString.isEmpty() && valuePlainString.charAt(0) == ('[' as char)) valuePlainString = valuePlainString.substring(1, valuePlainString.length() - 1).replaceAll(" ", "") String[] currentValueArr = valuePlainString != null && !valuePlainString.isEmpty() ? valuePlainString.split(",") : null if ("display".equals(widgetName)) { // primary value is for hidden field only, otherwise add nothing (display only) String alsoHidden = widgetNode.attribute("also-hidden") if (alsoHidden == null || alsoHidden.isEmpty() || "true".equals(alsoHidden)) fieldValues.put(fieldName, valuePlainString) // display value, reproduce logic that was in the ftl display macro String fieldValue = (String) null String textAttr = widgetNode.attribute("text") String currencyAttr = widgetNode.attribute("currency-unit-field") String currencyNoSymbolAttr = widgetNode.attribute("currency-hide-symbol") if (textAttr != null && ! textAttr.isEmpty()) { String textMapAttr = widgetNode.attribute("text-map") Map textMap = (Map) null if (textMapAttr != null && !textMapAttr.isEmpty()) textMap = (Map) ec.resourceFacade.expression(textMapAttr, null) if (textMap != null && textMap.size() > 0) { fieldValue = ec.resourceFacade.expand(textAttr, null, textMap) } else { fieldValue = ec.resourceFacade.expand(textAttr, null) } if (currencyAttr != null && !currencyAttr.isEmpty()) { if (currencyNoSymbolAttr == "true") fieldValue = ec.l10nFacade.formatCurrencyNoSymbol(fieldValue, ec.resourceFacade.expression(currencyAttr, null) as String) else fieldValue = ec.l10nFacade.formatCurrency(fieldValue, ec.resourceFacade.expression(currencyAttr, null) as String) } } else if (currencyAttr != null && !currencyAttr.isEmpty()) { if (currencyNoSymbolAttr == "true") fieldValue = ec.l10nFacade.formatCurrencyNoSymbol(getFieldValue(fieldNode, ""), ec.resourceFacade.expression(currencyAttr, null) as String) else fieldValue = ec.l10nFacade.formatCurrency(getFieldValue(fieldNode, ""), ec.resourceFacade.expression(currencyAttr, null) as String) } else { fieldValue = getFieldValueString(widgetNode) } fieldValues.put(fieldName + "_display", fieldValue) // TODO: handle dynamic-transition attribute for initial value, and dynamic on client side too } else if ("drop-down".equals(widgetName)) { boolean allowMultiple = "true".equals(ec.resourceFacade.expandNoL10n(widgetNode.attribute("allow-multiple"), null)) if (allowMultiple) { fieldValues.put(fieldName, currentValueArr != null ? new ArrayList(Arrays.asList(currentValueArr)) : null) fieldValues.put(fieldName + "_op", "in") } else { fieldValues.put(fieldName, currentValueArr != null && currentValueArr.length > 0 ? currentValueArr[0] : null) } if (ec.resourceFacade.expandNoL10n(widgetNode.attribute("show-not"), "") == "true") { fieldValues.put(fieldName + "_not", ec.contextStack.getByString(fieldName + "_not") ?: "N") } } else if ("text-line".equals(widgetName)) { fieldValues.put(fieldName, getFieldValueString(widgetNode)) } else if ("check".equals(widgetName)) { if ("true".equals(ec.resourceFacade.expandNoL10n(widgetNode.attribute("all-checked"), null))) { // get all options and add ArrayList Set fieldOptionKeys = getFieldOptions(widgetNode).keySet() fieldValues.put(fieldName, new ArrayList(fieldOptionKeys)) } else { if (currentValueArr == null || currentValueArr.length == 0) fieldValues.put(fieldName, new ArrayList()) else fieldValues.put(fieldName, new ArrayList(Arrays.asList(currentValueArr))) } } else if ("date-find".equals(widgetName)) { String type = widgetNode.attribute("type") String defaultFormat = "date".equals(type) ? "yyyy-MM-dd" : ("time".equals(type) ? "HH:mm" : "yyyy-MM-dd HH:mm") String fieldValueFrom = ec.l10nFacade.format(ec.contextStack.getByString(fieldName + "_from") ?: widgetNode.attribute("default-value-from"), defaultFormat) String fieldValueThru = ec.l10nFacade.format(ec.contextStack.getByString(fieldName + "_thru") ?: widgetNode.attribute("default-value-thru"), defaultFormat) fieldValues.put(fieldName + "_from", fieldValueFrom) fieldValues.put(fieldName + "_thru", fieldValueThru) } else if ("date-period".equals(widgetName)) { fieldValues.put(fieldName + "_poffset", ec.contextStack.getByString(fieldName + "_poffset")) fieldValues.put(fieldName + "_period", ec.contextStack.getByString(fieldName + "_period")) fieldValues.put(fieldName + "_pdate", ec.contextStack.getByString(fieldName + "_pdate")) fieldValues.put(fieldName + "_from", ec.contextStack.getByString(fieldName + "_from")) fieldValues.put(fieldName + "_thru", ec.contextStack.getByString(fieldName + "_thru")) } else if ("date-time".equals(widgetName)) { String type = widgetNode.attribute("type") String javaFormat = widgetNode.attribute("format") if (javaFormat == null) javaFormat = "date".equals(type) ? "yyyy-MM-dd" : ("time".equals(type) ? "HH:mm" : "yyyy-MM-dd HH:mm") fieldValues.put(fieldName, getFieldValueString(fieldNode, widgetNode.attribute("default-value"), javaFormat)) } else if ("display-entity".equals(widgetName)) { // primary value is for hidden field only, otherwise add nothing (display only) String alsoHidden = widgetNode.attribute("also-hidden") if (alsoHidden == null || alsoHidden.isEmpty() || "true".equals(alsoHidden)) fieldValues.put(fieldName, valuePlainString) // display value, reproduce logic that was in the ftl display macro fieldValues.put(fieldName + "_display", getFieldEntityValue(widgetNode)) } else if ("hidden".equals(widgetName)) { fieldValues.put(fieldName, getFieldValuePlainString(fieldNode, widgetNode.attribute("default-value"))) } else if ("file".equals(widgetName) || "ignored".equals(widgetName) || "password".equals(widgetName)) { // do nothing } else if ("radio".equals(widgetName)) { fieldValues.put(fieldName, getFieldValueString(fieldNode, widgetNode.attribute("no-current-selected-key"), null)) } else if ("range-find".equals(widgetName)) { fieldValues.put(fieldName + "_from", ec.contextStack.getByString(fieldName + "_from")) fieldValues.put(fieldName + "_thru", ec.contextStack.getByString(fieldName + "_thru")) } else if ("text-area".equals(widgetName)) { fieldValues.put(fieldName, getFieldValueString(widgetNode)) } else if ("text-find".equals(widgetName)) { fieldValues.put(fieldName, getFieldValueString(widgetNode)) String opName = fieldName + "_op" String opValue = ec.contextStack.getByString(opName) ?: widgetNode.attribute("default-operator") ?: "contains" fieldValues.put(opName, opValue) String notName = fieldName + "_not" String notValue = ec.contextStack.getByString(notName) fieldValues.put(notName, notValue ?: "N") String icName = fieldName + "_ic" String icAttr = widgetNode.attribute("ignore-case") String icValue = ec.contextStack.getByString(icName) if ((icValue == null || icValue.isEmpty()) && (icAttr == null || icAttr.isEmpty() || icAttr.equals("true"))) icValue = "Y" fieldValues.put(icName, icValue ?: "N") } else if (!"submit".equals(widgetName) && !"link".equals(widgetName)) { // unknown/other type fieldValues.put(fieldName, valuePlainString) } } if (setNodeList.size() > 0) ec.contextStack.pop() } LinkedHashMap getFieldOptions(MNode widgetNode) { LinkedHashMap optsMap = ScreenForm.getFieldOptions(widgetNode, ec) if (optsMap.size() == 0 && widgetNode.hasChild("dynamic-options")) { MNode childNode = widgetNode.first("dynamic-options") if (!"true".equals(childNode.attribute("server-search"))) { // a bit of a hack, use ScreenTest to call the transition server-side as if it were a web request String transition = childNode.attribute("transition") String labelField = childNode.attribute("label-field") ?: "label" String valueField = childNode.attribute("value-field") ?: "value" Map parameters = new HashMap<>() boolean hasAllDepends = addNodeParameters(childNode, parameters) // logger.warn("getFieldOptions parameters ${parameters}") if (hasAllDepends) { UrlInstance transUrl = buildUrl(transition) ScreenTest screenTest = ec.screen.makeTest().rootScreen(rootScreenLocation).skipJsonSerialize(true) ScreenTest.ScreenTestRender str = screenTest.render(transUrl.getPathWithParams(), parameters, null) Object jsonObj = str.getJsonObject() List optsList = null if (jsonObj instanceof List) { optsList = (List) jsonObj } else if (jsonObj instanceof Map) { Map jsonMap = (Map) jsonObj Object optionsObj = jsonMap.get("options") if (optionsObj instanceof List) optsList = (List) optionsObj } if (optsList != null) for (Object entryObj in optsList) { if (entryObj instanceof Map) { Map entryMap = (Map) entryObj String valueObj = entryMap.get(valueField) String labelObj = entryMap.get(labelField) if (valueObj && labelObj) optsMap.put(valueObj, labelObj) } } /* old approach before skipJsonSerialize String output = str.getOutput() try { Object jsonObj = new JsonSlurper().parseText(output) List optsList = null if (jsonObj instanceof List) { optsList = (List) jsonObj } else if (jsonObj instanceof Map) { Map jsonMap = (Map) jsonObj Object optionsObj = jsonMap.get("options") if (optionsObj instanceof List) optsList = (List) optionsObj } if (optsList != null) for (Object entryObj in optsList) { if (entryObj instanceof Map) { Map entryMap = (Map) entryObj String valueObj = entryMap.get(valueField) String labelObj = entryMap.get(labelField) if (valueObj && labelObj) optsMap.put(valueObj, labelObj) } } } catch (Throwable t) { logger.warn("Error getting field options from transition", t) } */ } } } return optsMap } /** This is messy, does a server-side/internal 'test' render so we can get the label/description for the current value * from the transition written for client access. */ String getFieldTransitionValue(String transition, MNode parameterParentNode, String term, String labelField, boolean alwaysGet) { if (!alwaysGet && (term == null || term.isEmpty())) return null if (!labelField) labelField = "label" Map parameters = new HashMap<>() parameters.put("term", term) boolean hasAllDepends = addNodeParameters(parameterParentNode, parameters) // logger.warn("getFieldTransitionValue parameters ${parameters}") // logger.warn("getFieldTransitionValue context ${ec.context.keySet()}") if (!hasAllDepends) return null UrlInstance transUrl = buildUrl(transition) ScreenTest screenTest = sfi.makeTest().rootScreen(rootScreenLocation) ScreenTest.ScreenTestRender str = screenTest.render(transUrl.getPathWithParams(), parameters, null) String output = str.getOutput() String transValue = null Object jsonObj = null try { jsonObj = new JsonSlurper().parseText(output) if (jsonObj instanceof List && ((List) jsonObj).size() > 0) { Object firstObj = ((List) jsonObj).get(0) if (firstObj instanceof Map) { transValue = ((Map) firstObj).get(labelField) } else { transValue = firstObj.toString() } } else if (jsonObj instanceof Map) { Map jsonMap = (Map) jsonObj Object optionsObj = jsonMap.get("options") if (optionsObj instanceof List && ((List) optionsObj).size() > 0) { Object firstObj = ((List) optionsObj).get(0) if (firstObj instanceof Map) { transValue = ((Map) firstObj).get(labelField) } else { transValue = firstObj.toString() } } else { transValue = jsonMap.get(labelField) } } else if (jsonObj != null) { transValue = jsonObj.toString() } } catch (Throwable t) { // this happens all the time for non-JSON text response: logger.warn("Error getting field label from transition", t) transValue = output } // logger.warn("term ${term} output ${output} transValue ${transValue}") return transValue } Map makeFormListSingleMap(ScreenForm.FormListRenderInfo renderInfo, Map listEntry, UrlInstance formTransitionUrl, String rowType) { MNode formNode = renderInfo.getFormNode() Map outMap = new LinkedHashMap<>() // add url parameter map pass through parameters first, others override outMap.putAll(formTransitionUrl.getParameterMap()) outMap.putAll(getFormHiddenParameters(formNode)) // listEntry fields before boilerplate fields below Map row = transformFormListRow(renderInfo, listEntry, rowType.charAt(0)) outMap.putAll(row) outMap.put("moquiFormName", formNode.attribute("name")) outMap.put("pageIndex", ec.contextStack.getByString("pageIndex") ?: "0") String orderByField = ec.contextStack.getByString("orderByField") if (orderByField) outMap.put("orderByField", orderByField) return outMap } Map makeFormListMultiMap(ScreenForm.FormListRenderInfo renderInfo, ArrayList> listObject, UrlInstance formTransitionUrl) { MNode formNode = renderInfo.getFormNode() Map outMap = new LinkedHashMap<>() // add url parameter map pass through parameters first, others override outMap.putAll(formTransitionUrl.getParameterMap()) outMap.putAll(getFormHiddenParameters(formNode)) // transform listObject rows to one big Map with _${rowNum} field name suffix int listSize = listObject.size() for (int i = 0; i < listSize; i++) { Map listEntry = (Map) listObject.get(i) Map row = transformFormListRow(renderInfo, listEntry, (char) 'r') for (Map.Entry mapEntry in row.entrySet()) { outMap.put(mapEntry.getKey() + "_" + i, mapEntry.getValue()) } } outMap.put("moquiFormName", formNode.attribute("name")) outMap.put("pageIndex", ec.contextStack.getByString("pageIndex") ?: "0") String orderByField = ec.contextStack.getByString("orderByField") if (orderByField) outMap.put("orderByField", orderByField) outMap.put("_isMulti", "true") return outMap } Map getFormHiddenParameters(MNode formNode) { Map parmMap = new LinkedHashMap<>() if (formNode == null) return parmMap MNode hiddenParametersNode = formNode.first("hidden-parameters") if (hiddenParametersNode == null) return parmMap Map objMap = new LinkedHashMap<>() addNodeParameters(hiddenParametersNode, objMap) for (Map.Entry entry in objMap.entrySet()) { Object valObj = entry.getValue() String valStr = ObjectUtilities.toPlainString(valObj) if (valStr != null && !valStr.isEmpty()) parmMap.put(entry.getKey(), valStr) } return parmMap } boolean addNodeParameters(MNode parameterParentNode, Map parameters) { if (parameterParentNode == null) return true // get specified parameters String parameterMapStr = (String) parameterParentNode.attribute("parameter-map") if (parameterMapStr != null && !parameterMapStr.isEmpty()) { Map ctxParameterMap = (Map) ec.resource.expression(parameterMapStr, "") if (ctxParameterMap != null) parameters.putAll(ctxParameterMap) } ArrayList parameterNodes = parameterParentNode.children("parameter") int parameterNodesSize = parameterNodes.size() for (int i = 0; i < parameterNodesSize; i++) { MNode parameterNode = (MNode) parameterNodes.get(i) String name = parameterNode.attribute("name") String from = parameterNode.attribute("from") if (from == null || from.isEmpty()) from = name parameters.put(name, getContextValue(from, parameterNode.attribute("value"))) } // get current values for depends-on fields boolean dependsOptional = "true".equals(parameterParentNode.attribute("depends-optional")) boolean hasAllDepends = true ArrayList doNodeList = parameterParentNode.children("depends-on") for (int i = 0; i < doNodeList.size(); i++) { MNode doNode = (MNode) doNodeList.get(i) String doField = doNode.attribute("field") String doParameter = doNode.attribute("parameter") ?: doField Object contextVal = ec.contextStack.get(doField) if (ObjectUtilities.isEmpty(contextVal) && ec.contextStack.get("_formMap") != null) contextVal = ((Map) ec.contextStack.get("_formMap")).get(doField) if (ObjectUtilities.isEmpty(contextVal)) { hasAllDepends = false } else { parameters.put(doParameter, contextVal) } } return hasAllDepends || dependsOptional } boolean isInCurrentScreenPath(List pathNameList) { if (pathNameList.size() > screenUrlInfo.fullPathNameList.size()) return false for (int i = 0; i < pathNameList.size(); i++) { if (pathNameList.get(i) != screenUrlInfo.fullPathNameList.get(i)) return false } return true } boolean isActiveInCurrentMenu() { List currentScreenPath = screenUrlInfo ? new ArrayList(screenUrlInfo.fullPathNameList) : null for (SubscreensItem ssi in getActiveScreenDef().subscreensByName.values()) { if (!ssi.menuInclude) continue ScreenUrlInfo urlInfo = buildUrlInfo(ssi.name) if (urlInfo.getInCurrentScreenPath(currentScreenPath)) return true } return false } boolean isAnchorLink(MNode linkNode, UrlInstance urlInstance) { String linkType = linkNode.attribute("link-type") String urlType = linkNode.attribute("url-type") return ("anchor".equals(linkType) || "anchor-button".equals(linkType)) || ((!linkType || "auto".equals(linkType)) && ((urlType && !urlType.equals("transition")) || (urlInstance.isReadOnly()))) } UrlInstance getCurrentScreenUrl() { return screenUrlInstance } URI getBaseLinkUri() { String urlString = baseLinkUrl ?: screenUrlInstance.getScreenPathUrl() // logger.warn("=================== urlString=${urlString}, baseLinkUrl=${baseLinkUrl}") URL blu = new URL(urlString) // NOTE: not including user info, query, or fragment... should consider them? // NOTE: using the multi-argument constructor so it will encode stuff URI baseUri = new URI(blu.getProtocol(), null, blu.getHost(), blu.getPort(), blu.getPath(), null, null) return baseUri } String getCurrentThemeId() { if (curThemeId != null) return curThemeId String stteId = null // loop through only screens to render and look for @screen-theme-type-enum-id, use last one found ArrayList screenPathDefList = screenUrlInfo.screenPathDefList int screenPathDefListSize = screenPathDefList.size() for (int i = screenUrlInfo.renderPathDifference; i < screenPathDefListSize; i++) { ScreenDefinition sd = (ScreenDefinition) screenPathDefList.get(i) String stteiStr = sd.screenNode.attribute("screen-theme-type-enum-id") if (stteiStr != null && stteiStr.length() > 0) stteId = stteiStr } // if no setting default to STT_INTERNAL if (stteId == null) stteId = "STT_INTERNAL" EntityFacadeImpl entityFacade = sfi.ecfi.entityFacade // see if there is a user setting for the theme String themeId = entityFacade.fastFindOne("moqui.security.UserScreenTheme", true, true, ec.userFacade.userId, stteId)?.screenThemeId // if no user theme see if group a user is in has a theme if (themeId == null || themeId.length() == 0) { // use reverse alpha so ALL_USERS goes last... List userGroupIdSet = new ArrayList(new TreeSet(ec.user.getUserGroupIdSet())).reverse(true) EntityList groupThemeList = entityFacade.find("moqui.security.UserGroupScreenTheme") .condition("userGroupId", "in", userGroupIdSet).condition("screenThemeTypeEnumId", stteId) .orderBy("sequenceNum,-userGroupId").useCache(true).disableAuthz().list() if (groupThemeList.size() > 0) themeId = groupThemeList.first().screenThemeId } // use the Enumeration.enumCode from the type to find the theme type's default screenThemeId if (themeId == null || themeId.length() == 0) { EntityValue themeTypeEnum = entityFacade.fastFindOne("moqui.basic.Enumeration", true, true, stteId) if (themeTypeEnum?.enumCode) themeId = themeTypeEnum.enumCode } // theme with "DEFAULT" in the ID if (themeId == null || themeId.length() == 0) { EntityValue stv = entityFacade.find("moqui.screen.ScreenTheme") .condition("screenThemeTypeEnumId", stteId) .condition("screenThemeId", ComparisonOperator.LIKE, "%DEFAULT%").disableAuthz().one() if (stv) themeId = stv.screenThemeId } curThemeId = themeId ?: "" return themeId } ArrayList getThemeValues(String resourceTypeEnumId) { return getThemeValues(resourceTypeEnumId, null) } ArrayList getThemeValues(String resourceTypeEnumId, String screenThemeId) { boolean currentTheme = screenThemeId == null || screenThemeId.isEmpty() || "null".equals(screenThemeId) if (currentTheme) { screenThemeId = getCurrentThemeId() ArrayList cachedList = (ArrayList) curThemeValuesByType.get(resourceTypeEnumId) if (cachedList != null) return cachedList } EntityList strList = sfi.ecfi.entityFacade.find("moqui.screen.ScreenThemeResource") .condition("screenThemeId", screenThemeId).condition("resourceTypeEnumId", resourceTypeEnumId) .orderBy("sequenceNum").useCache(true).disableAuthz().list() int strListSize = strList.size() ArrayList values = new ArrayList<>(strListSize) for (int i = 0; i < strListSize; i++) { EntityValue str = (EntityValue) strList.get(i) String resourceValue = (String) str.getNoCheckSimple("resourceValue") if (resourceValue != null && !resourceValue.isEmpty()) values.add(resourceValue) } if (currentTheme) curThemeValuesByType.put(resourceTypeEnumId, values) return values } // NOTE: this is called a LOT during screen renders, for links/buttons/etc String getThemeIconClass(String text) { String screenThemeId = getCurrentThemeId() Map curThemeIconByText = sfi.getThemeIconByText(screenThemeId) if (curThemeIconByText.containsKey(text)) return curThemeIconByText.get(text) EntityList stiList = sfi.ecfi.entityFacade.find("moqui.screen.ScreenThemeIcon") .condition("screenThemeId", screenThemeId).useCache(true).disableAuthz().list() int stiListSize = stiList.size() String iconClass = (String) null for (int i = 0; i < stiListSize; i++) { EntityValue sti = (EntityValue) stiList.get(i) if (text.matches(sti.getString("textPattern"))) { iconClass = sti.getString("iconClass") break } } curThemeIconByText.put(text, iconClass) return iconClass } List getMenuData(ArrayList pathNameList) { if (!ec.user.userId) { ec.web.sendJsonError(401, "Authentication required", null); return null } ScreenUrlInfo fullUrlInfo = ScreenUrlInfo.getScreenUrlInfo(this, rootScreenDef, pathNameList, null, 0) if (!fullUrlInfo.targetExists) { ec.web.sendJsonError(404, "Screen not found for path ${pathNameList}", null); return null } UrlInstance fullUrlInstance = fullUrlInfo.getInstance(this, null) if (!fullUrlInstance.isPermitted()) { ec.web.sendJsonError(403, "View not permitted for path ${pathNameList}", null); return null } ArrayList fullPathList = fullUrlInfo.fullPathNameList int fullPathSize = fullPathList.size() ArrayList extraPathList = fullUrlInfo.extraPathNameList int extraPathSize = extraPathList != null ? extraPathList.size() : 0 if (extraPathSize > 0) { fullPathSize -= extraPathSize fullPathList = new ArrayList(fullPathList.subList(0, fullPathSize)) } StringBuilder currentPath = new StringBuilder() List menuDataList = new LinkedList<>() ScreenDefinition curScreen = rootScreenDef // to support menu titles with values set in pre-actions: run pre-actions for all screens in path except first 2 (generally webroot, apps) ec.artifactExecutionFacade.setAnonymousAuthorizedView() ec.userFacade.loginAnonymousIfNoUser() ArrayList preActionSds = new ArrayList<>(fullUrlInfo.screenPathDefList.subList(2, fullUrlInfo.screenPathDefList.size())) int preActionSdSize = preActionSds.size() for (int i = 0; i < preActionSdSize; i++) { ScreenDefinition sd = (ScreenDefinition) preActionSds.get(i) if (sd.preActions != null) { try { sd.preActions.run(ec) } catch (Throwable t) { logger.warn("Error running pre-actions in ${sd.getLocation()} while getting menu data: " + t.toString()) } } } for (int i = 0; i < (fullPathSize - 1); i++) { String pathItem = (String) fullPathList.get(i) String nextItem = (String) fullPathList.get(i+1) currentPath.append('/').append(StringUtilities.urlEncodeIfNeeded(pathItem)) SubscreensItem curSsi = curScreen.getSubscreensItem(pathItem) // already checked for exists above, path may have extra path elements beyond the screen so allow it if (curSsi == null) break curScreen = ec.screenFacade.getScreenDefinition(curSsi.location) List subscreensList = new LinkedList<>() ArrayList menuItems = curScreen.getSubscreensItemsSorted() int menuItemsSize = menuItems.size() for (int j = 0; j < menuItemsSize; j++) { SubscreensItem subscreensItem = (SubscreensItem) menuItems.get(j) // include active subscreen even if not normally in menu if (!subscreensItem.menuInclude && subscreensItem.name != nextItem) continue // valid in current context? (user group, etc) if (!subscreensItem.isValidInCurrentContext()) continue String screenPath = new StringBuilder(currentPath).append('/').append(StringUtilities.urlEncodeIfNeeded(subscreensItem.name)).toString() UrlInstance screenUrlInstance = buildUrl(screenPath) ScreenUrlInfo sui = screenUrlInstance.sui if (!screenUrlInstance.isPermitted()) continue // build this subscreen's pathWithParams String pathWithParams = "/" + sui.preTransitionPathNameList.join("/") Map parmMap = screenUrlInstance.getParameterMap() // check for missing required parameters boolean parmMissing = false for (ScreenDefinition.ParameterItem pi in sui.pathParameterItems.values()) { if (!pi.required) continue String parmValue = parmMap.get(pi.name) if (parmValue == null || parmValue.isEmpty()) { parmMissing = true; break } } // if there is a parameter missing skip the subscreen if (parmMissing) continue String parmString = screenUrlInstance.getParameterString() if (!parmString.isEmpty()) pathWithParams += ('?' + parmString) String image = sui.menuImage String imageType = sui.menuImageType if (image != null && !image.isEmpty() && (imageType == null || imageType.isEmpty() || "url-screen".equals(imageType))) image = buildUrl(image).url boolean active = (nextItem == subscreensItem.name) Map itemMap = [name:subscreensItem.name, title:ec.resource.expand(subscreensItem.menuTitle, ""), path:screenPath, pathWithParams:pathWithParams, image:image, imageType:imageType] if (subscreensItem.menuInclude) itemMap.menuInclude = true if (active) itemMap.active = true if (screenUrlInstance.disableLink) itemMap.disableLink = true subscreensList.add(itemMap) // not needed: screenStatic:sui.targetScreen.isServerStatic(renderMode) } String curScreenPath = currentPath.toString() UrlInstance curUrlInstance = buildUrl(curScreenPath) String curPathWithParams = curScreenPath String curParmString = curUrlInstance.getParameterString() if (!curParmString.isEmpty()) curPathWithParams = curPathWithParams + '?' + curParmString ScreenUrlInfo sui = curUrlInstance.sui String image = sui.menuImage String imageType = sui.menuImageType if (image != null && !image.isEmpty() && (imageType == null || imageType.isEmpty() || "url-screen".equals(imageType))) image = buildUrl(image).url String menuTitle = ec.l10n.localize(curSsi.menuTitle) ?: curScreen.getDefaultMenuName() menuDataList.add([name:pathItem, title:menuTitle, subscreens:subscreensList, path:curScreenPath, pathWithParams:curPathWithParams, hasTabMenu:curScreen.hasTabMenu(), renderModes:curScreen.renderModes, image:image, imageType:imageType]) // not needed: screenStatic:curScreen.isServerStatic(renderMode) } String lastPathItem = (String) fullPathList.get(fullPathSize - 1) fullUrlInstance.addParameters(ec.web.getRequestParameters()) currentPath.append('/').append(StringUtilities.urlEncodeIfNeeded(lastPathItem)) String lastPath = currentPath.toString() String paramString = fullUrlInstance.getParameterString() if (paramString.length() > 0) currentPath.append('?').append(paramString) String lastImage = fullUrlInfo.menuImage String lastImageType = fullUrlInfo.menuImageType if (lastImage != null && !lastImage.isEmpty() && (lastImageType == null || lastImageType.isEmpty() || "url-screen".equals(lastImageType))) lastImage = buildUrl(lastImage).url SubscreensItem lastSsi = curScreen.getSubscreensItem(lastPathItem) String lastTitle = ec.l10n.localize(lastSsi?.menuTitle) ?: fullUrlInfo.targetScreen.getDefaultMenuName() if (lastTitle.contains('${')) lastTitle = ec.resourceFacade.expand(lastTitle, "") List> screenDocList = fullUrlInfo.targetScreen.getScreenDocumentInfoList() // look for form-list with saved find on target screen, if so look for saved finds available to user to display in menu List savedFindsList = new LinkedList<>() ScreenDefinition targetScreen = fullUrlInfo.getTargetScreen() ArrayList formList = targetScreen.getAllForms() for (int i = 0; i < formList.size(); i++) { ScreenForm screenForm = (ScreenForm) formList.get(i) if (screenForm.isFormList && "true".equals(screenForm.internalFormNode.attribute("saved-finds"))) { // is a saved find active (or has default)? String formListFindId = ec.contextStack.getByString("formListFindId") if (formListFindId == null || formListFindId.isEmpty()) formListFindId = screenForm.getUserDefaultFormListFindId(ec) // add data for saved finds List> userFlfList = screenForm.getUserFormListFinds(ec) for (Map userFlf in userFlfList) { EntityValue formListFind = (EntityValue) userFlf.formListFind Map itemMap = [name:formListFind.formListFindId, title:formListFind.description, image:lastImage, imageType:lastImageType, path:lastPath, pathWithParams:(lastPath + "?formListFindId=" + formListFind.formListFindId)] if (formListFindId != null && formListFindId.equals(formListFind.formListFindId)) itemMap.active = true savedFindsList.add(itemMap) } } } if (extraPathList != null) { int extraPathListSize = extraPathList.size() for (int i = 0; i < extraPathListSize; i++) extraPathList.set(i, StringUtilities.urlEncodeIfNeeded((String) extraPathList.get(i))) } Map lastMap = [name:lastPathItem, title:lastTitle, path:lastPath, pathWithParams:currentPath.toString(), image:lastImage, imageType:lastImageType, extraPathList:extraPathList, screenDocList:screenDocList, renderModes:fullUrlInfo.targetScreen.renderModes, savedFinds:savedFindsList] menuDataList.add(lastMap) // not needed: screenStatic:fullUrlInfo.targetScreen.isServerStatic(renderMode) // for (Map info in menuDataList) logger.warn("menu data item: ${info}") return menuDataList } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenSection.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.codehaus.groovy.runtime.InvokerHelper import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.util.CollectionUtilities import org.moqui.util.ContextStack import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.moqui.util.WebUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ScreenSection { protected final static Logger logger = LoggerFactory.getLogger(ScreenSection.class) protected MNode sectionNode protected String location protected Class conditionClass = null protected XmlAction condition = null protected XmlAction actions = null protected ScreenWidgets widgets = null protected ScreenWidgets failWidgets = null ScreenSection(ExecutionContextFactoryImpl ecfi, MNode sectionNode, String location) { this.sectionNode = sectionNode this.location = location // prep condition attribute String conditionAttr = sectionNode.attribute("condition") if (conditionAttr) conditionClass = ecfi.getGroovyClassLoader().parseClass(conditionAttr) // prep condition element if (sectionNode.first("condition")?.first() != null) { // the script is effectively the first child of the condition element condition = new XmlAction(ecfi, sectionNode.first("condition").first(), location + ".condition") } // prep actions if (sectionNode.hasChild("actions")) { actions = new XmlAction(ecfi, sectionNode.first("actions"), location + ".actions") // if (location.contains("FOO")) logger.warn("====== Actions for ${location}: ${actions.writeGroovyWithLines()}") } // prep widgets if (sectionNode.hasChild("widgets")) { if (sectionNode.getName() == "screen") { MNode widgetsNode = sectionNode.first("widgets") MNode screenNode = new MNode("screen", null, null, [widgetsNode], null) widgets = new ScreenWidgets(screenNode, location + ".widgets") } else { widgets = new ScreenWidgets(sectionNode.first("widgets"), location + ".widgets") } } // prep fail-widgets if (sectionNode.hasChild("fail-widgets")) failWidgets = new ScreenWidgets(sectionNode.first("fail-widgets"), location + ".fail-widgets") } @CompileStatic void render(ScreenRenderImpl sri) { ContextStack cs = sri.ec.contextStack if (sectionNode.name == "section-iterate") { String listName = sectionNode.attribute("list") Object list = sri.ec.resourceFacade.expression(listName, null) // if nothing to iterate over, all done if (!list) { if (logger.traceEnabled) logger.trace("Target list [${list}] is empty, not rendering section-iterate at [${location}]") return } boolean paginate = "true".equals(sectionNode.attribute("paginate")) Iterator listIterator = null if (paginate) { cs.push() if (list instanceof List) { List pageList = CollectionUtilities.paginateList((List) list, listName, cs) listIterator = pageList.iterator() } else { throw new IllegalArgumentException("section-iterate paginate requires a List, found type ${list?.class?.name}") } } else { if (list instanceof Iterator) listIterator = (Iterator) list else if (list instanceof Map) listIterator = ((Map) list).entrySet().iterator() else if (list instanceof Iterable) listIterator = ((Iterable) list).iterator() } String sectionEntry = sectionNode.attribute("entry") String sectionKey = sectionNode.attribute("key") int index = 0 while (listIterator != null && listIterator.hasNext()) { Object entry = listIterator.next() cs.push() try { cs.put(sectionEntry, (entry instanceof Map.Entry ? entry.getValue() : entry)) if (sectionKey && entry instanceof Map.Entry) cs.put(sectionKey, entry.getKey()) cs.put("sectionEntryIndex", index) cs.put(sectionEntry + "_index", index) cs.put(sectionEntry + "_has_next", listIterator.hasNext()) renderSingle(sri) } finally { cs.pop() } index++ } if (paginate) { cs.pop() } } else { // NOTE: don't push/pop context for normal sections, for root section want to be able to share-scope when it // is included by another screen so that fields set will be in context of other screen renderSingle(sri) } } @CompileStatic protected void renderSingle(ScreenRenderImpl sri) { if (logger.traceEnabled) logger.trace("Begin rendering screen section at [${location}]") ExecutionContextImpl ec = sri.ec boolean conditionPassed = true boolean skipActions = sri.sfi.isRenderModeSkipActions(sri.renderMode) if (!skipActions) { if (condition != null) conditionPassed = condition.checkCondition(ec) if (conditionPassed && conditionClass != null) { Script script = InvokerHelper.createScript(conditionClass, ec.getContextBinding()) Object result = script.run() conditionPassed = result as boolean } } if (conditionPassed) { if (!skipActions && actions != null) actions.run(ec) if (widgets != null) { // was there an error in the actions? don't try to render the widgets, likely to be more and more errors if (ec.message.hasError()) { sri.writer.append(WebUtilities.encodeHtml(ec.message.getErrorsString())) } else { // render the widgets widgets.render(sri) } } } else { if (failWidgets != null) failWidgets.render(sri) } if (logger.traceEnabled) logger.trace("End rendering screen section at [${location}]") } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenTestImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.apache.shiro.subject.Subject import org.moqui.BaseArtifactException import org.moqui.util.ContextStack import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.screen.ScreenRender import org.moqui.screen.ScreenTest import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.Future @CompileStatic class ScreenTestImpl implements ScreenTest { protected final static Logger logger = LoggerFactory.getLogger(ScreenTestImpl.class) protected final ExecutionContextFactoryImpl ecfi protected final ScreenFacadeImpl sfi // see FtlTemplateRenderer.MoquiTemplateExceptionHandler, others final List errorStrings = ["[Template Error", "FTL stack trace", "Could not find subscreen or transition"] protected String rootScreenLocation = null protected ScreenDefinition rootScreenDef = null protected String baseScreenPath = null protected List baseScreenPathList = null protected ScreenDefinition baseScreenDef = null protected String outputType = null protected String characterEncoding = null protected String macroTemplateLocation = null protected String baseLinkUrl = null protected String servletContextPath = null protected String webappName = null protected boolean skipJsonSerialize = false protected static final String hostname = "localhost" long renderCount = 0, errorCount = 0, totalChars = 0, startTime = System.currentTimeMillis() final Map sessionAttributes = [:] ScreenTestImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi sfi = ecfi.screenFacade // init default webapp, root screen webappName('webroot') } @Override ScreenTest rootScreen(String screenLocation) { rootScreenLocation = screenLocation rootScreenDef = sfi.getScreenDefinition(rootScreenLocation) if (rootScreenDef == null) throw new IllegalArgumentException("Root screen not found: ${rootScreenLocation}") baseScreenDef = rootScreenDef return this } @Override ScreenTest baseScreenPath(String screenPath) { if (!rootScreenLocation) throw new BaseArtifactException("No rootScreen specified") baseScreenPath = screenPath if (baseScreenPath.endsWith("/")) baseScreenPath = baseScreenPath.substring(0, baseScreenPath.length() - 1) if (baseScreenPath) { baseScreenPathList = ScreenUrlInfo.parseSubScreenPath(rootScreenDef, rootScreenDef, [], baseScreenPath, null, sfi) if (baseScreenPathList == null) throw new BaseArtifactException("Error in baseScreenPath, could find not base screen path ${baseScreenPath} under ${rootScreenDef.location}") for (String screenName in baseScreenPathList) { ScreenDefinition.SubscreensItem ssi = baseScreenDef.getSubscreensItem(screenName) if (ssi == null) throw new BaseArtifactException("Error in baseScreenPath, could not find ${screenName} under ${baseScreenDef.location}") baseScreenDef = sfi.getScreenDefinition(ssi.location) if (baseScreenDef == null) throw new BaseArtifactException("Error in baseScreenPath, could not find screen ${screenName} at ${ssi.location}") } } return this } @Override ScreenTest renderMode(String outputType) { this.outputType = outputType; return this } @Override ScreenTest encoding(String characterEncoding) { this.characterEncoding = characterEncoding; return this } @Override ScreenTest macroTemplate(String macroTemplateLocation) { this.macroTemplateLocation = macroTemplateLocation; return this } @Override ScreenTest baseLinkUrl(String baseLinkUrl) { this.baseLinkUrl = baseLinkUrl; return this } @Override ScreenTest servletContextPath(String scp) { this.servletContextPath = scp; return this } @Override ScreenTest skipJsonSerialize(boolean skip) { this.skipJsonSerialize = skip; return this } @Override ScreenTest webappName(String wan) { webappName = wan // set a default root screen based on config for "localhost" MNode webappNode = ecfi.getWebappNode(webappName) for (MNode rootScreenNode in webappNode.children("root-screen")) { if (hostname.matches(rootScreenNode.attribute('host'))) { String rsLoc = rootScreenNode.attribute('location') rootScreen(rsLoc) break } } return this } @Override List getNoRequiredParameterPaths(Set screensToSkip) { if (!rootScreenLocation) throw new IllegalStateException("No rootScreen specified") List noReqParmLocations = baseScreenDef.nestedNoReqParmLocations("", screensToSkip) // logger.info("======= rootScreenLocation=${rootScreenLocation}\nbaseScreenPath=${baseScreenPath}\nbaseScreenDef: ${baseScreenDef.location}\nnoReqParmLocations: ${noReqParmLocations}") return noReqParmLocations } @Override ScreenTestRender render(String screenPath, Map parameters, String requestMethod) { if (!rootScreenLocation) throw new IllegalArgumentException("No rootScreenLocation specified") return new ScreenTestRenderImpl(this, screenPath, parameters, requestMethod).render() } @Override void renderAll(List screenPathList, Map parameters, String requestMethod) { // NOTE: using single thread for now, doesn't actually make a lot of difference in overall test run time int threads = 1 if (threads == 1) { for (String screenPath in screenPathList) { ScreenTestRender str = render(screenPath, parameters, requestMethod) logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters") } } else { ExecutionContextImpl eci = ecfi.getEci() ArrayList threadList = new ArrayList(threads) int screenPathListSize = screenPathList.size() for (int si = 0; si < screenPathListSize; si++) { String screenPath = (String) screenPathList.get(si) threadList.add(eci.runAsync({ ScreenTestRender str = render(screenPath, parameters, requestMethod) logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms, ${str.output?.length()} characters") })) if (threadList.size() == threads || (si + 1) == screenPathList.size()) { for (int i = 0; i < threadList.size(); i++) { ((Future) threadList.get(i)).get() } threadList.clear() } } } } long getRenderCount() { return renderCount } long getErrorCount() { return errorCount } long getRenderTotalChars() { return totalChars } long getStartTime() { return startTime } @CompileStatic static class ScreenTestRenderImpl implements ScreenTestRender { protected final ScreenTestImpl sti String screenPath = (String) null Map parameters = [:] String requestMethod = (String) null ScreenRender screenRender = (ScreenRender) null String outputString = (String) null Object jsonObj = null long renderTime = 0 Map postRenderContext = (Map) null protected List errorMessages = [] ScreenTestRenderImpl(ScreenTestImpl sti, String screenPath, Map parameters, String requestMethod) { this.sti = sti this.screenPath = screenPath if (parameters != null) this.parameters.putAll(parameters) this.requestMethod = requestMethod } ScreenTestRender render() { // render in separate thread with an independent ExecutionContext so it doesn't muck up the current one ExecutionContextFactoryImpl ecfi = sti.ecfi ExecutionContextImpl localEci = ecfi.getEci() String username = localEci.userFacade.getUsername() Subject loginSubject = localEci.userFacade.getCurrentSubject() boolean authzDisabled = localEci.artifactExecutionFacade.getAuthzDisabled() ScreenTestRenderImpl stri = this Throwable threadThrown = null Thread newThread = new Thread("ScreenTestRender") { @Override void run() { try { ExecutionContextImpl threadEci = ecfi.getEci() if (loginSubject != null) threadEci.userFacade.internalLoginSubject(loginSubject) else if (username != null && !username.isEmpty()) threadEci.userFacade.internalLoginUser(username) if (authzDisabled) threadEci.artifactExecutionFacade.disableAuthz() // as this is used for server-side transition calls don't do tarpit checks threadEci.artifactExecutionFacade.disableTarpit() renderInternal(threadEci, stri) threadEci.destroy() } catch (Throwable t) { threadThrown = t } } } newThread.start() newThread.join() if (threadThrown != null) throw threadThrown return this } private static void renderInternal(ExecutionContextImpl eci, ScreenTestRenderImpl stri) { ScreenTestImpl sti = stri.sti long startTime = System.currentTimeMillis() // parse the screenPath ArrayList screenPathList = ScreenUrlInfo.parseSubScreenPath(sti.rootScreenDef, sti.baseScreenDef, sti.baseScreenPathList, stri.screenPath, stri.parameters, sti.sfi) if (screenPathList == null) throw new BaseArtifactException("Could not find screen path ${stri.screenPath} under base screen ${sti.baseScreenDef.location}") // push the context ContextStack cs = eci.getContext() cs.push() // create the WebFacadeStub WebFacadeStub wfs = new WebFacadeStub(sti.ecfi, stri.parameters, sti.sessionAttributes, stri.requestMethod) // set stub on eci, will also put parameters in the context eci.setWebFacade(wfs) // make the ScreenRender ScreenRender screenRender = sti.sfi.makeRender() stri.screenRender = screenRender // pass through various settings if (sti.rootScreenLocation != null && sti.rootScreenLocation.length() > 0) screenRender.rootScreen(sti.rootScreenLocation) if (sti.outputType != null && sti.outputType.length() > 0) screenRender.renderMode(sti.outputType) if (sti.characterEncoding != null && sti.characterEncoding.length() > 0) screenRender.encoding(sti.characterEncoding) if (sti.macroTemplateLocation != null && sti.macroTemplateLocation.length() > 0) screenRender.macroTemplate(sti.macroTemplateLocation) if (sti.baseLinkUrl != null && sti.baseLinkUrl.length() > 0) screenRender.baseLinkUrl(sti.baseLinkUrl) if (sti.servletContextPath != null && sti.servletContextPath.length() > 0) screenRender.servletContextPath(sti.servletContextPath) screenRender.webappName(sti.webappName) if (sti.skipJsonSerialize) wfs.skipJsonSerialize = true // set the screenPath screenRender.screenPath(screenPathList) // do the render try { screenRender.render(wfs.httpServletRequest, wfs.httpServletResponse) // get the response text from the WebFacadeStub stri.outputString = wfs.getResponseText() stri.jsonObj = wfs.getResponseJsonObj() } catch (Throwable t) { String errMsg = "Exception in render of ${stri.screenPath}: ${t.toString()}" logger.warn(errMsg, t) stri.errorMessages.add(errMsg) sti.errorCount++ } // calc renderTime stri.renderTime = System.currentTimeMillis() - startTime // pop the context stack, get rid of var space stri.postRenderContext = cs.pop() // check, pass through, error messages if (eci.message.hasError()) { stri.errorMessages.addAll(eci.message.getErrors()) eci.message.clearErrors() StringBuilder sb = new StringBuilder("Error messages from ${stri.screenPath}: ") for (String errorMessage in stri.errorMessages) sb.append("\n").append(errorMessage) logger.warn(sb.toString()) sti.errorCount += stri.errorMessages.size() } // check for error strings in output if (stri.outputString != null) for (String errorStr in sti.errorStrings) if (stri.outputString.contains(errorStr)) { String errMsg = "Found error [${errorStr}] in output from ${stri.screenPath}" stri.errorMessages.add(errMsg) sti.errorCount++ logger.warn(errMsg) } // update stats sti.renderCount++ if (stri.outputString != null) sti.totalChars += stri.outputString.length() } @Override ScreenRender getScreenRender() { return screenRender } @Override String getOutput() { return outputString } @Override Object getJsonObject() { return jsonObj } @Override long getRenderTime() { return renderTime } @Override Map getPostRenderContext() { return postRenderContext } @Override List getErrorMessages() { return errorMessages } @Override boolean assertContains(String text) { if (!outputString) return false return outputString.contains(text) } @Override boolean assertNotContains(String text) { if (!outputString) return true return !outputString.contains(text) } @Override boolean assertRegex(String regex) { if (!outputString) return false return outputString.matches(regex) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenTree.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.moqui.util.ContextStack import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ScreenTree { protected final static Logger logger = LoggerFactory.getLogger(ScreenTree.class) protected ExecutionContextFactoryImpl ecfi protected ScreenDefinition sd protected MNode treeNode protected String location // protected Map parameterByName = [:] protected Map nodeByName = [:] protected List subNodeList = [] ScreenTree(ExecutionContextFactoryImpl ecfi, ScreenDefinition sd, MNode treeNode, String location) { this.ecfi = ecfi this.sd = sd this.treeNode = treeNode this.location = location // prep tree-node for (MNode treeNodeNode in treeNode.children("tree-node")) nodeByName.put(treeNodeNode.attribute("name"), new TreeNode(this, treeNodeNode, location + ".node." + treeNodeNode.attribute("name"))) // prep tree-sub-node for (MNode treeSubNodeNode in treeNode.children("tree-sub-node")) subNodeList.add(new TreeSubNode(this, treeSubNodeNode, location + ".subnode." + treeSubNodeNode.attribute("node-name"))) } void sendSubNodeJson() { // NOTE: This method is very specific to jstree ExecutionContextImpl eci = ecfi.getEci() ContextStack cs = eci.getContext() // logger.warn("========= treeNodeId = ${cs.get("treeNodeId")}") // if this is the root node get the main tree sub-nodes, otherwise find the node and use its sub-nodes List currentSubNodeList = null if (cs.get("treeNodeId") == "#") { currentSubNodeList = subNodeList } else { // logger.warn("======== treeNodeName = ${cs.get("treeNodeName")}") if (cs.get("treeNodeName")) currentSubNodeList = nodeByName.get(cs.get("treeNodeName"))?.subNodeList if (currentSubNodeList == null) { // if no treeNodeName passed through just use the first defined node, though this shouldn't happen logger.warn("No treeNodeName passed in request for child nodes for node [${cs.get("treeNodeId")}] in tree [${this.location}], using first node in tree definition.") currentSubNodeList = nodeByName.values().first().subNodeList } } List outputNodeList = getChildNodes(currentSubNodeList, eci, cs, true) // logger.warn("========= outputNodeList = ${outputNodeList}") eci.getWeb().sendJsonResponse(outputNodeList) } List getChildNodes(List currentSubNodeList, ExecutionContextImpl eci, ContextStack cs, boolean recurse) { List outputNodeList = [] for (TreeSubNode tsn in currentSubNodeList) { // check condition if (tsn.condition != null && !tsn.condition.checkCondition(eci)) continue // run actions if (tsn.actions != null) tsn.actions.run(eci) TreeNode tn = nodeByName.get(tsn.treeSubNodeNode.attribute("node-name")) // iterate over the list and add a response node for each entry String nodeListName = tsn.treeSubNodeNode.attribute("list") ?: "nodeList" List nodeList = (List) eci.getResource().expression(nodeListName, "") // logger.warn("======= nodeList named [${nodeListName}]: ${nodeList}") Iterator i = nodeList?.iterator() int index = 0 while (i?.hasNext()) { Object nodeListEntry = i.next() cs.push() try { cs.put("nodeList_entry", nodeListEntry) cs.put("nodeList_index", index) cs.put("nodeList_has_next", i.hasNext()) // check condition if (tn.condition != null && !tn.condition.checkCondition(eci)) continue // run actions if (tn.actions != null) tn.actions.run(eci) MNode showNode = tn.linkNode != null ? tn.linkNode : tn.labelNode String id = eci.getResource().expand((String) showNode.attribute("id"), tn.location + ".id") String text = eci.getResource().expand((String) showNode.attribute("text"), tn.location + ".text") Map aAttrMap = (Map) null if (tn.linkNode != null) { ScreenUrlInfo.UrlInstance urlInstance = ((ScreenRenderImpl) cs.get("sri")).makeUrlByType((String) tn.linkNode.attribute("url"), (String) tn.linkNode.attribute("url-type") ?: "transition", tn.linkNode, (String) tn.linkNode.attribute("expand-transition-url") ?: "true") boolean noParam = tn.linkNode.attribute("url-noparam") == "true" String urlText = noParam ? urlInstance.getPath() : urlInstance.getPathWithParams() String hrefText = urlText String loadId = tn.linkNode.attribute("dynamic-load-id") if (loadId) { // NOTE: the void(0) is needed for Firefox and other browsers that render the result of the JS expression hrefText = "javascript:{\$('#${loadId}').load('${urlText}'); void(0);}" } aAttrMap = [href:hrefText, loadId:loadId, urlText:urlText] } boolean isOpen = ((String) cs.get("treeOpenPath"))?.startsWith(id) // now get children to check if has some, and if in treeOpenPath include them List childNodeList = null if (recurse) { cs.push() try { cs.put("treeNodeId", id) childNodeList = getChildNodes(tn.subNodeList, eci, cs, isOpen) } finally { cs.pop() } } // NOTE: passing href as either URL or JS to load (for static rendering with jstree), plus plain loadId and urlText for more dynamic stuff Map subNodeMap = [id:id, text:text, li_attr:["treeNodeName":tn.treeNodeNode.attribute("name")]] as Map if (aAttrMap != null) subNodeMap.a_attr = aAttrMap if (isOpen) { subNodeMap.state = [opened:true, selected:(cs.get("treeOpenPath") == id)] as Map subNodeMap.children = childNodeList } else { subNodeMap.children = childNodeList as boolean } outputNodeList.add(subNodeMap) /* structure of JSON object from jstree docs: { id : "string" // will be autogenerated if omitted text : "string" // node text icon : "string" // string for custom state : { opened : boolean // is the node open disabled : boolean // is the node disabled selected : boolean // is the node selected }, children : [] // array of strings or objects li_attr : {} // attributes for the generated LI node a_attr : {} // attributes for the generated A node } */ } finally { cs.pop() } } } // logger.warn("========= outputNodeList: ${outputNodeList}") return outputNodeList } static class TreeNode { protected ScreenTree screenTree protected MNode treeNodeNode protected String location protected XmlAction condition = null protected XmlAction actions = null protected MNode linkNode = null protected MNode labelNode = null protected List subNodeList = [] TreeNode(ScreenTree screenTree, MNode treeNodeNode, String location) { this.screenTree = screenTree this.treeNodeNode = treeNodeNode this.location = location this.linkNode = treeNodeNode.first("link") this.labelNode = treeNodeNode.first("label") // prep condition if (treeNodeNode.hasChild("condition") && treeNodeNode.first("condition").children) { // the script is effectively the first child of the condition element condition = new XmlAction(screenTree.ecfi, treeNodeNode.first("condition").children[0], location + ".condition") } // prep actions if (treeNodeNode.hasChild("actions")) actions = new XmlAction(screenTree.ecfi, treeNodeNode.first("actions"), location + ".actions") // prep tree-sub-node for (MNode treeSubNodeNode in treeNodeNode.children("tree-sub-node")) subNodeList.add(new TreeSubNode(screenTree, treeSubNodeNode, location + ".subnode." + treeSubNodeNode.attribute("node-name"))) } } static class TreeSubNode { protected ScreenTree screenTree protected MNode treeSubNodeNode protected String location protected XmlAction condition = null protected XmlAction actions = null TreeSubNode(ScreenTree screenTree, MNode treeSubNodeNode, String location) { this.screenTree = screenTree this.treeSubNodeNode = treeSubNodeNode this.location = location // prep condition if (treeSubNodeNode.hasChild("condition") && treeSubNodeNode.first("condition").children) { // the script is effectively the first child of the condition element condition = new XmlAction(screenTree.ecfi, treeSubNodeNode.first("condition").children[0], location + ".condition") } // prep actions if (treeSubNodeNode.hasChild("actions")) actions = new XmlAction(screenTree.ecfi, treeSubNodeNode.first("actions"), location + ".actions") } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenUrlInfo.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.BaseException import org.moqui.context.ArtifactExecutionInfo import org.moqui.context.ArtifactExecutionInfo.AuthzAction import org.moqui.context.ExecutionContext import org.moqui.resource.ResourceReference import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ArtifactExecutionFacadeImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.WebFacadeImpl import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.screen.ScreenDefinition.ParameterItem import org.moqui.impl.screen.ScreenDefinition.SubscreensItem import org.moqui.impl.screen.ScreenDefinition.TransitionItem import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.webapp.ScreenResourceNotFoundException import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.http.HttpServletRequest import javax.cache.Cache @CompileStatic class ScreenUrlInfo { protected final static Logger logger = LoggerFactory.getLogger(ScreenUrlInfo.class) // ExecutionContext ec ExecutionContextFactoryImpl ecfi ScreenFacadeImpl sfi ScreenDefinition rootSd String plainUrl = (String) null ScreenDefinition fromSd = (ScreenDefinition) null ArrayList fromPathList = (ArrayList) null String fromScreenPath = (String) null Map pathParameterMap = new HashMap() boolean requireEncryption = false // boolean hasActions = false // boolean disableLink = false boolean alwaysUseFullPath = false boolean beginTransaction = false Integer transactionTimeout = null String menuImage = (String) null String menuImageType = (String) null /** The full path name list for the URL, including extraPathNameList */ ArrayList fullPathNameList = (ArrayList) null /** The minimal path name list for the URL, basically without following the defaults */ ArrayList minimalPathNameList = (ArrayList) null /** Everything in the path after the screen or transition, may be used to pass additional info */ ArrayList extraPathNameList = (ArrayList) null /** The path for a file resource (template or static), relative to the targetScreen.location */ ArrayList fileResourcePathList = (ArrayList) null /** If the full path led to a file resource that is verified to exist, the URL goes here; the URL for access on the * server, the client will get the resource from the url field as normal */ ResourceReference fileResourceRef = (ResourceReference) null String fileResourceContentType = (String) null /** All screens found in the path list */ ArrayList screenPathDefList = new ArrayList() int renderPathDifference = 0 /** positive lastStandalone means how many to include from the end back, negative how many path elements to skip from the beginning */ int lastStandalone = 0 HashMap pathParameterItems = new HashMap<>() /** The last screen found in the path list */ ScreenDefinition targetScreen = (ScreenDefinition) null String targetScreenRenderMode = (String) null String targetTransitionActualName = (String) null String targetTransitionExtension = (String) null ArrayList preTransitionPathNameList = new ArrayList() boolean reusable = true boolean targetExists = true ScreenDefinition notExistsLastSd = (ScreenDefinition) null String notExistsLastName = (String) null String notExistsNextLoc = (String) null protected ScreenUrlInfo() { } /** Stub mode for ScreenUrlInfo, represent a plain URL and not a screen URL */ static ScreenUrlInfo getScreenUrlInfo(ScreenRenderImpl sri, String url) { Cache screenUrlCache = sri.sfi.screenUrlCache ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(url) if (cached != null) return cached ScreenUrlInfo newSui = new ScreenUrlInfo(sri, url) screenUrlCache.put(url, newSui) return newSui } static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, String url) { Cache screenUrlCache = sfi.screenUrlCache ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(url) if (cached != null) return cached ScreenUrlInfo newSui = new ScreenUrlInfo(sfi, url) screenUrlCache.put(url, newSui) return newSui } static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, ScreenDefinition rootSd, ScreenDefinition fromScreenDef, ArrayList fpnl, String subscreenPath, int lastStandalone) { // see if a plain URL was treated as a subscreen path if (subscreenPath != null && (subscreenPath.startsWith("https:") || subscreenPath.startsWith("http:"))) return getScreenUrlInfo(sfi, subscreenPath) Cache screenUrlCache = sfi.screenUrlCache String cacheKey = makeCacheKey(rootSd, fromScreenDef, fpnl, subscreenPath, lastStandalone) ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(cacheKey) if (cached != null) return cached ScreenUrlInfo newSui = new ScreenUrlInfo(sfi, rootSd, fromScreenDef, fpnl, subscreenPath, lastStandalone) screenUrlCache.put(cacheKey, newSui) return newSui } static ScreenUrlInfo getScreenUrlInfo(ScreenRenderImpl sri, ScreenDefinition fromScreenDef, ArrayList fpnl, String subscreenPath, int lastStandalone) { // see if a plain URL was treated as a subscreen path if (subscreenPath != null && (subscreenPath.startsWith("https:") || subscreenPath.startsWith("http:"))) return getScreenUrlInfo(sri, subscreenPath) ScreenDefinition rootSd = sri.getRootScreenDef() ScreenDefinition fromSd = fromScreenDef ArrayList fromPathList = fpnl if (fromSd == null) fromSd = sri.getActiveScreenDef() if (fromPathList == null) fromPathList = sri.getActiveScreenPath() Cache screenUrlCache = sri.sfi.screenUrlCache String cacheKey = makeCacheKey(rootSd, fromSd, fromPathList, subscreenPath, lastStandalone) ScreenUrlInfo cached = (ScreenUrlInfo) screenUrlCache.get(cacheKey) if (cached != null) return cached ScreenUrlInfo newSui = new ScreenUrlInfo(sri.sfi, rootSd, fromSd, fromPathList, subscreenPath, lastStandalone) if (newSui.reusable) screenUrlCache.put(cacheKey, newSui) return newSui } static ScreenUrlInfo getScreenUrlInfo(ScreenFacadeImpl sfi, HttpServletRequest request) { String webappName = request.servletContext.getInitParameter("moqui-name") String rootScreenLocation = sfi.rootScreenFromHost(request.getServerName(), webappName) ScreenDefinition rootScreenDef = sfi.getScreenDefinition(rootScreenLocation) if (rootScreenDef == null) throw new BaseArtifactException("Could not find root screen at location ${rootScreenLocation}") ArrayList screenPath = WebFacadeImpl.getPathInfoList(request) return getScreenUrlInfo(sfi, rootScreenDef, rootScreenDef, screenPath, null, 0) } final static char slashChar = (char) '/' static String makeCacheKey(ScreenDefinition rootSd, ScreenDefinition fromScreenDef, ArrayList fpnl, String subscreenPath, int lastStandalone) { StringBuilder sb = new StringBuilder() // shouldn't be too many root screens, so the screen name (filename) should be sufficiently unique and much shorter sb.append(rootSd.getScreenName()).append(":") if (fromScreenDef != null) sb.append(fromScreenDef.getScreenName()).append(":") boolean hasSsp = subscreenPath != null && subscreenPath.length() > 0 boolean skipFpnl = hasSsp && subscreenPath.charAt(0) == slashChar // NOTE: we will get more cache hits (less cache redundancy) if we combine with fpnl and use cleanupPathNameList, // but is it worth it? no, let there be redundant cache entries for the same screen path, will be faster if (!skipFpnl && fpnl != null) { int fpnlSize = fpnl.size() for (int i = 0; i < fpnlSize; i++) { String fpn = (String) fpnl.get(i) sb.append('/').append(fpn) } } if (hasSsp) sb.append(subscreenPath) sb.append(":").append(lastStandalone) // logger.warn("======= makeCacheKey subscreenPath=${subscreenPath}, fpnl=${fpnl}\n key=${sb}") return sb.toString() } /** Stub mode for ScreenUrlInfo, represent a plain URL and not a screen URL */ ScreenUrlInfo(ScreenRenderImpl sri, String url) { this.sfi = sri.sfi this.ecfi = sfi.ecfi this.rootSd = sri.getRootScreenDef() this.plainUrl = url } ScreenUrlInfo(ScreenFacadeImpl sfi, String url) { this.sfi = sfi this.ecfi = sfi.ecfi this.plainUrl = url } ScreenUrlInfo(ScreenFacadeImpl sfi, ScreenDefinition rootSd, ScreenDefinition fromScreenDef, ArrayList fpnl, String subscreenPath, int lastStandalone) { this.sfi = sfi this.ecfi = sfi.getEcfi() this.rootSd = rootSd fromSd = fromScreenDef fromPathList = fpnl fromScreenPath = subscreenPath ?: "" this.lastStandalone = lastStandalone initUrl() } UrlInstance getInstance(ScreenRenderImpl sri, Boolean expandAliasTransition) { return new UrlInstance(this, sri, expandAliasTransition) } boolean getInCurrentScreenPath(List currentPathNameList) { // if currentPathNameList (was from sri.screenUrlInfo) is null it is because this object is not yet set to it, so set this to true as it "is" the current screen path if (currentPathNameList == null) return true if (minimalPathNameList == null) return false if (minimalPathNameList.size() > currentPathNameList.size()) return false for (int i = 0; i < minimalPathNameList.size(); i++) { if (minimalPathNameList.get(i) != currentPathNameList.get(i)) return false } return true } ScreenDefinition getParentScreen() { if (screenPathDefList.size() > 1) { return screenPathDefList.get(screenPathDefList.size() - 2) } else { return null } } boolean isPermitted(ExecutionContext ec, TransitionItem transitionItem) { return isPermitted(ec, transitionItem, ArtifactExecutionInfo.AUTHZA_VIEW) } boolean isPermitted(ExecutionContext ec, TransitionItem transitionItem, AuthzAction actionEnum) { ArtifactExecutionFacadeImpl aefi = (ArtifactExecutionFacadeImpl) ec.getArtifactExecution() String userId = ec.getUser().getUserId() // if a user is permitted to view a certain location once in a render/ec they can safely be always allowed to, so cache it // add the username to the key just in case user changes during an EC instance String permittedCacheKey = (String) null if (fullPathNameList != null) { String keyUserId = userId != null ? userId : '_anonymous' permittedCacheKey = keyUserId.concat(fullPathNameList.toString()) Boolean cachedPermitted = (Boolean) aefi.screenPermittedCache.get(permittedCacheKey) if (cachedPermitted != null) return cachedPermitted.booleanValue() } else { // logger.warn("======== Not caching isPermitted, username=${username}, fullPathNameList=${fullPathNameList}") } ArrayDeque artifactExecutionInfoStack = new ArrayDeque() int screenPathDefListSize = screenPathDefList.size() boolean allowedByScreenDefinitionView = false boolean allowedByScreenDefinitionAll = false boolean allowedByScreenDefinition = false for (int i = 0; i < screenPathDefListSize; i++) { AuthzAction curActionEnum = (i == (screenPathDefListSize - 1)) ? actionEnum : ArtifactExecutionInfo.AUTHZA_VIEW ScreenDefinition screenDef = (ScreenDefinition) screenPathDefList.get(i) ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(screenDef.getLocation(), ArtifactExecutionInfo.AT_XML_SCREEN, curActionEnum, null) ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst() // logger.warn("TOREMOVE checking screen for user ${username} - ${aeii}") boolean isLast = ((i + 1) == screenPathDefListSize) MNode screenNode = screenDef.getScreenNode() String requireAuthentication = screenNode.attribute('require-authentication') allowedByScreenDefinitionView = "anonymous-view".equals(requireAuthentication) allowedByScreenDefinitionAll = "anonymous-all".equals(requireAuthentication) if (actionEnum == ArtifactExecutionInfo.AUTHZA_VIEW) { allowedByScreenDefinition = allowedByScreenDefinition || allowedByScreenDefinitionView || allowedByScreenDefinitionAll } else if (actionEnum == ArtifactExecutionInfo.AUTHZA_ALL) allowedByScreenDefinition = allowedByScreenDefinition || allowedByScreenDefinitionAll if (!aefi.isPermitted(aeii, lastAeii, isLast ? (!requireAuthentication || "true".equals(requireAuthentication)) : false, false, false, artifactExecutionInfoStack)) { //logger.warn("TOREMOVE user ${userId} is NOT allowed to view screen at path ${this.fullPathNameList} because of screen at ${screenDef.location}") if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false) return false } artifactExecutionInfoStack.addFirst(aeii) } // see if the transition is permitted if (!allowedByScreenDefinition && transitionItem != null) { ScreenDefinition lastScreenDef = (ScreenDefinition) screenPathDefList.get(screenPathDefList.size() - 1) ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl("${lastScreenDef.location}/${transitionItem.name}", ArtifactExecutionInfo.AT_XML_SCREEN_TRANS, ArtifactExecutionInfo.AUTHZA_VIEW, null) ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst() if (!aefi.isPermitted(aeii, lastAeii, true, false, false, artifactExecutionInfoStack)) { // logger.warn("TOREMOVE user ${username} is NOT allowed to view screen at path ${this.fullPathNameList} because of screen at ${screenDef.location}") if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false) return false } } // if there is a transition with a single service go a little further and see if we have permission to call it String serviceName = transitionItem?.singleServiceName if (transitionItem != null && !transitionItem.isReadOnly() && serviceName != null && !serviceName.isEmpty()) { ServiceDefinition sd = sfi.ecfi.serviceFacade.getServiceDefinition(serviceName) ArtifactExecutionInfo.AuthzAction authzAction if (sd != null) authzAction = sd.authzAction if (authzAction == null) authzAction = ServiceDefinition.verbAuthzActionEnumMap.get(ServiceDefinition.getVerbFromName(serviceName)) if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL boolean allowedByServiceDefinition = false if (authzAction == ArtifactExecutionInfo.AUTHZA_VIEW) { allowedByServiceDefinition = allowedByScreenDefinitionView || (sd != null && ("anonymous-view".equals(sd.authenticate) || "anonymous-all".equals(sd.authenticate))) } else if (authzAction in [ArtifactExecutionInfo.AUTHZA_ALL, ArtifactExecutionInfo.AUTHZA_CREATE, ArtifactExecutionInfo.AUTHZA_UPDATE, ArtifactExecutionInfo.AUTHZA_DELETE]) { allowedByServiceDefinition = allowedByScreenDefinitionAll || (sd != null && "anonymous-all".equals(sd.authenticate)) } ArtifactExecutionInfoImpl aeii = new ArtifactExecutionInfoImpl(serviceName, ArtifactExecutionInfo.AT_SERVICE, authzAction, null) ArtifactExecutionInfoImpl lastAeii = (ArtifactExecutionInfoImpl) artifactExecutionInfoStack.peekFirst() if (!aefi.isPermitted(aeii, lastAeii, !allowedByServiceDefinition, false, false, null)) { // logger.warn("TOREMOVE user ${username} is NOT allowed to run transition at path ${this.fullPathNameList} because of screen at ${screenDef.location}") if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, false) return false } artifactExecutionInfoStack.addFirst(aeii) } // logger.warn("TOREMOVE user ${username} IS allowed to view screen at path ${this.fullPathNameList}") if (permittedCacheKey != null) aefi.screenPermittedCache.put(permittedCacheKey, true) return true } String getBaseUrl(ScreenRenderImpl sri) { // support the stub mode for ScreenUrlInfo, representing a plain URL and not a screen URL if (plainUrl != null && plainUrl.length() > 0) return plainUrl if (sri == null) return "" String baseUrl if (sri.baseLinkUrl != null && sri.baseLinkUrl.length() > 0) { baseUrl = sri.baseLinkUrl if (baseUrl && baseUrl.charAt(baseUrl.length()-1) == (char) '/') baseUrl = baseUrl.substring(0, baseUrl.length()-1) } else { if (sri.webappName == null || sri.webappName.length() == 0) throw new BaseArtifactException("No webappName specified, cannot get base URL for screen location ${sri.rootScreenLocation}") baseUrl = WebFacadeImpl.getWebappRootUrl(sri.webappName, sri.servletContextPath, true, this.requireEncryption, sri.ec) } return baseUrl } String getUrlWithBase(String baseUrl) { if (!targetExists) { logger.warn("Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash") return "#" } StringBuilder urlBuilder = new StringBuilder(baseUrl) if (fullPathNameList != null) { int listSize = fullPathNameList.size() for (int i = 0; i < listSize; i++) { String pathName = fullPathNameList.get(i) urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName)) } } return urlBuilder.toString() } String getMinimalPathUrlWithBase(String baseUrl) { if (!targetExists) { logger.warn("Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash") return "#" } StringBuilder urlBuilder = new StringBuilder(baseUrl) if (alwaysUseFullPath) { // really get the full path instead of minimal if (fullPathNameList != null) { int listSize = fullPathNameList.size() for (int i = 0; i < listSize; i++) { String pathName = fullPathNameList.get(i) urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName)) } } } else { if (minimalPathNameList != null) { int listSize = minimalPathNameList.size() for (int i = 0; i < listSize; i++) { String pathName = minimalPathNameList.get(i) urlBuilder.append('/').append(StringUtilities.urlEncodeIfNeeded(pathName)) } } } return urlBuilder.toString() } String getScreenPathUrlWithBase(String baseUrl) { if (!targetExists) { logger.warn("Tried to get URL for screen path ${fullPathNameList} that does not exist under ${rootSd.location}, returning hash") return "#" } StringBuilder urlBuilder = new StringBuilder(baseUrl) if (preTransitionPathNameList) for (String pathName in preTransitionPathNameList) urlBuilder.append('/').append(pathName) return urlBuilder.toString() } ArrayList getPreTransitionPathNameList() { return preTransitionPathNameList } ArrayList getExtraPathNameList() { return extraPathNameList } ScreenUrlInfo addParameter(Object name, Object value) { if (!name || value == null) return this pathParameterMap.put(name as String, ObjectUtilities.toPlainString(value)) return this } ScreenUrlInfo addParameters(Map manualParameters) { if (!manualParameters) return this for (Map.Entry mpEntry in manualParameters.entrySet()) { pathParameterMap.put(mpEntry.getKey() as String, ObjectUtilities.toPlainString(mpEntry.getValue())) } return this } Map getPathParameterMap() { return pathParameterMap } void initUrl() { // TODO: use this in all calling code (expand url before creating/caching so that we have the full/unique one) // support string expansion if there is a "${" // if (fromScreenPath.contains('${')) fromScreenPath = ec.getResource().expand(fromScreenPath, "") ArrayList screenRenderDefList = new ArrayList() ArrayList subScreenPath = parseSubScreenPath(rootSd, fromSd, fromPathList, fromScreenPath, pathParameterMap, sfi) if (subScreenPath == null) { targetExists = false return } // logger.info("initUrl BEFORE fromPathList=${fromPathList}, fromScreenPath=${fromScreenPath}, subScreenPath=${subScreenPath}") boolean fromPathSlash = fromScreenPath.startsWith("/") if (fromPathSlash && fromScreenPath.startsWith("//")) { // find the screen by name fromSd = rootSd fromPathList = subScreenPath fullPathNameList = subScreenPath } else { if (fromPathSlash) { fromSd = rootSd fromPathList = new ArrayList() } fullPathNameList = subScreenPath } // logger.info("initUrl fromScreenPath=${fromScreenPath}, fromPathList=${fromPathList}, fullPathNameList=${fullPathNameList}") // encrypt is the default loop through screens if all are not secure/etc use http setting, otherwise https requireEncryption = !"false".equals(rootSd?.webSettingsNode?.attribute("require-encryption")) if ("true".equals(rootSd?.screenNode?.attribute('begin-transaction'))) beginTransaction = true String txTimeoutAttr = rootSd?.screenNode?.attribute("transaction-timeout") if (txTimeoutAttr) transactionTimeout = Integer.parseInt(txTimeoutAttr) // start the render lists with the root SD screenRenderDefList.add(rootSd) screenPathDefList.add(rootSd) // loop through path for various things: check validity, see if we can do a transition short-cut and go right // to its response url, etc ScreenDefinition lastSd = rootSd extraPathNameList = new ArrayList(fullPathNameList) for (int i = 0; i < fullPathNameList.size(); i++) { String pathName = (String) fullPathNameList.get(i) String rmExtension = (String) null String pathNamePreDot = (String) null int dotIndex = pathName.indexOf('.') if (dotIndex > 0) { // is there an extension with a render-mode added to the screen name? String curExtension = pathName.substring(dotIndex + 1) if (sfi.isRenderModeValid(curExtension)) { rmExtension = curExtension pathNamePreDot = pathName.substring(0, dotIndex) } } // This section is for no-sub-path support, allowing screen override or extend on same path with wrapping by no-sub-path screen // check getSubscreensNoSubPath() for subscreens item, transition, resource ref // add subscreen to screenRenderDefList and screenPathDefList, also add to fullPathNameList ArrayList subscreensNoSubPath = lastSd.getSubscreensNoSubPath() if (subscreensNoSubPath != null) { int subscreensNoSubPathSize = subscreensNoSubPath.size() for (int sni = 0; sni < subscreensNoSubPathSize; sni++) { SubscreensItem noSubPathSi = (SubscreensItem) subscreensNoSubPath.get(sni) String noSubPathLoc = noSubPathSi.getLocation() ScreenDefinition noSubPathSd = (ScreenDefinition) null try { noSubPathSd = sfi.getScreenDefinition(noSubPathLoc) } catch (Exception e) { logger.error("Error loading no sub-path screen under path ${pathName} at ${noSubPathLoc}", BaseException.filterStackTrace(e)) } if (noSubPathSd == null) continue boolean foundChild = false // look for subscreen, transition SubscreensItem subSi = noSubPathSd.getSubscreensItem(pathName) if ((subSi != null && sfi.isScreen(subSi.getLocation())) || noSubPathSd.hasTransition(pathName)) foundChild = true // is this a file under the screen? if (!foundChild) { ResourceReference existingFileRef = noSubPathSd.getSubContentRef(extraPathNameList) if (existingFileRef != null && existingFileRef.getExists() && !existingFileRef.isDirectory() && !sfi.isScreen(existingFileRef.getLocation())) foundChild = true } // if pathNamePreDot not null see if matches subscreen or transition if (!foundChild && pathNamePreDot != null) { // is there an extension with a render-mode added to the screen name? subSi = noSubPathSd.getSubscreensItem(pathNamePreDot) if ((subSi != null && sfi.isScreen(subSi.getLocation())) || noSubPathSd.hasTransition(pathNamePreDot)) foundChild = true } if (foundChild) { // if standalone, clear out screenRenderDefList before adding this to it if (noSubPathSd.isStandalone()) { renderPathDifference += screenRenderDefList.size() screenRenderDefList.clear() } else { while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) { renderPathDifference++ screenRenderDefList.remove(0) } } screenRenderDefList.add(noSubPathSd) screenPathDefList.add(noSubPathSd) fullPathNameList.add(i, noSubPathSi.name) i++ lastSd = noSubPathSd break } } } SubscreensItem curSi = lastSd.getSubscreensItem(pathName) if (curSi == null || !sfi.isScreen(curSi.getLocation())) { // handle case where last one may be a transition name, and not a subscreen name if (lastSd.hasTransition(pathName)) { // extra path elements always allowed after transitions for parameters, but we don't want the transition name on it extraPathNameList.remove(0) targetTransitionActualName = pathName // break out; a transition means we're at the end break } // is this a file under the screen? ResourceReference existingFileRef = lastSd.getSubContentRef(extraPathNameList) if (existingFileRef != null && existingFileRef.getExists() && !existingFileRef.isDirectory() && !sfi.isScreen(existingFileRef.getLocation())) { // exclude screen files, don't want to treat them as resources and let them be downloaded fileResourceRef = existingFileRef break } if (pathNamePreDot != null) { // is there an extension with a render-mode added to the screen name? curSi = lastSd.getSubscreensItem(pathNamePreDot) if (curSi != null && sfi.isScreen(curSi.getLocation())) { targetScreenRenderMode = rmExtension if (sfi.isRenderModeAlwaysStandalone(rmExtension)) lastStandalone = 1 fullPathNameList.set(i, pathNamePreDot) pathName = pathNamePreDot } // is there an extension beyond a transition name? if (curSi == null && lastSd.hasTransition(pathNamePreDot)) { // extra path elements always allowed after transitions for parameters, but we don't want the transition name on it extraPathNameList.remove(0) targetTransitionActualName = pathNamePreDot targetTransitionExtension = rmExtension // break out; a transition means we're at the end break } } // next SubscreenItem still not found? if (curSi == null) { // call it good if extra path is allowed if (lastSd.allowExtraPath) break targetExists = false notExistsLastSd = lastSd notExistsLastName = extraPathNameList ? extraPathNameList.last() : (fullPathNameList ? fullPathNameList.last() : null) return // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, extraPathNameList?.last(), null, new Exception("Screen sub-content not found here")) } } String nextLoc = curSi.getLocation() ScreenDefinition curSd = (ScreenDefinition) null try { curSd = sfi.getScreenDefinition(nextLoc) } catch (Exception e) { logger.error("Error loading screen with path name ${pathName} at ${nextLoc}", BaseException.filterStackTrace(e)) } if (curSd == null) { targetExists = false notExistsLastSd = lastSd notExistsLastName = pathName notExistsNextLoc = nextLoc return // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, pathName, nextLoc, new Exception("Screen subscreen or transition not found here")) } if (curSd.webSettingsNode?.attribute('require-encryption') != "false") this.requireEncryption = true if (curSd.screenNode?.attribute('begin-transaction') == "true") this.beginTransaction = true String curTxTimeoutAttr = curSd.screenNode?.attribute("transaction-timeout") if (curTxTimeoutAttr) { Integer curTransactionTimeout = Integer.parseInt(curTxTimeoutAttr) if (transactionTimeout == null || curTransactionTimeout > transactionTimeout) transactionTimeout = curTransactionTimeout } if (curSd.getSubscreensNode()?.attribute('always-use-full-path') == "true") alwaysUseFullPath = true for (ParameterItem pi in curSd.getParameterMap().values()) if (!pathParameterItems.containsKey(pi.name)) pathParameterItems.put(pi.name, pi) // if standalone, clear out screenRenderDefList before adding this to it if (curSd.isStandalone()) { renderPathDifference += screenRenderDefList.size() screenRenderDefList.clear() } else { while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) { renderPathDifference++ screenRenderDefList.remove(0) } } screenRenderDefList.add(curSd) screenPathDefList.add(curSd) lastSd = curSd // add this to the list of path names to use for transition redirect preTransitionPathNameList.add(pathName) // made it all the way to here so this was a screen extraPathNameList.remove(0) } // save the path so far for minimal URLs minimalPathNameList = new ArrayList(fullPathNameList) // beyond the last screenPathName, see if there are any screen.default-item values (keep following until none found) int defaultSubScreenCount = 0 // NOTE: don't look for defaults if we have a target screen with a render mode, means we want to render that screen while (targetScreenRenderMode == null && targetTransitionActualName == null && fileResourceRef == null && lastSd.getDefaultSubscreensItem()) { if (lastSd.getSubscreensNode()?.attribute('always-use-full-path') == "true") alwaysUseFullPath = true // logger.warn("TOREMOVE lastSd ${minimalPathNameList} subscreens: ${lastSd.screenNode?.subscreens}, alwaysUseFullPath=${alwaysUseFullPath}, from ${lastSd.screenNode."subscreens"?."@always-use-full-path"?.getAt(0)}, subscreenName=${subscreenName}") // determine the subscreen name String subscreenName = null // check SubscreensDefault records EntityList subscreensDefaultList = ecfi.entity.find("moqui.screen.SubscreensDefault") .condition("screenLocation", lastSd.location).useCache(true).disableAuthz().list() for (int i = 0; i < subscreensDefaultList.size(); i++) { EntityValue subscreensDefault = subscreensDefaultList.get(i) String condStr = (String) subscreensDefault.conditionExpression if (condStr && !ecfi.getResource().condition(condStr, "SubscreensDefault_condition")) continue subscreenName = subscreensDefault.subscreenName } // if any conditional-default.@condition eval to true, use that conditional-default.@item instead List condDefaultList = lastSd.getSubscreensNode()?.children("conditional-default") if (condDefaultList != null && condDefaultList.size() > 0) for (MNode conditionalDefaultNode in condDefaultList) { String condStr = conditionalDefaultNode.attribute('condition') if (!condStr) continue if (ecfi.getResource().condition(condStr, null)) { subscreenName = conditionalDefaultNode.attribute('item') break } } // whether we got a hit or not there are conditional defaults for this path, so can't reuse this instance if ((subscreensDefaultList != null && subscreensDefaultList.size() > 0) || (condDefaultList != null && condDefaultList.size() > 0)) reusable = false if (subscreenName == null || subscreenName.isEmpty()) subscreenName = lastSd.getDefaultSubscreensItem() String nextLoc = lastSd.getSubscreensItem(subscreenName)?.location if (nextLoc == null || nextLoc.isEmpty()) { // handle case where last one may be a transition name, and not a subscreen name if (lastSd.hasTransition(subscreenName)) { targetTransitionActualName = subscreenName fullPathNameList.add(subscreenName) break } // is this a file under the screen? ResourceReference existingFileRef = lastSd.getSubContentRef([subscreenName]) if (existingFileRef && existingFileRef.supportsExists() && existingFileRef.exists) { fileResourceRef = existingFileRef fullPathNameList.add(subscreenName) break } targetExists = false return // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, subscreenName, null, new Exception("Screen subscreen or transition not found here")) } ScreenDefinition curSd = sfi.getScreenDefinition(nextLoc) if (curSd == null) { targetExists = false return // throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, lastSd, subscreenName, nextLoc, new Exception("Screen subscreen or transition not found here")) } if (curSd.webSettingsNode?.attribute('require-encryption') != "false") this.requireEncryption = true if (curSd.screenNode?.attribute('begin-transaction') == "true") this.beginTransaction = true String curTxTimeoutAttr = curSd.screenNode?.attribute("transaction-timeout") if (curTxTimeoutAttr) { Integer curTransactionTimeout = Integer.parseInt(curTxTimeoutAttr) if (transactionTimeout == null || curTransactionTimeout > transactionTimeout) transactionTimeout = curTransactionTimeout } // if standalone, clear out screenRenderDefList before adding this to it if (curSd.isStandalone()) { renderPathDifference += screenRenderDefList.size() screenRenderDefList.clear() } else { while (this.lastStandalone < 0 && -lastStandalone > renderPathDifference && screenRenderDefList.size() > 0) { renderPathDifference++ screenRenderDefList.remove(0) } } screenRenderDefList.add(curSd) screenPathDefList.add(curSd) lastSd = curSd // for use in URL writing and such add the subscreenName we found to the main path name list fullPathNameList.add(subscreenName) // add this to the list of path names to use for transition redirect, just in case a default is a transition preTransitionPathNameList.add(subscreenName) defaultSubScreenCount++ } this.targetScreen = lastSd // remove all but lastStandalone items from screenRenderDefList if (lastStandalone > 0) while (screenRenderDefList.size() > lastStandalone) { renderPathDifference++ screenRenderDefList.remove(0) } // screenRenderDefList now in place, look for menu-image and menu-image-type of last in list int renderListSize = screenRenderDefList.size() int defaultSubScreenLimit = renderListSize - defaultSubScreenCount - 1 for (int i = 0; i < renderListSize; i++) { // only use explicit path to find icon, don't want default subscreens overriding it ScreenDefinition curSd = screenRenderDefList.get(i) String curMenuImage = curSd.getScreenNode().attribute("menu-image") if (curMenuImage) { menuImage = curMenuImage menuImageType = curSd.getScreenNode().attribute("menu-image-type") ?: 'url-screen' } if (i >= defaultSubScreenLimit && menuImage) break } } void checkExists() { if (!targetExists) throw new ScreenResourceNotFoundException(fromSd, fullPathNameList, notExistsLastSd, notExistsLastName, notExistsNextLoc, new Exception("Screen, transition, or resource not found here")) } @Override String toString() { // return ONLY the url built from the inputs; that is the most basic possible value return this.getUrlWithBase(getBaseUrl(null)) } ScreenUrlInfo cloneUrlInfo() { ScreenUrlInfo sui = new ScreenUrlInfo() this.copyUrlInfoInto(sui) return sui } void copyUrlInfoInto(ScreenUrlInfo sui) { sui.sfi = this.sfi sui.rootSd = this.rootSd sui.fromSd = this.fromSd sui.fromPathList = this.fromPathList != null ? new ArrayList(this.fromPathList) : null sui.fromScreenPath = this.fromScreenPath sui.pathParameterMap = this.pathParameterMap != null ? new HashMap(this.pathParameterMap) : null sui.requireEncryption = this.requireEncryption sui.beginTransaction = this.beginTransaction sui.transactionTimeout = this.transactionTimeout sui.fullPathNameList = this.fullPathNameList != null ? new ArrayList(this.fullPathNameList) : null sui.minimalPathNameList = this.minimalPathNameList != null ? new ArrayList(this.minimalPathNameList) : null sui.fileResourcePathList = this.fileResourcePathList != null ? new ArrayList(this.fileResourcePathList) : null sui.fileResourceRef = this.fileResourceRef sui.fileResourceContentType = this.fileResourceContentType sui.screenPathDefList = this.screenPathDefList != null ? new ArrayList(this.screenPathDefList) : null sui.renderPathDifference = this.renderPathDifference sui.lastStandalone = this.lastStandalone sui.targetScreen = this.targetScreen sui.targetScreenRenderMode = this.targetScreenRenderMode sui.targetTransitionActualName = this.targetTransitionActualName sui.targetTransitionExtension = this.targetTransitionExtension sui.preTransitionPathNameList = this.preTransitionPathNameList!=null ? new ArrayList(this.preTransitionPathNameList) : null } static ArrayList parseSubScreenPath(ScreenDefinition rootSd, ScreenDefinition fromSd, List fromPathList, String screenPath, Map inlineParameters, ScreenFacadeImpl sfi) { if (screenPath == null) screenPath = "" // at very beginning look up ScreenPathAlias to see if this should be replaced; allows various flexible uses of this including global placeholders boolean startsWithSlash = screenPath.startsWith("/") String aliasPath = screenPath if (!startsWithSlash && fromPathList != null && fromPathList.size() > 0) { StringBuilder newPath = new StringBuilder() int fplSize = fromPathList.size() for (int i = 0; i < fplSize; i++) newPath.append('/').append(fromPathList.get(i)) if (!screenPath.isEmpty()) newPath.append('/').append(screenPath) aliasPath = newPath.toString() } // logger.warn("Looking for path alias with screenPath ${screenPath} fromPathList ${fromPathList} aliasPath ${aliasPath}") EntityList screenPathAliasList = sfi.ecfi.entityFacade.find("moqui.screen.ScreenPathAlias") .condition("aliasPath", aliasPath).disableAuthz().useCache(true).list() // logger.warn("Looking for path alias with aliasPath ${aliasPath} screenPathAliasList ${screenPathAliasList}") // keep this as light weight as possible, only filter and sort if needed if (screenPathAliasList.size() > 0) { screenPathAliasList = screenPathAliasList.cloneList().filterByDate("fromDate", "thruDate", null) int spaListSize = screenPathAliasList.size() if (spaListSize > 0) { if (spaListSize > 1) screenPathAliasList.orderByFields(["-fromDate"]) String newScreenPath = screenPathAliasList.get(0).getNoCheckSimple("screenPath") if (newScreenPath != null && !newScreenPath.isEmpty()) { screenPath = newScreenPath } } } // NOTE: this is somewhat tricky because screenPath may be encoded or not, may come from internal string or from browser URL string // if there are any ?... parameters parse them off and remove them from the string int indexOfQuestionMark = screenPath.lastIndexOf("?") // BAD idea: common to have at least '.' characters in URL parameters and such // for wiki pages and other odd filenames try to handle a '?' in the filename, ie don't consider parameter separator if // there is a '/' or '.' after it or if it is the end of the string; doesn't handle all cases, may not be possible to // if (indexOfQuestionMark > 0 && (indexOfQuestionMark == screenPath.length() - 1 || screenPath.indexOf("/", indexOfQuestionMark) > 0 || screenPath.indexOf(".", indexOfQuestionMark) > 0)) { indexOfQuestionMark = -1 } // logger.warn("indexOfQuestionMark ${indexOfQuestionMark} screenPath ${screenPath}") if (indexOfQuestionMark > 0) { String pathParmString = screenPath.substring(indexOfQuestionMark + 1) if (inlineParameters != null && pathParmString.length() > 0) { List nameValuePairs = pathParmString.replaceAll("&", "&").split("&") as List for (String nameValuePair in nameValuePairs) { String[] nameValue = nameValuePair.substring(0).split("=") if (nameValue.length == 2) inlineParameters.put(nameValue[0], URLDecoder.decode(nameValue[1], "UTF-8")) } } screenPath = screenPath.substring(0, indexOfQuestionMark) } startsWithSlash = screenPath.startsWith("/") if (startsWithSlash && screenPath.startsWith("//")) { // find the screen by name String trimmedFromPath = screenPath.substring(2) ArrayList originalPathNameList = new ArrayList(Arrays.asList(trimmedFromPath.split("/"))) originalPathNameList = cleanupPathNameList(originalPathNameList, inlineParameters) if (sfi.screenFindPathCache.containsKey(screenPath)) { ArrayList cachedPathList = (ArrayList) sfi.screenFindPathCache.get(screenPath) if (cachedPathList != null && cachedPathList.size() > 0) { return cachedPathList } else { return null // throw new ScreenResourceNotFoundException(fromSd, originalPathNameList, fromSd, screenPath, null, new Exception("Could not find screen, transition or content matching path")) } } else { ArrayList expandedPathNameList = rootSd.findSubscreenPath(originalPathNameList) sfi.screenFindPathCache.put(screenPath, expandedPathNameList) if (expandedPathNameList) { return expandedPathNameList } else { return null // throw new ScreenResourceNotFoundException(fromSd, originalPathNameList, fromSd, screenPath, null, new Exception("Could not find screen, transition or content matching path")) } } } else { if (startsWithSlash) fromPathList = (List) null ArrayList tempPathNameList = new ArrayList() if (fromPathList != null) tempPathNameList.addAll(fromPathList) tempPathNameList.addAll(Arrays.asList(screenPath.split("/"))) return cleanupPathNameList(tempPathNameList, inlineParameters) } } static ArrayList cleanupPathNameList(ArrayList inputPathNameList, Map inlineParameters) { // filter the list: remove empty, remove ".", remove ".." and previous int inputPathNameListSize = inputPathNameList.size() ArrayList cleanList = new ArrayList(inputPathNameListSize) for (int i = 0; i < inputPathNameListSize; i++) { String pathName = (String) inputPathNameList.get(i) if (pathName == null || pathName.length() == 0) continue if (".".equals(pathName)) continue // .. means go up a level, ie drop the last in the list if ("..".equals(pathName)) { int cleanListSize = cleanList.size() if (cleanListSize > 0) cleanList.remove(cleanListSize - 1) continue } // if it has a tilde it is a parameter, so skip it but remember it if (pathName.startsWith("~")) { if (inlineParameters != null) { String[] nameValue = pathName.substring(1).split("=") if (nameValue.length == 2) inlineParameters.put(nameValue[0], URLDecoder.decode(nameValue[1], "UTF-8")) } continue } // the original approach, not needed as already decoded: cleanList.add(URLDecoder.decode(pathName, "UTF-8")) // the 2nd pass approach, now not needed as ScreenRenderImpl.render(request, response) uses URLDecoder for each path segment: cleanList.add(pathName.replace(plusChar, spaceChar)) cleanList.add(pathName) } return cleanList } static int parseLastStandalone(String lastStandalone, int defLs) { if (lastStandalone == null || lastStandalone.length() == 0) return defLs if (lastStandalone.startsWith("t")) return 1 if (lastStandalone.startsWith("f")) return 0 try { return Integer.parseInt(lastStandalone) } catch (Exception e) { if (logger.isTraceEnabled()) logger.trace("Error parsing lastStandalone value ${lastStandalone}, default to 0 for no lastStandalone") return 0 } } @CompileStatic static class UrlInstance { ScreenUrlInfo sui ScreenRenderImpl sri ExecutionContextImpl ec Boolean expandAliasTransition /** If a transition is specified, the target transition within the targetScreen */ TransitionItem curTargetTransition = (TransitionItem) null Map otherParameterMap = new HashMap() Map transitionAliasParameters = (Map) null Map allParameterMap = (Map) null UrlInstance(ScreenUrlInfo sui, ScreenRenderImpl sri, Boolean expandAliasTransition) { this.sui = sui this.sri = sri ec = sri.ec this.expandAliasTransition = expandAliasTransition if (expandAliasTransition != null && expandAliasTransition.booleanValue()) expandTransitionAliasUrl() // logger.warn("======= Creating UrlInstance ${sui.getFullPathNameList()} - ${sui.targetScreen.getLocation()} - ${sui.getTargetTransitionActualName()}") } String getRequestMethod() { return ec.web != null ? ec.web.request.method : "" } TransitionItem getTargetTransition() { if (curTargetTransition == null && sui.targetScreen != null && sui.targetTransitionActualName != null) curTargetTransition = sui.targetScreen.getTransitionItem(sui.targetTransitionActualName, getRequestMethod()) return curTargetTransition } boolean getHasActions() { getTargetTransition() != null && (getTargetTransition().actions != null || getTargetTransition().serviceActions != null) } boolean isReadOnly() { getTargetTransition() == null || getTargetTransition().isReadOnly() } boolean getDisableLink() { return !sui.targetExists || (getTargetTransition() != null && !getTargetTransition().checkCondition(ec)) || !isPermitted() } boolean isPermitted() { return sui.isPermitted(ec, getTargetTransition()) } boolean getInCurrentScreenPath() { List currentPathNameList = new ArrayList(sri.screenUrlInfo.fullPathNameList) return sui.getInCurrentScreenPath(currentPathNameList) } boolean isScreenUrl() { if (getTargetTransition() != null && curTargetTransition.defaultResponse != null && ("plain".equals(curTargetTransition.defaultResponse.urlType) || "none".equals(curTargetTransition.defaultResponse.type) || curTargetTransition.defaultResponse.parameterMap.containsKey("renderMode"))) return false return sui.targetScreen != null } void expandTransitionAliasUrl() { TransitionItem ti = getTargetTransition() if (ti == null) return // Screen Transition as a URL Alias: // if fromScreenPath is a transition, and that transition has no condition, // service/actions or conditional-response then use the default-response.url instead // of the name (if type is screen-path or empty, url-type is url or empty) if (ti.condition == null && !ti.hasActionsOrSingleService() && !ti.conditionalResponseList && ti.defaultResponse != null && "url".equals(ti.defaultResponse.type) && "screen-path".equals(ti.defaultResponse.urlType) && ec.web != null) { transitionAliasParameters = ti.defaultResponse.expandParameters(sui.getExtraPathNameList(), ec) // create a ScreenUrlInfo, then copy its info into this String expandedUrl = ti.defaultResponse.url if (expandedUrl.contains('${')) expandedUrl = ec.resourceFacade.expand(expandedUrl, "") ScreenUrlInfo aliasUrlInfo = getScreenUrlInfo(sri.sfi, sui.rootSd, sui.fromSd, sui.preTransitionPathNameList, expandedUrl, parseLastStandalone((String) transitionAliasParameters.lastStandalone, sui.lastStandalone)) // logger.warn("Made transition alias: ${aliasUrlInfo.toString()}") sui = aliasUrlInfo curTargetTransition = (TransitionItem) null } } Map getTransitionAliasParameters() { return transitionAliasParameters } String getPath() { return sui.getUrlWithBase("") } String getPathWithParams() { String ps = getParameterString() String path = getPath() if (ps.length() > 0) path = path.concat("?").concat(ps) return path } // now redundant with getPath() but left in place for backward compatibility String getScreenPath() { return sui.getUrlWithBase("") } String getUrl() { return sui.getUrlWithBase(sui.getBaseUrl(sri)) } String getUrlWithParams() { String ps = getParameterString() String url = getUrl() if (ps.length() > 0) url = url.concat("?").concat(ps) return url } String getUrlWithParams(String extension) { String ps = getParameterString() String url = getUrl() if (extension != null && !extension.isEmpty()) url = url.concat(".").concat(extension) if (ps.length() > 0) url = url.concat("?").concat(ps) return url } String getMinimalPathUrl() { return sui.getMinimalPathUrlWithBase(sui.getBaseUrl(sri)) } String getMinimalPathUrlWithParams() { String ps = getParameterString() String url = getMinimalPathUrl() if (ps != null && ps.length() > 0) url = url.concat("?").concat(ps) return url } String getScreenOnlyPath() { return sui.getScreenPathUrlWithBase("") } String getScreenPathUrl() { return sui.getScreenPathUrlWithBase(sui.getBaseUrl(sri)) } Map getParameterMap() { if (allParameterMap != null) return allParameterMap allParameterMap = new HashMap<>() // get default parameters for the screens in the path for (ParameterItem pi in (Collection) sui.pathParameterItems.values()) { Object value = pi.getValue(ec) String valueStr = ObjectUtilities.toPlainString(value) if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pi.name, valueStr) } TransitionItem targetTrans = getTargetTransition() if (targetTrans != null) { Map transParameterMap = targetTrans.getParameterMap() for (ParameterItem pi in (Collection) transParameterMap.values()) { Object value = pi.getValue(ec) String valueStr = ObjectUtilities.toPlainString(value) if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pi.name, valueStr) } String targetServiceName = targetTransition.getSingleServiceName() if (targetServiceName != null && targetServiceName.length() > 0) { ServiceDefinition sd = ec.serviceFacade.getServiceDefinition(targetServiceName) Map csMap = ec.contextStack.getCombinedMap() Map wfParameters = ec.getWeb()?.getParameters() if (sd != null) { ArrayList inParameterNames = sd.getInParameterNames() int inParameterNamesSize = inParameterNames.size() for (int i = 0; i < inParameterNamesSize; i++) { String pn = (String) inParameterNames.get(i) Object value = csMap.get(pn) if (ObjectUtilities.isEmpty(value) && wfParameters != null) value = wfParameters.get(pn) String valueStr = ObjectUtilities.toPlainString(value) if (valueStr != null && valueStr.length() > 0) allParameterMap.put(pn, valueStr) } } else if (targetServiceName.contains("#")) { // service name but no service def, see if it is an entity op and if so try the pk fields String verb = targetServiceName.substring(0, targetServiceName.indexOf("#")) if (verb == "create" || verb == "update" || verb == "delete" || verb == "store") { String en = targetServiceName.substring(targetServiceName.indexOf("#") + 1) EntityDefinition ed = ec.entityFacade.getEntityDefinition(en) if (ed != null) { for (String fn in ed.getPkFieldNames()) { Object value = csMap.get(fn) if (ObjectUtilities.isEmpty(value) && wfParameters != null) value = wfParameters.get(fn) String valueStr = ObjectUtilities.toPlainString(value) if (valueStr != null && valueStr.length() > 0) allParameterMap.put(fn, valueStr) } } } } } } // add all of the parameters specified inline in the screen path or added after if (sui.pathParameterMap != null) allParameterMap.putAll(sui.pathParameterMap) // add transition parameters, for alias transitions if (transitionAliasParameters != null) allParameterMap.putAll(transitionAliasParameters) // add all parameters added to the instance after allParameterMap.putAll(otherParameterMap) // logger.info("TOREMOVE Getting parameterMap [${pm}] for targetScreen [${targetScreen.location}]") return allParameterMap } String getParameterString() { StringBuilder ps = new StringBuilder() Map pm = getParameterMap() for (Map.Entry pme in pm.entrySet()) { if (!pme.value) continue if (pme.key == "moquiSessionToken") continue if (ps.length() > 0) ps.append("&") ps.append(StringUtilities.urlEncodeIfNeeded(pme.key)).append("=").append(StringUtilities.urlEncodeIfNeeded(pme.value)) } return ps.toString() } String getParameterPathString() { StringBuilder ps = new StringBuilder() Map pm = getParameterMap() for (Map.Entry pme in pm.entrySet()) { if (!pme.getValue()) continue ps.append("/~") ps.append(StringUtilities.urlEncodeIfNeeded(pme.getKey())).append("=").append(StringUtilities.urlEncodeIfNeeded(pme.getValue())) } return ps.toString() } UrlInstance addParameter(Object nameObj, Object value) { String name = nameObj.toString() if (name == null || name.length() == 0 || value == null) return this String parmValue = ObjectUtilities.toPlainString(value) otherParameterMap.put(name, parmValue) if (allParameterMap != null) allParameterMap.put(name, parmValue) return this } UrlInstance addParameters(Map manualParameters) { if (manualParameters == null || manualParameters.size() == 0) return this for (Map.Entry mpEntry in manualParameters.entrySet()) { String parmKey = mpEntry.getKey().toString() // just in case a ContextStack with the context entry used is passed if ("context".equals(parmKey)) continue String parmValue = ObjectUtilities.toPlainString(mpEntry.getValue()) otherParameterMap.put(parmKey, parmValue) if (allParameterMap != null) allParameterMap.put(parmKey, parmValue) } return this } UrlInstance removeParameter(Object nameObj) { String name = nameObj.toString() if (name == null || name.length() == 0) return this otherParameterMap.remove(name) // make sure allParameterMap is populated first if (allParameterMap == null) getParameterMap() allParameterMap.remove(name) return this } Map getOtherParameterMap() { return otherParameterMap } UrlInstance passThroughSpecialParameters() { copySpecialParameters(ec.context, otherParameterMap) return this } static void copySpecialParameters(Map fromMap, Map toMap) { if (!fromMap || toMap == null) return for (String fieldName in fromMap.keySet()) { if (fieldName.startsWith("formDisplayOnly")) toMap.put(fieldName, (String) fromMap.get(fieldName)) } if (fromMap.containsKey("pageNoLimit")) toMap.put("pageNoLimit", (String) fromMap.get("pageNoLimit")) if (fromMap.containsKey("lastStandalone")) toMap.put("lastStandalone", (String) fromMap.get("lastStandalone")) if (fromMap.containsKey("renderMode")) toMap.put("renderMode", (String) fromMap.get("renderMode")) } Map getPassThroughParameterMap() { Map paramMap = new HashMap<>(getParameterMap()) paramMap.remove("moquiFormName") paramMap.remove("moquiSessionToken") paramMap.remove("lastStandalone") paramMap.remove("formListFindId") paramMap.remove("moquiRequestStartTime") paramMap.remove("webrootTT") logger.warn("pass through params for ${getUrl()}: ${paramMap}") return paramMap } UrlInstance addPassThroughParameters(UrlInstance sourceUrlInstance) { if (sourceUrlInstance == null) return null addParameters(sourceUrlInstance.getPassThroughParameterMap()) return this } UrlInstance cloneUrlInstance() { UrlInstance ui = new UrlInstance(sui, sri, expandAliasTransition) ui.curTargetTransition = curTargetTransition if (otherParameterMap) ui.otherParameterMap = new HashMap(otherParameterMap) if (transitionAliasParameters) ui.transitionAliasParameters = new HashMap(transitionAliasParameters) return ui } @Override String toString() { // return ONLY the url built from the inputs; that is the most basic possible value return this.getUrl() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgetRender.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen; public interface ScreenWidgetRender { void render(ScreenWidgets widgets, ScreenRenderImpl sri); } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgetRenderFtl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.moqui.util.ContextStack @CompileStatic class ScreenWidgetRenderFtl implements ScreenWidgetRender { ScreenWidgetRenderFtl() { } @Override void render(ScreenWidgets widgets, ScreenRenderImpl sri) { ContextStack cs = sri.ec.contextStack cs.push() try { cs.sri = sri cs.widgetsNode = widgets.getWidgetsNode() sri.template.createProcessingEnvironment(cs, sri.writer).process() } finally { cs.pop() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/ScreenWidgets.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.moqui.util.MNode import org.slf4j.LoggerFactory import org.slf4j.Logger @CompileStatic class ScreenWidgets { protected final static Logger logger = LoggerFactory.getLogger(ScreenWidgets.class) protected MNode widgetsNode protected String location ScreenWidgets(MNode widgetsNode, String location) { this.widgetsNode = widgetsNode this.location = location } MNode getWidgetsNode() { return widgetsNode } String getLocation() { return location } void render(ScreenRenderImpl sri) { ScreenWidgetRender swr = sri.getScreenWidgetRender() swr.render(this, sri) } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/screen/WebFacadeStub.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.screen import groovy.transform.CompileStatic import org.moqui.impl.context.ContextJavaUtil import org.moqui.util.ContextStack import org.moqui.context.ValidationError import org.moqui.context.WebFacade import org.moqui.context.MessageFacade.MessageInfo import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.WebFacadeImpl import org.moqui.impl.service.RestApi import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.AsyncContext import jakarta.servlet.DispatcherType import jakarta.servlet.Filter import jakarta.servlet.FilterRegistration import jakarta.servlet.RequestDispatcher import jakarta.servlet.Servlet import jakarta.servlet.ServletConnection import jakarta.servlet.ServletContext import jakarta.servlet.ServletException import jakarta.servlet.ServletInputStream import jakarta.servlet.ServletOutputStream import jakarta.servlet.ServletRegistration import jakarta.servlet.ServletRequest import jakarta.servlet.ServletResponse import jakarta.servlet.SessionCookieConfig import jakarta.servlet.SessionTrackingMode import jakarta.servlet.descriptor.JspConfigDescriptor import jakarta.servlet.http.Cookie import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpUpgradeHandler import jakarta.servlet.http.Part import java.security.Principal /** A test stub for the WebFacade interface, used in ScreenTestImpl */ @CompileStatic class WebFacadeStub implements WebFacade { protected final static Logger logger = LoggerFactory.getLogger(WebFacadeStub.class) ExecutionContextFactoryImpl ecfi ContextStack parameters = (ContextStack) null Map requestParameters = [:] Map sessionAttributes = [:] String requestMethod = "get" boolean skipJsonSerialize = false protected HttpSessionStub httpSession protected HttpServletRequestStub httpServletRequest protected ServletContextStub servletContext protected HttpServletResponseStub httpServletResponse protected StringWriter responseWriter = new StringWriter() protected PrintWriter responsePrintWriter = new PrintWriter(responseWriter) protected Object responseJsonObj = null WebFacadeStub(ExecutionContextFactoryImpl ecfi, Map requestParameters, Map sessionAttributes, String requestMethod) { this.ecfi = ecfi if (requestParameters != null) this.requestParameters.putAll(requestParameters) if (sessionAttributes != null) this.sessionAttributes = sessionAttributes if (requestMethod != null) this.requestMethod = requestMethod servletContext = new ServletContextStub(this) httpSession = new HttpSessionStub(this) httpServletRequest = new HttpServletRequestStub(this) httpServletResponse = new HttpServletResponseStub(this) } String getResponseText() { responseWriter.flush(); return responseWriter.toString() } Object getResponseJsonObj() { return responseJsonObj } HttpServletResponseStub getHttpServletResponseStub() { return httpServletResponse } String getRequestDetails() { return "Stub" } @Override String getRequestUrl() { return "TestRequestUrl" } @Override Map getParameters() { // only create when requested, then keep for additional requests if (parameters != null) return parameters ContextStack cs = new ContextStack() cs.push(sessionAttributes) cs.push(requestParameters) parameters = cs return parameters } @Override HttpServletRequest getRequest() { return httpServletRequest } @Override Map getRequestAttributes() { return requestParameters } @Override Map getRequestParameters() { return requestParameters } @Override Map getSecureRequestParameters() { return requestParameters } @Override String getHostName(boolean withPort) { return withPort ? "localhost:443" : "localhost" } @Override String getPathInfo() { return httpServletRequest.getPathInfo() } @Override ArrayList getPathInfoList() { return WebFacadeImpl.getPathInfoList(request) } @Override String getRequestBodyText() { return null } @Override String getResourceDistinctValue() { return ecfi.initStartHex } @Override HttpServletResponse getResponse() { return httpServletResponse } @Override HttpSession getSession() { return httpSession } @Override Map getSessionAttributes() { return sessionAttributes } @Override String getSessionToken() { return "TestSessionToken" } @Override ServletContext getServletContext() { return servletContext } @Override Map getApplicationAttributes() { return sessionAttributes } @Override String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption) { return useEncryption ? "https://localhost" : "http://localhost" } @Override Map getErrorParameters() { return null } @Override List getSavedMessages() { return null } @Override List getSavedPublicMessages() { return null } @Override List getSavedErrors() { return null } @Override List getSavedValidationErrors() { return null } @Override List getFieldValidationErrors(String fieldName) { return null } @Override List getScreenHistory() { return (List) sessionAttributes.get("moqui.screen.history") ?: new ArrayList() } @Override void sendJsonResponse(Object responseObj) { if (skipJsonSerialize) { responseJsonObj = responseObj } else { WebFacadeImpl.sendJsonResponseInternal(responseObj, ecfi.getEci(), httpServletRequest, httpServletResponse, requestAttributes) } /* String jsonStr if (responseObj instanceof CharSequence) { jsonStr = responseObj.toString() } else if (responseObj != null) { JsonBuilder jb = new JsonBuilder() if (responseObj instanceof Map) { jb.call((Map) responseObj) } else if (responseObj instanceof List) { jb.call((List) responseObj) } else { jb.call((Object) responseObj) } jsonStr = jb.toPrettyString() } else { jsonStr = "" } responseWriter.append(jsonStr) logger.info("WebFacadeStub sendJsonResponse ${jsonStr.length()} chars") */ } @Override void sendJsonError(int statusCode, String message, Throwable origThrowable) { WebFacadeImpl.sendJsonErrorInternal(statusCode, message, origThrowable, response) } @Override void sendTextResponse(String text) { sendTextResponse(text, "text/plain", null) } @Override void sendTextResponse(String text, String contentType, String filename) { WebFacadeImpl.sendTextResponseInternal(text, contentType, filename, ecfi.getEci(), httpServletRequest, httpServletResponse, requestAttributes) // responseWriter.append(text) // logger.info("WebFacadeStub sendTextResponse (${text.length()} chars, content type ${contentType}, filename: ${filename})") } @Override void sendResourceResponse(String location) { sendResourceResponse(location, false) } @Override void sendResourceResponse(String location, boolean inline) { WebFacadeImpl.sendResourceResponseInternal(location, inline, ecfi.getEci(), httpServletResponse) /* ResourceReference rr = ecfi.getResource().getLocationReference(location) if (rr == null) throw new IllegalArgumentException("Resource not found at: ${location}") String rrText = rr.getText() responseWriter.append(rrText) logger.info("WebFacadeStub sendResourceResponse ${rrText.length()} chars, location: ${location}") */ } @Override void sendError(int errorCode, String message, Throwable origThrowable) { response.sendError(errorCode, message) } @Override void handleJsonRpcServiceCall() { throw new IllegalArgumentException("WebFacadeStub handleJsonRpcServiceCall not supported") } @Override void handleEntityRestCall(List extraPathNameList, boolean masterNameInPath) { throw new IllegalArgumentException("WebFacadeStub handleEntityRestCall not supported") } @Override void handleServiceRestCall(List extraPathNameList) { long startTime = System.currentTimeMillis() ExecutionContextImpl eci = ecfi.getEci() eci.contextStack.push(getParameters()) RestApi.RestResult restResult = eci.serviceFacade.restApi.run(extraPathNameList, eci) eci.contextStack.pop() response.addIntHeader('X-Run-Time-ms', (System.currentTimeMillis() - startTime) as int) restResult.setHeaders(response) sendJsonResponse(restResult.responseObj) } @Override void handleSystemMessage(List extraPathNameList) { throw new IllegalArgumentException("WebFacadeStub handleSystemMessage not supported") } static class HttpServletRequestStub implements HttpServletRequest { WebFacadeStub wfs HttpServletRequestStub(WebFacadeStub wfs) { this.wfs = wfs } String getRequestId() { return null } String getProtocolRequestId() { return null } ServletConnection getServletConnection() { return null } String getAuthType() { return null } Cookie[] getCookies() { return new Cookie[0] } long getDateHeader(String s) { return System.currentTimeMillis() } String getHeader(String s) { return null } Enumeration getHeaders(String s) { return null } Enumeration getHeaderNames() { return null } int getIntHeader(String s) { return 0 } @Override String getMethod() { return wfs.requestMethod } @Override String getPathInfo() { // TODO return null } @Override String getPathTranslated() { // TODO return null } @Override String getContextPath() { // TODO return null } String getQueryString() { return null } String getRemoteUser() { return null } boolean isUserInRole(String s) { return false } Principal getUserPrincipal() { return null } String getRequestedSessionId() { return null } @Override String getRequestURI() { // TODO return null } @Override StringBuffer getRequestURL() { // TODO return null } @Override String getServletPath() { return "" } @Override HttpSession getSession(boolean b) { return wfs.httpSession } @Override HttpSession getSession() { return wfs.httpSession } @Override boolean isRequestedSessionIdValid() { return true } @Override boolean isRequestedSessionIdFromCookie() { return false } @Override boolean isRequestedSessionIdFromURL() { return false } @Override Object getAttribute(String s) { return wfs.requestParameters.get(s) } @Override Enumeration getAttributeNames() { return wfs.requestParameters.keySet() as Enumeration } @Override String getCharacterEncoding() { return "UTF-8" } @Override void setCharacterEncoding(String s) throws UnsupportedEncodingException { } @Override int getContentLength() { return 0 } @Override String getContentType() { return null } @Override ServletInputStream getInputStream() throws IOException { return null } @Override String getParameter(String s) { return wfs.requestParameters.get(s) as String } @Override Enumeration getParameterNames() { return new Enumeration() { Iterator i = wfs.requestParameters.keySet().iterator() boolean hasMoreElements() { return i.hasNext() } Object nextElement() { return i.next() } } } @Override String[] getParameterValues(String s) { Object valObj = wfs.requestParameters.get(s) if (valObj != null) { String[] retVal = new String[1] retVal[0] = valObj as String return retVal } else { return null } } @Override Map getParameterMap() { return wfs.requestParameters } @Override String getProtocol() { return "HTTP/1.1" } @Override String getScheme() { return "https" } @Override String getServerName() { return "localhost" } @Override int getServerPort() { return 443 } @Override BufferedReader getReader() throws IOException { return null } @Override String getRemoteAddr() { return "TestRemoteAddr" } @Override String getRemoteHost() { return "TestRemoteHost" } @Override void setAttribute(String s, Object o) { wfs.requestParameters.put(s, o) } @Override void removeAttribute(String s) { wfs.requestParameters.remove(s) } @Override Locale getLocale() { return Locale.ENGLISH } @Override Enumeration getLocales() { return null } @Override boolean isSecure() { return true } @Override RequestDispatcher getRequestDispatcher(String s) { return null } @Override int getRemotePort() { return 0 } @Override String getLocalName() { return "TestLocalName" } @Override String getLocalAddr() { return "TestLocalAddr" } @Override int getLocalPort() { return 443 } // ========== New methods for Servlet 3.1 ========== @Override String changeSessionId() { throw new UnsupportedOperationException() } @Override boolean authenticate(HttpServletResponse response) throws IOException, ServletException { throw new UnsupportedOperationException() } @Override void login(String username, String password) throws ServletException { throw new UnsupportedOperationException() } @Override void logout() throws ServletException { throw new UnsupportedOperationException() } @Override Collection getParts() throws IOException, ServletException { throw new UnsupportedOperationException() } @Override Part getPart(String name) throws IOException, ServletException { throw new UnsupportedOperationException() } @Override def T upgrade(Class handlerClass) throws IOException, ServletException { return null } @Override long getContentLengthLong() { return 0 } @Override ServletContext getServletContext() { return wfs.servletContext } @Override AsyncContext startAsync() throws IllegalStateException { throw new UnsupportedOperationException("startAsync not supported") } @Override AsyncContext startAsync(ServletRequest servletRequest, ServletResponse servletResponse) throws IllegalStateException { return null } @Override boolean isAsyncStarted() { return false } @Override boolean isAsyncSupported() { return false } @Override AsyncContext getAsyncContext() { throw new UnsupportedOperationException("getAsyncContext not supported") } @Override DispatcherType getDispatcherType() { throw new UnsupportedOperationException("getDispatcherType not supported") } } static class HttpSessionStub implements HttpSession { WebFacadeStub wfs HttpSessionStub(WebFacadeStub wfs) { this.wfs = wfs } long getCreationTime() { return System.currentTimeMillis() } String getId() { return "TestSessionId" } long getLastAccessedTime() { return System.currentTimeMillis() } ServletContext getServletContext() { return wfs.servletContext } void setMaxInactiveInterval(int i) { } int getMaxInactiveInterval() { return 0 } @Override Object getAttribute(String s) { return wfs.sessionAttributes.get(s) } @Override Enumeration getAttributeNames() { return new Enumeration() { Iterator i = wfs.sessionAttributes.keySet().iterator() boolean hasMoreElements() { return i.hasNext() } Object nextElement() { return i.next() } } } @Override void setAttribute(String s, Object o) { wfs.sessionAttributes.put(s, o) } @Override void removeAttribute(String s) { wfs.sessionAttributes.remove(s) } void invalidate() { } boolean isNew() { return false } } static class ServletContextStub implements ServletContext { WebFacadeStub wfs ServletContextStub(WebFacadeStub wfs) { this.wfs = wfs } ServletContext getContext(String s) { return this } String getContextPath() { return "" } int getMajorVersion() { return 3 } int getMinorVersion() { return 0 } String getMimeType(String s) { return null } Set getResourcePaths(String s) { return new HashSet() } URL getResource(String s) throws MalformedURLException { return null } InputStream getResourceAsStream(String s) { return null } RequestDispatcher getRequestDispatcher(String s) { return null } RequestDispatcher getNamedDispatcher(String s) { return null } Servlet getServlet(String s) throws ServletException { return null } Enumeration getServlets() { return null } Enumeration getServletNames() { return null } void log(String s) { } void log(Exception e, String s) { } void log(String s, Throwable throwable) { } String getRealPath(String s) { return null } String getServerInfo() { return "Web Facade Stub/1.0" } @Override String getInitParameter(String s) { return s == "moqui-name" ? "webroot" : null } @Override Enumeration getInitParameterNames() { return new Enumeration() { Iterator i = ['moqui-name'].iterator() boolean hasMoreElements() { return i.hasNext() } Object nextElement() { return i.next() } } } @Override Object getAttribute(String s) { return wfs.sessionAttributes.get(s) } @Override Enumeration getAttributeNames() { return new Enumeration() { Iterator i = wfs.sessionAttributes.keySet().iterator() boolean hasMoreElements() { return i.hasNext() } Object nextElement() { return i.next() } } } @Override void setAttribute(String s, Object o) { wfs.sessionAttributes.put(s, o) } @Override void removeAttribute(String s) { wfs.sessionAttributes.remove(s) } @Override String getServletContextName() { return "Moqui Root Webapp" } // ========== New methods for Servlet 3.1 and 4.0 ========== @Override int getEffectiveMajorVersion() { return 4 } @Override int getEffectiveMinorVersion() { return 0 } @Override boolean setInitParameter(String name, String value) { return false } @Override ServletRegistration.Dynamic addServlet(String servletName, String className) { throw new UnsupportedOperationException() } @Override ServletRegistration.Dynamic addServlet(String servletName, Servlet servlet) { throw new UnsupportedOperationException() } @Override ServletRegistration.Dynamic addServlet(String servletName, Class servletClass) { throw new UnsupportedOperationException() } @Override ServletRegistration.Dynamic addJspFile(String servletName, String jspFile) { throw new UnsupportedOperationException() } @Override def T createServlet(Class clazz) throws ServletException { throw new UnsupportedOperationException() } @Override ServletRegistration getServletRegistration(String servletName) { throw new UnsupportedOperationException() } @Override Map getServletRegistrations() { throw new UnsupportedOperationException() } @Override FilterRegistration.Dynamic addFilter(String filterName, String className) { throw new UnsupportedOperationException() } @Override FilterRegistration.Dynamic addFilter(String filterName, Filter filter) { throw new UnsupportedOperationException() } @Override FilterRegistration.Dynamic addFilter(String filterName, Class filterClass) { throw new UnsupportedOperationException() } @Override def T createFilter(Class clazz) throws ServletException { throw new UnsupportedOperationException() } @Override FilterRegistration getFilterRegistration(String filterName) { throw new UnsupportedOperationException() } @Override Map getFilterRegistrations() { throw new UnsupportedOperationException() } @Override SessionCookieConfig getSessionCookieConfig() { throw new UnsupportedOperationException() } @Override void setSessionTrackingModes(Set sessionTrackingModes) { throw new UnsupportedOperationException() } @Override Set getDefaultSessionTrackingModes() { throw new UnsupportedOperationException() } @Override Set getEffectiveSessionTrackingModes() { throw new UnsupportedOperationException() } @Override void addListener(String className) { throw new UnsupportedOperationException() } @Override def void addListener(T t) { throw new UnsupportedOperationException() } @Override void addListener(Class listenerClass) { throw new UnsupportedOperationException() } @Override def T createListener(Class clazz) throws ServletException { throw new UnsupportedOperationException() } @Override JspConfigDescriptor getJspConfigDescriptor() { throw new UnsupportedOperationException() } @Override ClassLoader getClassLoader() { throw new UnsupportedOperationException() } @Override void declareRoles(String... roleNames) { throw new UnsupportedOperationException() } @Override String getVirtualServerName() { throw new UnsupportedOperationException() } @Override int getSessionTimeout() { return 30 } @Override void setSessionTimeout(int sessionTimeout) { } @Override String getRequestCharacterEncoding() { return "UTF-8" } @Override void setRequestCharacterEncoding(String encoding) { throw new UnsupportedOperationException() } @Override String getResponseCharacterEncoding() { return "UTF-8" } @Override void setResponseCharacterEncoding(String encoding) { throw new UnsupportedOperationException() } } static class HttpServletResponseStub implements HttpServletResponse { WebFacadeStub wfs String characterEncoding = null int contentLength = 0 String contentType = null Locale locale = Locale.default int status = SC_OK Map headers = [:] HttpServletResponseStub(WebFacadeStub wfs) { this.wfs = wfs } @Override void addCookie(Cookie cookie) { } @Override boolean containsHeader(String s) { return headers.containsKey(s) } @Override String encodeURL(String s) { return null } @Override String encodeRedirectURL(String s) { return null } @Override void sendError(int i, String s) throws IOException { status = i if (s != null) wfs.responseWriter.append(s) } @Override void sendError(int i) throws IOException { status = i } @Override void sendRedirect(String s, int i, boolean b) { logger.info("HttpServletResponseStub sendRedirect to: ${s}") } @Override void setDateHeader(String s, long l) { headers.put(s, l) } @Override void addDateHeader(String s, long l) { headers.put(s, l) } @Override void setHeader(String s, String s1) { headers.put(s, s1) } @Override void addHeader(String s, String s1) { headers.put(s, s1) } @Override void setIntHeader(String s, int i) { headers.put(s, i) } @Override void addIntHeader(String s, int i) { headers.put(s, i) } @Override String getCharacterEncoding() { return characterEncoding } @Override String getContentType() { return contentType } @Override ServletOutputStream getOutputStream() throws IOException { throw new UnsupportedOperationException("Using WebFacadeStub getOutputStream is not supported") } @Override PrintWriter getWriter() throws IOException { return wfs.responsePrintWriter } @Override void setBufferSize(int i) { } @Override int getBufferSize() { return wfs.responseWriter.getBuffer().length() } @Override void flushBuffer() throws IOException { wfs.responseWriter.flush() } @Override void resetBuffer() { wfs.responseWriter = new StringWriter() } @Override boolean isCommitted() { return false } @Override void reset() { resetBuffer(); status = SC_OK; headers.clear() } @Override void setLocale(Locale locale) { this.locale = locale } @Override Locale getLocale() { return locale } // ========== New methods for Servlet 3.1 ========== @Override String getHeader(String name) { return headers.get(name) as String } @Override Collection getHeaders(String name) { return [headers.get(name) as String] } @Override Collection getHeaderNames() { return headers.keySet() } @Override void setContentLengthLong(long len) { } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/EmailEcaRule.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.apache.commons.io.IOUtils import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.mail.* import jakarta.mail.internet.MimeMessage import java.sql.Timestamp @CompileStatic class EmailEcaRule { protected final static Logger logger = LoggerFactory.getLogger(EmailEcaRule.class) protected MNode emecaNode protected String location protected XmlAction condition = null protected XmlAction actions = null EmailEcaRule(ExecutionContextFactoryImpl ecfi, MNode emecaNode, String location) { this.emecaNode = emecaNode this.location = location // prep condition if (emecaNode.hasChild("condition") && emecaNode.first("condition").children) { // the script is effectively the first child of the condition element condition = new XmlAction(ecfi, emecaNode.first("condition").children.get(0), location + ".condition") } // prep actions if (emecaNode.hasChild("actions")) { actions = new XmlAction(ecfi, emecaNode.first("actions"), location + ".actions") } } // Node getEmecaNode() { return emecaNode } void runIfMatches(MimeMessage message, String emailServerId, ExecutionContextImpl ec) { try { ec.context.push() ec.context.put("emailServerId", emailServerId) ec.context.put("message", message) Map fields = [:] ec.context.put("fields", fields) List toList = [] for (Address addr in message.getRecipients(MimeMessage.RecipientType.TO)) toList.add(addr.toString()) fields.put("toList", toList) List ccList = [] for (Address addr in message.getRecipients(MimeMessage.RecipientType.CC)) ccList.add(addr.toString()) fields.put("ccList", ccList) List bccList = [] for (Address addr in message.getRecipients(MimeMessage.RecipientType.BCC)) bccList.add(addr.toString()) fields.put("bccList", bccList) fields.put("from", message.getFrom() ? message.getFrom()[0] : null) fields.put("subject", message.getSubject()) fields.put("sentDate", message.getSentDate() ? new Timestamp(message.getSentDate().getTime()) : null) fields.put("receivedDate", message.getReceivedDate() ? new Timestamp(message.getReceivedDate().getTime()) : null) ec.context.put("bodyPartList", makeBodyPartList(message)) Map headers = [:] ec.context.put("headers", headers) Enumeration
allHeaders = message.getAllHeaders() while (allHeaders.hasMoreElements()) { Header header = allHeaders.nextElement() String headerName = header.name.toLowerCase() if (headers.get(headerName)) { Object hi = headers.get(headerName) if (hi instanceof List) { hi.add(header.value) } else { headers.put(headerName, [hi, header.value]) } } else { headers.put(headerName, header.value) } } Map flags = [:] ec.context.put("flags", flags) flags.answered = message.isSet(Flags.Flag.ANSWERED) flags.deleted = message.isSet(Flags.Flag.DELETED) flags.draft = message.isSet(Flags.Flag.DRAFT) flags.flagged = message.isSet(Flags.Flag.FLAGGED) flags.recent = message.isSet(Flags.Flag.RECENT) flags.seen = message.isSet(Flags.Flag.SEEN) // run the condition and if passes run the actions boolean conditionPassed = true if (condition) conditionPassed = condition.checkCondition(ec) // logger.info("======== EMECA ${emecaNode.attribute("rule-name")} conditionPassed? ${conditionPassed} fields:\n${fields}\nflags: ${flags}\nheaders: ${headers}") if (conditionPassed) { if (actions) actions.run(ec) } } finally { ec.context.pop() } } static List makeBodyPartList(Part part) { List bodyPartList = [] Object content = part.getContent() Map bpMap = [contentType:part.getContentType(), filename:part.getFileName(), disposition:part.getDisposition()?.toLowerCase()] if (content instanceof CharSequence) { bpMap.contentText = content.toString() bodyPartList.add(bpMap) } else if (content instanceof Multipart) { Multipart mpContent = (Multipart) content int count = mpContent.getCount() for (int i = 0; i < count; i++) { BodyPart bp = mpContent.getBodyPart(i) bodyPartList.addAll(makeBodyPartList(bp)) } } else if (content instanceof InputStream) { InputStream is = (InputStream) content bpMap.contentBytes = IOUtils.toByteArray(is) bodyPartList.add(bpMap) } return bodyPartList } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ParameterInfo.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service; import org.jsoup.Jsoup; import org.jsoup.nodes.Document; import org.jsoup.safety.Safelist; import org.moqui.impl.context.ContextJavaUtil; import org.moqui.util.MClassLoader; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.util.MNode; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.text.MessageFormat; import java.util.*; /** This is a dumb data holder class for framework internal use only; in Java for efficiency as it is used a LOT */ public class ParameterInfo { protected final static Logger logger = LoggerFactory.getLogger(ParameterInfo.class); public enum ParameterAllowHtml { ANY, SAFE, NONE } public enum ParameterType { STRING, INTEGER, LONG, FLOAT, DOUBLE, BIG_DECIMAL, BIG_INTEGER, TIME, DATE, TIMESTAMP, LIST, SET, MAP } public static Map typeEnumByString = new HashMap<>(); static { typeEnumByString.put("String", ParameterType.STRING); typeEnumByString.put("java.lang.String", ParameterType.STRING); typeEnumByString.put("Integer", ParameterType.INTEGER); typeEnumByString.put("java.lang.Integer", ParameterType.INTEGER); typeEnumByString.put("Long", ParameterType.LONG); typeEnumByString.put("java.lang.Long", ParameterType.LONG); typeEnumByString.put("Float", ParameterType.FLOAT); typeEnumByString.put("java.lang.Float", ParameterType.FLOAT); typeEnumByString.put("Double", ParameterType.DOUBLE); typeEnumByString.put("java.lang.Double", ParameterType.DOUBLE); typeEnumByString.put("BigDecimal", ParameterType.BIG_DECIMAL); typeEnumByString.put("java.math.BigDecimal", ParameterType.BIG_DECIMAL); typeEnumByString.put("BigInteger", ParameterType.BIG_INTEGER); typeEnumByString.put("java.math.BigInteger", ParameterType.BIG_INTEGER); typeEnumByString.put("Time", ParameterType.TIME); typeEnumByString.put("java.sql.Time", ParameterType.TIME); typeEnumByString.put("Date", ParameterType.DATE); typeEnumByString.put("java.sql.Date", ParameterType.DATE); typeEnumByString.put("Timestamp", ParameterType.TIMESTAMP); typeEnumByString.put("java.sql.Timestamp", ParameterType.TIMESTAMP); typeEnumByString.put("Collection", ParameterType.LIST); typeEnumByString.put("java.util.Collection", ParameterType.LIST); typeEnumByString.put("List", ParameterType.LIST); typeEnumByString.put("java.util.List", ParameterType.LIST); typeEnumByString.put("Set", ParameterType.SET); typeEnumByString.put("java.util.Set", ParameterType.SET); typeEnumByString.put("Map", ParameterType.MAP); typeEnumByString.put("java.util.Map", ParameterType.MAP); } public final ServiceDefinition sd; public final String serviceName; public final MNode parameterNode; public final String name, type, format; public final ParameterType parmType; public final Class parmClass; public final String entityName, fieldName; public final String defaultStr, defaultValue; public final boolean defaultValueNeedsExpand; public final boolean hasDefault; public final boolean thisOrChildHasDefault; public final boolean required; public final boolean disabled; public final ParameterAllowHtml allowHtml; public final boolean allowSafe; public final ParameterInfo[] childParameterInfoArray; public final ArrayList validationNodeList; public ParameterInfo(ServiceDefinition sd, MNode parameterNode) { this.sd = sd; this.parameterNode = parameterNode; serviceName = sd.serviceName; name = parameterNode.attribute("name"); String typeAttr = parameterNode.attribute("type"); type = typeAttr == null || typeAttr.isEmpty() ? "String" : typeAttr; parmType = typeEnumByString.get(type); parmClass = MClassLoader.getCommonClass(type); format = parameterNode.attribute("format"); entityName = parameterNode.attribute("entity-name"); fieldName = parameterNode.attribute("field-name"); String defaultTmp = parameterNode.attribute("default"); if (defaultTmp != null && defaultTmp.isEmpty()) defaultTmp = null; defaultStr = defaultTmp; String defaultValTmp = parameterNode.attribute("default-value"); if (defaultValTmp != null && defaultValTmp.isEmpty()) defaultValTmp = null; defaultValue = defaultValTmp; hasDefault = defaultStr != null || defaultValue != null; defaultValueNeedsExpand = defaultValue != null && defaultValue.contains("${"); required = "true".equals(parameterNode.attribute("required")); disabled = "disabled".equals(parameterNode.attribute("required")); String allowHtmlStr = parameterNode.attribute("allow-html"); if ("any".equals(allowHtmlStr)) allowHtml = ParameterAllowHtml.ANY; else if ("safe".equals(allowHtmlStr)) allowHtml = ParameterAllowHtml.SAFE; else allowHtml = ParameterAllowHtml.NONE; allowSafe = ParameterAllowHtml.SAFE == allowHtml; Map childParameterInfoMap = new HashMap<>(); ArrayList parmNameList = new ArrayList<>(); for (MNode childParmNode: parameterNode.children("parameter")) { String name = childParmNode.attribute("name"); childParameterInfoMap.put(name, new ParameterInfo(sd, childParmNode)); parmNameList.add(name); } int parmNameListSize = parmNameList.size(); boolean childHasDefault = false; if (parmNameListSize > 0) { childParameterInfoArray = new ParameterInfo[parmNameListSize]; for (int i = 0; i < parmNameListSize; i++) { String parmName = parmNameList.get(i); ParameterInfo pi = childParameterInfoMap.get(parmName); childParameterInfoArray[i] = pi; if (pi.thisOrChildHasDefault) childHasDefault = true; } } else { childParameterInfoArray = null; } thisOrChildHasDefault = hasDefault || childHasDefault; ArrayList tempValidationNodeList = new ArrayList<>(); for (MNode child: parameterNode.getChildren()) { if ("description".equals(child.getName()) || "parameter".equals(child.getName())) continue; tempValidationNodeList.add(child); } if (tempValidationNodeList.size() > 0) { validationNodeList = tempValidationNodeList; } else { validationNodeList = null; } } /** Currently used only in ServiceDefinition.checkParameterMap() */ Object convertType(String namePrefix, Object parameterValue, boolean isString, ExecutionContextImpl eci) { // no need to check for null, only called with parameterValue not empty // if (parameterValue == null) return null; // no need to check for type match, only called when types don't match // if (ObjectUtilities.isInstanceOf(parameterValue, type)) { // do type conversion if possible Object converted = null; boolean isEmptyString = isString && ((CharSequence) parameterValue).length() == 0; if (parmType != null && isString && !isEmptyString) { String valueStr = parameterValue.toString().trim(); // try some String to XYZ specific conversions for parsing with format, locale, etc switch (parmType) { case INTEGER: case LONG: case FLOAT: case DOUBLE: case BIG_DECIMAL: case BIG_INTEGER: BigDecimal bdVal = eci.l10nFacade.parseNumber(valueStr, format); if (bdVal == null) { eci.messageFacade.addValidationError(null, namePrefix + name, serviceName, MessageFormat.format(eci.getL10n().localize("Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}"),valueStr,type,(format != null ? 1 : 0),(format == null ? "" : format)), null); } else { switch (parmType) { case INTEGER: converted = bdVal.intValue(); break; case LONG: converted = bdVal.longValue(); break; case FLOAT: converted = bdVal.floatValue(); break; case DOUBLE: converted = bdVal.doubleValue(); break; case BIG_INTEGER: converted = bdVal.toBigInteger(); break; default: converted = bdVal; } } break; case TIME: converted = eci.l10nFacade.parseTime(valueStr, format); if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name, serviceName, MessageFormat.format(eci.getL10n().localize("Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}"),valueStr,type,(format != null ? 1 : 0),(format == null ? "" : format)), null); break; case DATE: converted = eci.l10nFacade.parseDate(valueStr, format); if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name, serviceName, MessageFormat.format(eci.getL10n().localize("Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}"),valueStr,type,(format != null ? 1 : 0),(format == null ? "" : format)), null); break; case TIMESTAMP: converted = eci.l10nFacade.parseTimestamp(valueStr, format); if (converted == null) eci.messageFacade.addValidationError(null, namePrefix + name, serviceName, MessageFormat.format(eci.getL10n().localize("Value entered ({0}) could not be converted to a {1}{2,choice,0#|1# using format [}{3}{2,choice,0#|1#]}"),valueStr,type,(format != null ? 1 : 0),(format == null ? "" : format)), null); break; case LIST: // strip off square braces if (valueStr.charAt(0) == '[' && valueStr.charAt(valueStr.length()-1) == ']') valueStr = valueStr.substring(1, valueStr.length()-1); // split by comma or just create a list with the single string if (valueStr.contains(",")) { converted = Arrays.asList(valueStr.split(",")); } else { List newList = new ArrayList<>(); newList.add(valueStr); converted = newList; } break; case SET: // strip off square braces if (valueStr.charAt(0) == '[' && valueStr.charAt(valueStr.length()-1) == ']') valueStr = valueStr.substring(1, valueStr.length()-1); // split by comma or just create a list with the single string if (valueStr.contains(",")) { converted = new HashSet<>(Arrays.asList(valueStr.split(","))); } else { Set newSet = new LinkedHashSet<>(); newSet.add(valueStr); converted = newSet; } break; case MAP: if (valueStr.startsWith("{")) { try { converted = ContextJavaUtil.jacksonMapper.readValue(valueStr, Map.class); } catch (Exception e) { eci.messageFacade.addValidationError(null, namePrefix + name, serviceName, "Could not convert JSON to Map", e); } } break; } } // fallback to a really simple type conversion // TODO: how to detect conversion failed to add validation error? if (converted == null && !isEmptyString) converted = ObjectUtilities.basicConvert(parameterValue, type); return converted; } Object validateParameterHtml(String namePrefix, Object parameterValue, boolean isString, ExecutionContextImpl eci) { // check for none/safe/any HTML if (isString) { return canonicalizeAndCheckHtml(sd, namePrefix, (String) parameterValue, eci); } else { Collection lst = (Collection) parameterValue; ArrayList lstClone = new ArrayList<>(lst); int lstSize = lstClone.size(); for (int i = 0; i < lstSize; i++) { Object obj = lstClone.get(i); if (obj instanceof CharSequence) { String htmlChecked = canonicalizeAndCheckHtml(sd, namePrefix, obj.toString(), eci); lstClone.set(i, htmlChecked != null ? htmlChecked : obj); } else { lstClone.set(i, obj); } } return lstClone; } } public static Document.OutputSettings outputSettings = new Document.OutputSettings().charset("UTF-8").prettyPrint(true).indentAmount(4); private String canonicalizeAndCheckHtml(ServiceDefinition sd, String namePrefix, String parameterValue, ExecutionContextImpl eci) { // NOTE DEJ20161114 Jsoup.clean() does not have a way to tell us if anything was filtered, so to avoid reformatting other // text this method now only calls Jsoup if a '<' is found // int indexOfEscape = -1; int indexOfLessThan = -1; int valueLength = parameterValue.length(); for (int i = 0; i < valueLength; i++) { char curChar = parameterValue.charAt(i); /* if (curChar == '%' || curChar == '&') { indexOfEscape = i; if (indexOfLessThan >= 0) break; } else */ if (curChar == '<') { indexOfLessThan = i; break; // if (indexOfEscape >= 0) break; } } // if (indexOfEscape < 0 && indexOfLessThan < 0) return null; if (indexOfLessThan >= 0) { if (allowSafe) { return Jsoup.clean(parameterValue, "", Safelist.relaxed(), outputSettings); } else { // check for "<"; this will protect against HTML/JavaScript injection eci.getMessage().addValidationError(null, namePrefix + name, sd.serviceName, eci.getL10n().localize("HTML not allowed including less-than (<), greater-than (>), etc symbols"), null); } } // nothing changed, return null return null; } /* Old OWASP HTML Sanitizer code (removed because heavy, depends on Guava): in framework/build.gradle: // OWASP Java HTML Sanitizer compile 'com.googlecode.owasp-java-html-sanitizer:owasp-java-html-sanitizer:20160924.1' // New BSD & Apache 2.0 SafeHtmlChangeListener changes = new SafeHtmlChangeListener(eci, sd); String cleanHtml = EbayPolicyExample.POLICY_DEFINITION.sanitize(parameterValue, changes, namePrefix.concat(name)); List cleanChanges = changes.getMessages(); // use message instead of error, accept cleaned up HTML if (cleanChanges.size() > 0) { for (String cleanChange: cleanChanges) eci.getMessage().addMessage(cleanChange); logger.info("Service parameter safe HTML messages for " + sd.serviceName + "." + name + ": " + cleanChanges); return cleanHtml; } else { // nothing changed, return null return null; } private static class SafeHtmlChangeListener implements HtmlChangeListener { private ExecutionContextImpl eci; private ServiceDefinition sd; private List messages = new LinkedList<>(); SafeHtmlChangeListener(ExecutionContextImpl eci, ServiceDefinition sd) { this.eci = eci; this.sd = sd; } List getMessages() { return messages; } @SuppressWarnings("NullableProblems") @Override public void discardedTag(@Nullable String context, String elementName) { messages.add(MessageFormat.format(eci.getL10n().localize("Removed HTML element {0} from field {1} in service {2}"), elementName, context, sd.serviceName)); } @SuppressWarnings("NullableProblems") @Override public void discardedAttributes(@Nullable String context, String tagName, String... attributeNames) { for (String attrName: attributeNames) messages.add(MessageFormat.format(eci.getL10n().localize("Removed attribute {0} from HTML element {1} from field {2} in service {3}"), attrName, tagName, context, sd.serviceName)); } } */ } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/RestApi.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.ArtifactExecutionInfo import org.moqui.context.AuthenticationRequiredException import org.moqui.context.ExecutionContext import org.moqui.entity.EntityValue import org.moqui.resource.ResourceReference import org.moqui.entity.EntityFind import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.UserFacadeImpl import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.FieldInfo import org.moqui.impl.util.RestSchemaUtil import org.moqui.jcache.MCache import org.moqui.util.CollectionUtilities import org.moqui.util.MNode import org.moqui.util.SystemBinding import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import javax.cache.Cache import java.math.RoundingMode @CompileStatic class RestApi { protected final static Logger logger = LoggerFactory.getLogger(RestApi.class) @SuppressWarnings("GrFinalVariableAccess") protected final ExecutionContextFactoryImpl ecfi @SuppressWarnings("GrFinalVariableAccess") final MCache rootResourceCache RestApi(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi rootResourceCache = ecfi.cacheFacade.getLocalCache("service.rest.api") loadRootResourceNode(null) } ResourceNode getRootResourceNode(String name) { ResourceNode resourceNode = rootResourceCache.get(name) if (resourceNode != null) return resourceNode loadRootResourceNode(name) resourceNode = rootResourceCache.get(name) if (resourceNode != null) return resourceNode throw new ResourceNotFoundException("Service REST API Root resource not found with name ${name}") } synchronized void loadRootResourceNode(String name) { if (name != null) { ResourceNode resourceNode = rootResourceCache.get(name) if (resourceNode != null) return } long startTime = System.currentTimeMillis() // find *.rest.xml files in component/service directories, put in rootResourceMap for (String location in this.ecfi.getComponentBaseLocations().values()) { ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + "/service") if (serviceDirRr.supportsAll()) { // if for some weird reason this isn't a directory, skip it if (!serviceDirRr.isDirectory()) continue for (ResourceReference rr in serviceDirRr.directoryEntries) { if (!rr.fileName.endsWith(".rest.xml")) continue MNode rootNode = MNode.parse(rr) if (name == null || name.equals(rootNode.attribute("name"))) { ResourceNode rn = new ResourceNode(rootNode, null, ecfi) rootResourceCache.put(rn.name, rn) logger.info("Loaded REST API from ${rr.getFileName()} (${rn.childPaths} paths, ${rn.childMethods} methods)") // logger.info(rn.toString()) } } } else { logger.warn("Can't load REST APIs from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc") } } logger.info("Loaded REST API files, ${rootResourceCache.size()} roots, in ${System.currentTimeMillis() - startTime}ms") } /** Used in tools dashboard screen */ List getFreshRootResources() { loadRootResourceNode(null) List rootList = new ArrayList<>() for (Cache.Entry entry in rootResourceCache.getEntryList()) rootList.add(entry.getValue()) return rootList } RestResult run(List pathList, ExecutionContextImpl ec) { if (pathList == null || pathList.size() == 0) throw new ResourceNotFoundException("Cannot run REST service with no path") String firstPath = pathList[0] ResourceNode resourceNode = getRootResourceNode(firstPath) return resourceNode.visit(pathList, 0, ec) } Map getRamlMap(String rootResourceName, String linkPrefix) { ResourceNode resourceNode = getRootResourceNode(rootResourceName) Map typesMap = new TreeMap() Map rootMap = [title:(resourceNode.displayName ?: rootResourceName + ' REST API'), version:(resourceNode.version ?: '1.0'), baseUri:linkPrefix, mediaType:'application/json', types:typesMap] as Map Map headers = ['X-Total-Count':[type:'integer', description:"Count of all results (not just current page)"], 'X-Page-Index':[type:'integer', description:"Index of current page"], 'X-Page-Size':[type:'integer', description:"Number of results per page"], 'X-Page-Max-Index':[type:'integer', description:"Highest page index given page size and count of results"], 'X-Page-Range-Low':[type:'integer', description:"Index of first result in page"], 'X-Page-Range-High':[type:'integer', description:"Index of last result in page"]] as Map rootMap.put('traits', [[paged:[queryParameters:RestSchemaUtil.ramlPaginationParameters, headers:headers]], [service:[responses:[401:[description:"Authentication required"], 403:[description:"Access Forbidden (no authz)"], 429:[description:"Too Many Requests (tarpit)"], 500:[description:"General Error"]]]], [entity:[responses:[401:[description:"Authentication required"], 403:[description:"Access Forbidden (no authz)"], 404:[description:"Value Not Found"], 429:[description:"Too Many Requests (tarpit)"], 500:[description:"General Error"]]]] ]) Map childrenMap = resourceNode.getRamlChildrenMap(typesMap) rootMap.put('/' + rootResourceName, childrenMap) return rootMap } Map getSwaggerMap(List rootPathList, List schemes, String hostName, String basePath) { // TODO: support generate for all roots with empty path if (!rootPathList) throw new ResourceNotFoundException("No resource path specified") String rootResourceName = rootPathList[0] ResourceNode resourceNode = getRootResourceNode(rootResourceName) StringBuilder fullBasePath = new StringBuilder(basePath) for (String rootPath in rootPathList) fullBasePath.append('/').append(rootPath) Map paths = [:] // NOTE: using LinkedHashMap though TreeMap would be nice as saw odd behavior where TreeMap.put() did nothing Map definitions = new LinkedHashMap() Map swaggerMap = [swagger:'2.0', info:[title:(resourceNode.displayName ?: "Service REST API (${fullBasePath})"), version:(resourceNode.version ?: '1.0'), description:(resourceNode.description ?: '')], host:hostName, basePath:fullBasePath.toString(), schemes:schemes, securityDefinitions:[basicAuth:[type:'basic', description:'HTTP Basic Authentication'], api_key:[type:"apiKey", name:"api_key", in:"header", description:'HTTP Header api_key']], consumes:['application/json', 'multipart/form-data'], produces:['application/json'], ] // add tags for 2nd level resources if (rootPathList.size() >= 1) { List tags = [] for (ResourceNode childResource in resourceNode.getResourceMap().values()) tags.add([name:childResource.name, description:(childResource.description ?: childResource.name)]) swaggerMap.put("tags", tags) } swaggerMap.put("paths", paths) swaggerMap.put("definitions", definitions) resourceNode.addToSwaggerMap(swaggerMap, rootPathList) int methodsCount = 0 for (Map rsMap in paths.values()) methodsCount += rsMap.size() logger.info("Generated Swagger for ${rootPathList}; ${paths.size()} (${resourceNode.childPaths}) paths with ${methodsCount} (${resourceNode.childMethods}) methods, ${definitions.size()} definitions") return swaggerMap } static abstract class MethodHandler { ExecutionContextFactoryImpl ecfi String method PathNode pathNode String requireAuthentication MethodHandler(MNode methodNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi method = methodNode.attribute("type") this.pathNode = pathNode requireAuthentication = methodNode.attribute("require-authentication") ?: pathNode.requireAuthentication ?: "true" } abstract RestResult run(List pathList, ExecutionContext ec) abstract void addToSwaggerMap(Map swaggerMap, Map> resourceMap) abstract Map getRamlMap(Map typesMap) abstract void toString(int level, StringBuilder sb) } protected static final Map objectTypeJsonMap = [ Integer:"integer", Long:"integer", Short:"integer", Float:"number", Double:"number", BigDecimal:"number", BigInteger:"integer", Boolean:"boolean", List:"array", Set:"array", Collection:"array", Map:"object", EntityValue:"object", EntityList:"array" ] static String getJsonType(String javaType) { if (!javaType) return "string" if (javaType.contains(".")) javaType = javaType.substring(javaType.lastIndexOf(".") + 1) return objectTypeJsonMap.get(javaType) ?: "string" } protected static final Map objectJsonFormatMap = [ Integer:"int32", Long:"int64", Short:"int32", Float:"float", Double:"double", BigDecimal:"", BigInteger:"int64", Date:"date", Timestamp:"date-time", Boolean:"", List:"", Set:"", Collection:"", Map:"" ] static String getJsonFormat(String javaType) { if (!javaType) return "" if (javaType.contains(".")) javaType = javaType.substring(javaType.lastIndexOf(".") + 1) return objectJsonFormatMap.get(javaType) ?: "" } protected static final Map objectTypeRamlMap = [ Integer:"integer", Long:"integer", Short:"integer", Float:"number", Double:"number", BigDecimal:"number", BigInteger:"integer", Boolean:"boolean", List:"array", Set:"array", Collection:"array", Map:"object", EntityValue:"object", EntityList:"array" ] static String getRamlType(String javaType) { if (!javaType) return "string" if (javaType.contains(".")) javaType = javaType.substring(javaType.lastIndexOf(".") + 1) return objectTypeRamlMap.get(javaType) ?: "string" } static class MethodService extends MethodHandler { String serviceName MethodService(MNode methodNode, MNode serviceNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) { super(methodNode, pathNode, ecfi) serviceName = serviceNode.attribute("name") } RestResult run(List pathList, ExecutionContext ec) { if ((requireAuthentication == null || requireAuthentication.length() == 0 || "true".equals(requireAuthentication)) && !ec.getUser().getUsername()) { throw new AuthenticationRequiredException("User must be logged in to call service ${serviceName}") } boolean loggedInAnonymous = false if ("anonymous-all".equals(requireAuthentication)) { ec.artifactExecution.setAnonymousAuthorizedAll() loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser() } else if ("anonymous-view".equals(requireAuthentication)) { ec.artifactExecution.setAnonymousAuthorizedView() loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser() } try { Map result = ec.getService().sync().name(serviceName).parameters(ec.context).call() ServiceDefinition.nestedRemoveNullsFromResultMap(result) return new RestResult(result, null) } finally { if (loggedInAnonymous) ((UserFacadeImpl) ec.getUser()).logoutAnonymousOnly() } } void addToSwaggerMap(Map swaggerMap, Map> resourceMap) { ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(serviceName) if (sd == null) throw new IllegalArgumentException("Service ${serviceName} not found") MNode serviceNode = sd.serviceNode Map definitionsMap = (Map) swaggerMap.definitions // add parameters, including path parameters List parameters = [] Set remainingInParmNames = new LinkedHashSet(sd.getInParameterNames()) for (String pathParm in pathNode.pathParameters) { MNode parmNode = sd.getInParameter(pathParm) if (parmNode == null) throw new IllegalArgumentException("No in parameter found for path parameter ${pathParm} in service ${sd.serviceName}") parameters.add([name:pathParm, in:'path', required:true, type:getJsonType((String) parmNode?.attribute('type')), description:parmNode.first("description")?.text]) remainingInParmNames.remove(pathParm) } if (remainingInParmNames) { if (method in ['post', 'put', 'patch']) { parameters.add([name:'body', in:'body', required:true, schema:['$ref':"#/definitions/${sd.serviceNameNoHash}.In".toString()]]) // add a definition for service in parameters definitionsMap.put("${sd.serviceNameNoHash}.In".toString(), RestSchemaUtil.getJsonSchemaMapIn(sd)) } else { for (String parmName in remainingInParmNames) { MNode parmNode = sd.getInParameter(parmName) String javaType = parmNode.attribute("type") String jsonType = getJsonType(javaType) // these are query parameters because method doesn't support body, so skip objects and arrays // (in many services they are not needed, pre-lookup sorts of objects; use post or something if needed) if (jsonType == 'object' || jsonType == 'array') continue Map propMap = [name:parmName, in:'query', required:false, type:jsonType, format:getJsonFormat(javaType), description:parmNode.first("description")?.text] as Map parameters.add(propMap) RestSchemaUtil.addParameterEnums(sd, parmNode, propMap) } } } // add responses Map responses = ["401":[description:"Authentication required"], "403":[description:"Access Forbidden (no authz)"], "429":[description:"Too Many Requests (tarpit)"], "500":[description:"General Error"]] as Map if (sd.getOutParameterNames().size() > 0) { responses.put("200", [description:'Success', schema:['$ref':"#/definitions/${sd.serviceNameNoHash}.Out".toString()]]) definitionsMap.put("${sd.serviceNameNoHash}.Out".toString(), RestSchemaUtil.getJsonSchemaMapOut(sd)) } Map curMap = new LinkedHashMap() if (swaggerMap.tags && pathNode.fullPathList.size() > 1) curMap.put("tags", [pathNode.fullPathList[1]]) curMap.putAll([summary:(serviceNode.attribute("displayName") ?: "${sd.verb} ${sd.noun}".toString()), description:serviceNode.first("description")?.text, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses]) resourceMap.put(method, curMap) } Map getRamlMap(Map typesMap) { ServiceDefinition sd = ecfi.serviceFacade.getServiceDefinition(serviceName) if (sd == null) throw new IllegalArgumentException("Service ${serviceName} not found") MNode serviceNode = sd.serviceNode Map ramlMap = [is:['service'], displayName:(serviceNode.attribute("displayName") ?: "${sd.verb} ${sd.noun}".toString())] as Map // add parameters, including path parameters Set remainingInParmNames = new LinkedHashSet(sd.getInParameterNames()) for (String pathParm in pathNode.pathParameters) remainingInParmNames.remove(pathParm) if (remainingInParmNames) { ramlMap.put("body", ['application/json': [type:"${sd.serviceName}.In".toString()]]) // add a definition for service in parameters typesMap.put("${sd.serviceName}.In".toString(), RestSchemaUtil.getRamlMapIn(sd)) } if (sd.getOutParameterNames().size() > 0) { ramlMap.put("responses", [200:[body:['application/json': [type:"${sd.serviceName}.Out".toString()]]]]) typesMap.put("${sd.serviceName}.Out".toString(), RestSchemaUtil.getRamlMapOut(sd)) } return ramlMap } void toString(int level, StringBuilder sb) { for (int i=0; i < (level * 4); i++) sb.append(" ") sb.append(method).append(": service - ").append(serviceName).append("\n") } } static class MethodEntity extends MethodHandler { String entityName, masterName, operation MethodEntity(MNode methodNode, MNode entityNode, PathNode pathNode, ExecutionContextFactoryImpl ecfi) { super(methodNode, pathNode, ecfi) entityName = entityNode.attribute("name") masterName = entityNode.attribute("masterName") operation = entityNode.attribute("operation") } RestResult run(List pathList, ExecutionContext ec) { // for entity ops authc always required if ((requireAuthentication == null || requireAuthentication.length() == 0 || "true".equals(requireAuthentication)) && !ec.getUser().getUsername()) { throw new AuthenticationRequiredException("User must be logged in for operaton ${operation} on entity ${entityName}") } boolean loggedInAnonymous = false if ("anonymous-all".equals(requireAuthentication)) { ec.artifactExecution.setAnonymousAuthorizedAll() loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser() } else if ("anonymous-view".equals(requireAuthentication)) { ec.artifactExecution.setAnonymousAuthorizedView() loggedInAnonymous = ec.getUser().loginAnonymousIfNoUser() } try { if (operation == 'one') { EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false) if (masterName) { return new RestResult(ef.oneMaster(masterName), null) } else { EntityValue val = ef.one() return new RestResult(val != null ? CollectionUtilities.removeNullsFromMap(val.getMap()) : null, null) } } else if (operation == 'list') { EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false) // we don't want to go overboard with these requests, never do an unlimited find, if no limit use 100 if (!ef.getLimit() && !"true".equals(ec.context.get("pageNoLimit"))) ef.limit(100) int count = ef.count() as int int pageIndex = ef.getPageIndex() int pageSize = ef.getPageSize() int pageMaxIndex = ((count - 1) as BigDecimal).divide(pageSize as BigDecimal, 0, RoundingMode.DOWN).intValue() int pageRangeLow = pageIndex * pageSize + 1 int pageRangeHigh = (pageIndex * pageSize) + pageSize if (pageRangeHigh > count) pageRangeHigh = count Map headers = ['X-Total-Count':count, 'X-Page-Index':pageIndex, 'X-Page-Size':pageSize, 'X-Page-Max-Index':pageMaxIndex, 'X-Page-Range-Low':pageRangeLow, 'X-Page-Range-High':pageRangeHigh] as Map if (masterName) { return new RestResult(ef.listMaster(masterName), headers) } else { return new RestResult(ef.list().getValueMapList(), headers) } } else if (operation == 'count') { EntityFind ef = ec.entity.find(entityName).searchFormMap(ec.context, null, null, null, false) long count = ef.count() Map headers = ['X-Total-Count':count] as Map return new RestResult([count:count], headers) } else if (operation in ['create', 'update', 'store', 'delete']) { Map result = ec.getService().sync().name(operation, entityName).parameters(ec.context).call() return new RestResult(result, null) } else { throw new IllegalArgumentException("Entity operation ${operation} not supported, must be one of: one, list, count, create, update, store, delete") } } finally { if (loggedInAnonymous) ((UserFacadeImpl) ec.getUser()).logoutAnonymousOnly() } } void addToSwaggerMap(Map swaggerMap, Map> resourceMap) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) if (ed == null) throw new IllegalArgumentException("Entity ${entityName} not found") // Node entityNode = ed.getEntityNode() Map definitionsMap = ((Map) swaggerMap.definitions) String refDefName = ed.getShortOrFullEntityName() if (masterName) refDefName = refDefName + "." + masterName String refDefNamePk = refDefName + ".PK" // add path parameters List parameters = [] ArrayList remainingPkFields = new ArrayList(ed.getPkFieldNames()) for (String pathParm in pathNode.pathParameters) { FieldInfo fi = ed.getFieldInfo(pathParm) if (fi == null) throw new IllegalArgumentException("No field found for path parameter ${pathParm} in entity ${ed.getFullEntityName()}") parameters.add([name:pathParm, in:'path', required:true, type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: "string"), description:fi.fieldNode.first("description")?.text]) remainingPkFields.remove(pathParm) } // add responses Map responses = ["401":[description:"Authentication required"], "403":[description:"Access Forbidden (no authz)"], "404":[description:"Value Not Found"], "429":[description:"Too Many Requests (tarpit)"], "500":[description:"General Error"]] as Map boolean addEntityDef = true boolean addPkDef = false if (operation == 'one') { if (remainingPkFields) { for (String fieldName in remainingPkFields) { FieldInfo fi = ed.getFieldInfo(fieldName) Map fieldMap = [name:fieldName, in:'query', required:false, type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: "string"), format:(RestSchemaUtil.fieldTypeJsonFormatMap.get(fi.type) ?: ""), description:fi.fieldNode.first("description")?.text] as Map parameters.add(fieldMap) List enumList = RestSchemaUtil.getFieldEnums(ed, fi) if (enumList) fieldMap.put('enum', enumList) } } responses.put("200", [description:'Success', schema:['$ref':"#/definitions/${refDefName}".toString()]]) } else if (operation == 'list') { parameters.addAll(RestSchemaUtil.swaggerPaginationParameters) for (String fieldName in ed.getAllFieldNames()) { if (fieldName in pathNode.pathParameters) continue FieldInfo fi = ed.getFieldInfo(fieldName) parameters.add([name:fieldName, in:'query', required:false, type:(RestSchemaUtil.fieldTypeJsonMap.get(fi.type) ?: "string"), format:(RestSchemaUtil.fieldTypeJsonFormatMap.get(fi.type) ?: ""), description:fi.fieldNode.first("description")?.text]) } // parameters.add([name:'body', in:'body', required:false, schema:[allOf:[['$ref':'#/definitions/paginationParameters'], ['$ref':"#/definitions/${refDefName}"]]]]) responses.put("200", [description:'Success', schema:[type:"array", items:['$ref':"#/definitions/${refDefName}".toString()]]]) } else if (operation == 'count') { parameters.add([name:'body', in:'body', required:false, schema:['$ref':"#/definitions/${refDefName}".toString()]]) responses.put("200", [description:'Success', schema:RestSchemaUtil.jsonCountParameters]) } else if (operation in ['create', 'update', 'store']) { parameters.add([name:'body', in:'body', required:false, schema:['$ref':"#/definitions/${refDefName}".toString()]]) responses.put("200", [description:'Success', schema:['$ref':"#/definitions/${refDefNamePk}".toString()]]) addPkDef = true } else if (operation == 'delete') { addEntityDef = false if (remainingPkFields) { parameters.add([name:'body', in:'body', required:false, schema:['$ref':"#/definitions/${refDefNamePk}".toString()]]) addPkDef = true } } Map curMap = new LinkedHashMap() String summary = "${operation} ${ed.entityInfo.internalEntityName}" if (masterName) summary = summary + " (master: " + masterName + ")" if (swaggerMap.tags && pathNode.fullPathList.size() > 1) curMap.put("tags", [pathNode.fullPathList[1]]) curMap.putAll([summary:summary, description:ed.getEntityNode().first("description")?.text, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses]) resourceMap.put(method, curMap) // add a definition for entity fields if (addEntityDef) definitionsMap.put(refDefName, RestSchemaUtil.getJsonSchema(ed, false, false, definitionsMap, null, null, null, false, masterName, null)) if (addPkDef) definitionsMap.put(refDefNamePk, RestSchemaUtil.getJsonSchema(ed, true, false, null, null, null, null, false, masterName, null)) } Map getRamlMap(Map typesMap) { Map ramlMap = null EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(entityName) if (ed == null) throw new IllegalArgumentException("Entity ${entityName} not found") String refDefName = ed.getShortOrFullEntityName() if (masterName) refDefName = refDefName + "." + masterName String prettyName = ed.getPrettyName(null, null) // add path parameters ArrayList remainingPkFields = new ArrayList(ed.getPkFieldNames()) for (String pathParm in pathNode.pathParameters) { remainingPkFields.remove(pathParm) } Map pkQpMap = [:] for (int i = 0; i < remainingPkFields.size(); i++) { FieldInfo fi = ed.getFieldInfo(remainingPkFields.get(i)) pkQpMap.put(fi.name, RestSchemaUtil.getRamlFieldMap(ed, fi)) } Map allQpMap = [:] ArrayList allFields = ed.getAllFieldNames() for (int i = 0; i < allFields.size(); i++) { FieldInfo fi = ed.getFieldInfo(allFields.get(i)) allQpMap.put(fi.name, RestSchemaUtil.getRamlFieldMap(ed, fi)) } boolean addEntityDef = true if (operation == 'one') { ramlMap = [is:['entity'], displayName:"Get single ${prettyName}".toString()] as Map if (pkQpMap) ramlMap.put('queryParameters', pkQpMap) ramlMap.put("responses", [200:[body:['application/json': [type:refDefName]]]]) } else if (operation == 'list') { // TODO: add pagination headers ramlMap = [is:['paged', 'entity'], displayName:"Get list of ${prettyName}".toString(), body:['application/json': [type:refDefName]]] ramlMap.put("responses", [200:[body:['application/json': [type:"array", items:refDefName]]]]) } else if (operation == 'count') { ramlMap = [is:['entity'], displayName:"Count ${prettyName}".toString(), body:['application/json': [type:refDefName]]] as Map ramlMap.put("responses", [200:[body:['application/json': RestSchemaUtil.jsonCountParameters]]]) } else if (operation == 'create') { ramlMap = [is:['entity'], displayName:"Create ${prettyName}".toString(), body:['application/json': [type:refDefName]]] as Map if (pkQpMap) ramlMap.put("responses", [200:[body:['application/json': [type:'object', properties:pkQpMap]]]]) } else if (operation == 'update') { ramlMap = [is:['entity'], displayName:"Update ${prettyName}".toString(), body:['application/json': [type:refDefName]]] as Map } else if (operation == 'store') { ramlMap = [is:['entity'], displayName:"Create or Update ${prettyName}".toString(), body:['application/json': [type:refDefName]]] as Map if (pkQpMap) ramlMap.put("responses", [200:[body:['application/json': [type:'object', properties:pkQpMap]]]]) } else if (operation == 'delete') { ramlMap = [is:['entity'], displayName:"Delete ${prettyName}".toString()] as Map if (pkQpMap) ramlMap.put('queryParameters', pkQpMap) addEntityDef = false } if (addEntityDef) RestSchemaUtil.getRamlTypeMap(ed, false, typesMap, masterName, null) return ramlMap } void toString(int level, StringBuilder sb) { for (int i=0; i < (level * 4); i++) sb.append(" ") sb.append(method).append(": entity - ").append(operation).append(" - ").append(entityName) if (masterName) sb.append(" (master: ").append(masterName).append(")") sb.append("\n") } } static abstract class PathNode { ExecutionContextFactoryImpl ecfi String displayName, description, version Map methodMap = [:] IdNode idNode = null Map resourceMap = [:] String name String requireAuthentication PathNode parent List fullPathList = [] Set pathParameters = new LinkedHashSet() int childPaths = 0 int childMethods = 0 PathNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi, boolean isId) { this.ecfi = ecfi this.parent = parent displayName = node.attribute("displayName") description = node.attribute("description") version = node.attribute("version") if (version && version.contains('${')) version = SystemBinding.expand(version) if (parent != null) this.pathParameters.addAll(parent.pathParameters) name = node.attribute("name") if (parent != null) fullPathList.addAll(parent.fullPathList) fullPathList.add(isId ? "{${name}}".toString() : name) if (isId) pathParameters.add(name) requireAuthentication = node.attribute("require-authentication") ?: parent?.requireAuthentication ?: "true" for (MNode childNode in node.children) { if (childNode.name == "method") { String method = childNode.attribute("type") MNode methodNode = childNode.children[0] if (methodNode.name == "service") { methodMap.put(method, new MethodService(childNode, methodNode, this, ecfi)) } else if (methodNode.name == "entity") { methodMap.put(method, new MethodEntity(childNode, methodNode, this, ecfi)) } } else if (childNode.name == "resource") { ResourceNode resourceNode = new ResourceNode(childNode, this, ecfi) resourceMap.put(resourceNode.name, resourceNode) } else if (childNode.name == "id") { idNode = new IdNode(childNode, this, ecfi) } } childMethods += methodMap.size() for (ResourceNode rn in resourceMap.values()) { childPaths++ childPaths += rn.childPaths childMethods += rn.childMethods } if (idNode != null) { childPaths++ childPaths += idNode.childPaths childMethods += idNode.childMethods } } RestResult runByMethod(List pathList, ExecutionContext ec) { String method = getCurrentMethod(ec) MethodHandler mh = (MethodHandler) methodMap.get(method) if (mh == null) throw new MethodNotSupportedException("Method ${method} not supported at ${pathList}") return mh.run(pathList, ec) } private String getCurrentMethod(ExecutionContext ec) { HttpServletRequest request = ec.web.getRequest() String method = request.getMethod().toLowerCase() if ("post".equals(method)) { String ovdMethod = request.getHeader("X-HTTP-Method-Override") if (ovdMethod != null && !ovdMethod.isEmpty()) method = ovdMethod.toLowerCase() } return method } RestResult visitChildOrRun(List pathList, int pathIndex, ExecutionContextImpl ec) { // more in path? visit the next, otherwise run by request method int nextPathIndex = pathIndex + 1 boolean moreInPath = pathList.size() > nextPathIndex // push onto artifact stack, check authz String curPath = getFullPathName([]) ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(curPath, ArtifactExecutionInfo.AT_REST_PATH, getActionFromMethod(ec), null) // for now don't track/count artifact hits for REST path aei.setTrackArtifactHit(false) // NOTE: consider setting parameters on aei, but don't like setting entire context, currently used for entity/service calls ec.artifactExecutionFacade.pushInternal(aei, !moreInPath ? (requireAuthentication == null || requireAuthentication.length() == 0 || "true".equals(requireAuthentication)) : false, true) boolean loggedInAnonymous = false if ("anonymous-all".equals(requireAuthentication)) { ec.artifactExecutionFacade.setAnonymousAuthorizedAll() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } else if ("anonymous-view".equals(requireAuthentication)) { ec.artifactExecutionFacade.setAnonymousAuthorizedView() loggedInAnonymous = ec.userFacade.loginAnonymousIfNoUser() } try { if (moreInPath) { String nextPath = pathList[nextPathIndex] // first try resources ResourceNode rn = resourceMap.get(nextPath) if (rn != null) { return rn.visit(pathList, nextPathIndex, ec) } else if (idNode != null) { // no resource? if there is an idNode treat as ID return idNode.visit(pathList, nextPathIndex, ec) } else { // not a resource and no idNode, is a bad path throw new ResourceNotFoundException("Resource ${nextPath} not valid, index ${pathIndex} in path ${pathList}; resources available are ${resourceMap.keySet()}") } } else { // if there is a child id node and it has allow-extra-path=true then try using it, allow id with extra path to have no extra path if (idNode != null && idNode.allowExtraPath && methodMap.get(getCurrentMethod(ec)) == null) { return idNode.visit(pathList, nextPathIndex, ec) } return runByMethod(pathList, ec) } } finally { ec.artifactExecutionFacade.pop(aei) if (loggedInAnonymous) ec.userFacade.logoutAnonymousOnly() } } void addToSwaggerMap(Map swaggerMap, List rootPathList) { // see if we are in the root path specified int curIndex = fullPathList.size() - 1 if (curIndex < rootPathList.size() && fullPathList[curIndex] != rootPathList[curIndex]) return // if we have method handlers add this, otherwise just do children if (rootPathList.size() - 1 <= curIndex && methodMap) { String curPath = getFullPathName(rootPathList) Map> rsMap = [:] for (MethodHandler mh in methodMap.values()) mh.addToSwaggerMap(swaggerMap, rsMap) ((Map) swaggerMap.paths).put(curPath ?: '/', rsMap) } // add the id node if there is one if (idNode != null) idNode.addToSwaggerMap(swaggerMap, rootPathList) // add any resource nodes there might be for (ResourceNode rn in resourceMap.values()) rn.addToSwaggerMap(swaggerMap, rootPathList) } String getFullPathName(List rootPathList) { StringBuilder curPath = new StringBuilder() for (int i = rootPathList.size(); i < fullPathList.size(); i++) { String pathItem = fullPathList.get(i) curPath.append('/').append(pathItem) } return curPath.toString() } static final Map actionByMethodMap = [get:ArtifactExecutionInfo.AUTHZA_VIEW, patch:ArtifactExecutionInfo.AUTHZA_UPDATE, put:ArtifactExecutionInfo.AUTHZA_UPDATE, post:ArtifactExecutionInfo.AUTHZA_CREATE, delete:ArtifactExecutionInfo.AUTHZA_DELETE, options:ArtifactExecutionInfo.AUTHZA_VIEW, head:ArtifactExecutionInfo.AUTHZA_VIEW] static ArtifactExecutionInfo.AuthzAction getActionFromMethod(ExecutionContext ec) { String method = ec.web.getRequest().getMethod().toLowerCase() return actionByMethodMap.get(method) } Map getRamlChildrenMap(Map typesMap) { Map childrenMap = [:] // add displayName, description if (displayName) childrenMap.put('displayName', displayName) if (description) childrenMap.put('description', description) // if we have method handlers add this, otherwise just do children if (methodMap) for (MethodHandler mh in methodMap.values()) childrenMap.put(mh.method, mh.getRamlMap(typesMap)) // add the id node if there is one if (idNode != null) childrenMap.put('/{' + idNode.name + '}', idNode.getRamlChildrenMap(typesMap)) // add any resource nodes there might be for (ResourceNode rn in resourceMap.values()) childrenMap.put('/' + rn.name, rn.getRamlChildrenMap(typesMap)) return childrenMap } void toStringChildren(int level, StringBuilder sb) { for (MethodHandler mh in methodMap.values()) mh.toString(level + 1, sb) for (ResourceNode rn in resourceMap.values()) rn.toString(level + 1, sb) if (idNode != null) idNode.toString(level + 1, sb) } abstract Object visit(List pathList, int pathIndex, ExecutionContextImpl ec) } static class ResourceNode extends PathNode { ResourceNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi) { super(node, parent, ecfi, false) } RestResult visit(List pathList, int pathIndex, ExecutionContextImpl ec) { // logger.info("Visit resource ${name}") // visit child or run here return visitChildOrRun(pathList, pathIndex, ec) } String toString() { StringBuilder sb = new StringBuilder() toString(0, sb) return sb.toString() } void toString(int level, StringBuilder sb) { for (int i=0; i < (level * 4); i++) sb.append(" ") sb.append("/").append(name) if (displayName) sb.append(" - ").append(displayName) sb.append("\n") toStringChildren(level, sb) } } static class IdNode extends PathNode { private boolean allowExtraPath = false IdNode(MNode node, PathNode parent, ExecutionContextFactoryImpl ecfi) { super(node, parent, ecfi, true) allowExtraPath = "true".equals(node.attribute("allow-extra-path")) } RestResult visit(List pathList, int pathIndex, ExecutionContextImpl ec) { // logger.info("Visit id ${name}") // set ID value in context if (allowExtraPath) { // handle allow-extra-path, make a List of this path element plus all after it // path elements remaining to include in this list, including the current element int elementsRemaining = pathList.size() - pathIndex ArrayList pathElements = new ArrayList<>() // note that this may do nothing if pathIndex = pathList.size() (ie no extra path elements) for (int i = pathIndex; i < pathList.size(); i++) pathElements.add(pathList.get(i)) ec.context.put(name, pathElements) // add elementsRemaining - 1 (for the current element) to advance pathIndex to the end pathIndex += (elementsRemaining - 1) } else { ec.context.put(name, pathList.get(pathIndex)) } // visit child or run here return visitChildOrRun(pathList, pathIndex, ec) } void toString(int level, StringBuilder sb) { for (int i=0; i < (level * 4); i++) sb.append(" ") sb.append("/{").append(name).append("}\n") toStringChildren(level, sb) } } static class RestResult { Object responseObj Map headers = [:] RestResult(Object responseObj, Map headers) { this.responseObj = responseObj if (headers) this.headers.putAll(headers) } void setHeaders(HttpServletResponse response) { for (Map.Entry entry in headers) { Object value = entry.value if (value == null) continue if (value instanceof Integer) { response.setIntHeader(entry.key, (int) value) } else if (value instanceof Date) { response.setDateHeader(entry.key, ((Date) value).getTime()) } else { response.setHeader(entry.key, value.toString()) } } } } static class ResourceNotFoundException extends BaseException { ResourceNotFoundException(String str) { super(str) } // ResourceNotFoundException(String str, Throwable nested) { super(str, nested) } } static class MethodNotSupportedException extends BaseException { MethodNotSupportedException(String str) { super(str) } // MethodNotSupportedException(String str, Throwable nested) { super(str, nested) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ScheduledJobRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import com.cronutils.descriptor.CronDescriptor import com.cronutils.model.Cron import com.cronutils.model.CronType import com.cronutils.model.definition.CronDefinition import com.cronutils.model.definition.CronDefinitionBuilder import com.cronutils.model.time.ExecutionTime import com.cronutils.parser.CronParser import groovy.transform.CompileStatic import org.moqui.entity.EntityCondition import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp import java.time.Instant import java.time.ZoneId import java.time.ZonedDateTime import java.util.concurrent.ThreadPoolExecutor /** * Runs scheduled jobs as defined in ServiceJob records with a cronExpression. Cron expression uses Quartz flavored syntax. * * Uses cron-utils for cron processing, see: * https://github.com/jmrozanec/cron-utils * For a Quartz cron reference see: * http://www.quartz-scheduler.org/documentation/quartz-2.x/tutorials/crontrigger.html * https://www.quartz-scheduler.org/api/2.2.1/org/quartz/CronExpression.html * * Handy cron strings: [0 0 2 * * ?] every night at 2:00 am, [0 0/15 * * * ?] every 15 minutes, [0 0/2 * * * ?] every 2 minutes */ @CompileStatic class ScheduledJobRunner implements Runnable { private final static Logger logger = LoggerFactory.getLogger(ScheduledJobRunner.class) private final ExecutionContextFactoryImpl ecfi private final static CronDefinition cronDefinition = CronDefinitionBuilder.instanceDefinitionFor(CronType.QUARTZ) private final static CronParser parser = new CronParser(cronDefinition) private final static Map cronByExpression = new HashMap<>() private long lastExecuteTime = 0 private int jobQueueMax = 0, executeCount = 0, totalJobsRun = 0, lastJobsActive = 0, lastJobsPaused = 0 ScheduledJobRunner(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi MNode serviceFacadeNode = ecfi.confXmlRoot.first("service-facade") jobQueueMax = (serviceFacadeNode.attribute("job-queue-max") ?: "0") as int } // NOTE: these are called in the service job screens long getLastExecuteTime() { lastExecuteTime } int getExecuteCount() { executeCount } int getTotalJobsRun() { totalJobsRun } int getLastJobsActive() { lastJobsActive } int getLastJobsPaused() { lastJobsPaused } @Override synchronized void run() { try { runInternal() } catch (Throwable t) { logger.error("Uncaught Throwable in ScheduledJobRunner, catching and suppressing to avoid removal from scheduler", t) } } void runInternal() { ZonedDateTime now = ZonedDateTime.now() long nowMillis = now.toInstant().toEpochMilli() Timestamp nowTimestamp = new Timestamp(nowMillis) int jobsRun = 0, jobsActive = 0, jobsPaused = 0, jobsReadyNotRun = 0 // Get ExecutionContext, just for disable authz ExecutionContextImpl eci = ecfi.getEci() eci.artifactExecution.disableAuthz() EntityFacadeImpl efi = ecfi.entityFacade ThreadPoolExecutor jobWorkerPool = ecfi.serviceFacade.jobWorkerPool try { // make sure no transaction is in place, shouldn't be any so try to commit if there is one if (ecfi.transactionFacade.isTransactionInPlace()) { logger.error("Found transaction in place in ScheduledJobRunner thread ${Thread.currentThread().getName()}, trying to commit") try { ecfi.transactionFacade.destroyAllInThread() } catch (Exception e) { logger.error(" Commit of in-place transaction failed for ScheduledJobRunner thread ${Thread.currentThread().getName()}", e) } } // look at jobWorkerPool to see how many jobs we can run: (jobQueueMax + poolMax) - (active + queueSize) int jobSlots = jobQueueMax + jobWorkerPool.getMaximumPoolSize() int jobsRunning = jobWorkerPool.getActiveCount() + jobWorkerPool.queue.size() int jobSlotsAvailable = jobSlots - jobsRunning // if we can't handle any more jobs if (jobSlotsAvailable <= 0) { logger.info("ScheduledJobRunner doing nothing, already ${jobsRunning} of ${jobSlots} jobs running") } // find scheduled jobs EntityList serviceJobList = efi.find("moqui.service.job.ServiceJob").useCache(false) .condition("cronExpression", EntityCondition.ComparisonOperator.NOT_EQUAL, null) .orderBy("priority").orderBy("jobName").list() serviceJobList.filterByDate("fromDate", "thruDate", nowTimestamp) int serviceJobListSize = serviceJobList.size() for (int i = 0; i < serviceJobListSize; i++) { EntityValue serviceJob = (EntityValue) serviceJobList.get(i) String jobName = (String) serviceJob.jobName // a job is ACTIVE if the paused field is null or 'N', so skip for any other value for paused (Y, T, whatever) if (serviceJob.paused != null && !"N".equals(serviceJob.paused)) { jobsPaused++ continue } if (serviceJob.repeatCount != null) { long repeatCount = ((Long) serviceJob.repeatCount).longValue() long runCount = efi.find("moqui.service.job.ServiceJobRun").condition("jobName", jobName).useCache(false).count() if (runCount >= repeatCount) { // pause the job and set thruDate for faster future filtering ecfi.service.sync().name("update", "moqui.service.job.ServiceJob") .parameters([jobName: jobName, paused:'Y', thruDate:nowTimestamp] as Map) .disableAuthz().call() continue } } jobsActive++ String jobRunId EntityValue serviceJobRun EntityValue serviceJobRunLock Timestamp lastRunTime // get a lock, see if another instance is running the job // now we need to run in a transaction; note that this is running in a executor service thread, no tx should ever be in place boolean beganTransaction = ecfi.transaction.begin(null) try { serviceJobRunLock = efi.find("moqui.service.job.ServiceJobRunLock") .condition("jobName", jobName).forUpdate(true).one() lastRunTime = (Timestamp) serviceJobRunLock?.lastRunTime ZonedDateTime lastRunDt = (lastRunTime != (Timestamp) null) ? ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastRunTime.getTime()), now.getZone()) : null if (serviceJobRunLock != null && serviceJobRunLock.jobRunId != null && lastRunDt != null) { // for failure with no lock reset: run recovery, based on expireLockTime (default to 1440 minutes) Long expireLockTime = (Long) serviceJob.expireLockTime if (expireLockTime == null) expireLockTime = 1440L ZonedDateTime lockCheckTime = now.minusMinutes(expireLockTime.intValue()) if (lastRunDt.isBefore(lockCheckTime)) { // recover failed job without lock reset, run it if schedule says to logger.warn("Lock expired: found lock for job ${jobName} from ${lastRunDt}, more than ${expireLockTime} minutes old, ignoring lock") serviceJobRunLock.set("jobRunId", null).update() } else { // normal lock, skip this job logger.info("Lock found for job ${jobName} from ${lastRunDt} run ID ${serviceJobRunLock.jobRunId}, not running") continue } } // calculate time it should have run last String cronExpression = (String) serviceJob.getNoCheckSimple("cronExpression") ExecutionTime executionTime = getExecutionTime(cronExpression) ZonedDateTime lastSchedule = executionTime.lastExecution(now).get() if (lastSchedule != null && lastRunDt != null) { // if the time it should have run last is before the time it ran last don't run it if (lastSchedule.isBefore(lastRunDt)) continue } // if the last run had an error check the minRetryTime, don't run if hasn't been long enough EntityValue lastJobRun = efi.find("moqui.service.job.ServiceJobRun").condition("jobName", jobName) .orderBy("-startTime").limit(1).useCache(false).list().getFirst() if (lastJobRun != null && "Y".equals(lastJobRun.hasError)) { Timestamp lastErrorTime = (Timestamp) lastJobRun.endTime ?: (Timestamp) lastJobRun.startTime if (lastErrorTime != (Timestamp) null) { ZonedDateTime lastErrorDt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastErrorTime.getTime()), now.getZone()) Long minRetryTime = (Long) serviceJob.minRetryTime ?: 5L ZonedDateTime retryCheckTime = now.minusMinutes(minRetryTime.intValue()) // if last error time after retry check time don't run the job if (lastErrorDt.isAfter(retryCheckTime)) { logger.info("Not retrying job ${jobName} after error, before ${minRetryTime} min retry minutes (error run at ${lastErrorDt})") continue } } } // if no more job slots available continue, don't break because we want to loop through all to get jobsPaused, jobsActive, etc if (jobSlotsAvailable <= 0) { jobsReadyNotRun++ continue } // create a job run and lock it serviceJobRun = efi.makeValue("moqui.service.job.ServiceJobRun") .set("jobName", jobName).setSequencedIdPrimary().create() jobRunId = (String) serviceJobRun.getNoCheckSimple("jobRunId") if (serviceJobRunLock == null) { serviceJobRunLock = efi.makeValue("moqui.service.job.ServiceJobRunLock").set("jobName", jobName) .set("jobRunId", jobRunId).set("lastRunTime", nowTimestamp).create() } else { serviceJobRunLock.set("jobRunId", jobRunId).set("lastRunTime", nowTimestamp).update() } logger.info("Running job ${jobName} run ${jobRunId} (last run ${lastRunTime}, schedule ${lastSchedule})") } catch (Throwable t) { String errMsg = "Error getting and checking service job run lock" ecfi.transaction.rollback(beganTransaction, errMsg, t) logger.error(errMsg, t) continue } finally { ecfi.transaction.commit(beganTransaction) } jobsRun++ jobSlotsAvailable-- if (jobSlotsAvailable <= 0) { logger.info("ScheduledJobRunner out of job slots after running ${jobsRun} jobs, ${jobSlots} jobs running, evaluated ${i} of ${serviceJobListSize} ServiceJob records") } // at this point jobRunId and serviceJobRunLock should not be null ServiceCallJobImpl serviceCallJob = new ServiceCallJobImpl(jobName, ecfi.serviceFacade) // use the job run we created serviceCallJob.withJobRunId(jobRunId) serviceCallJob.withLastRunTime(lastRunTime) // clear the lock when finished serviceCallJob.clearLock() // always run locally to use service job's worker pool and keep queue of pending jobs in the database serviceCallJob.localOnly(true) // run it, will run async try { serviceCallJob.run() } catch (Throwable t) { logger.error("Error running scheduled job ${jobName}", t) ecfi.transactionFacade.runUseOrBegin(null, "Error clearing lock and saving error on scheduled job run error", { serviceJobRunLock.set("jobRunId", null).update() serviceJobRun.set("hasError", "Y").set("errors", t.toString()).set("startTime", nowTimestamp) .set("endTime", nowTimestamp).update() }) } // end of for loop } } catch (Throwable t) { logger.error("Uncaught error in scheduled job runner", t) } finally { // no need, we're destroying the eci: if (!authzDisabled) eci.artifactExecution.enableAuthz() eci.destroy() } // update job runner stats lastExecuteTime = nowMillis executeCount++ totalJobsRun += jobsRun lastJobsActive = jobsActive lastJobsPaused = jobsPaused int jobSlots = jobQueueMax + jobWorkerPool.getMaximumPoolSize() int jobsRunning = jobWorkerPool.getActiveCount() + jobWorkerPool.queue.size() if (jobsRun > 0 || logger.isTraceEnabled()) { String infoStr = "Ran ${jobsRun} Service Jobs starting ${now} - active: ${jobsActive}, paused: ${jobsPaused}; on this server using ${jobsRunning} of ${jobSlots} job slots" if (jobsReadyNotRun > 0) infoStr += ", ${jobsReadyNotRun} jobs ready but not run (insufficient job slots)" logger.info(infoStr) } } static Cron getCron(String cronExpression) { Cron cachedCron = cronByExpression.get(cronExpression) if (cachedCron != null) return cachedCron Cron cron = parser.parse(cronExpression) cronByExpression.put(cronExpression, cron) return cron } static ExecutionTime getExecutionTime(String cronExpression) { return ExecutionTime.forCron(getCron(cronExpression)) } /** Use to determine if it is time to run again, if returns true then run and if false don't run. * See if lastRun is before last scheduled run time based on cronExpression and nowTimestamp (defaults to current date/time) */ static boolean isLastRunBeforeLastSchedule(String cronExpression, Timestamp lastRun, String description, Timestamp nowTimestamp) { try { if (lastRun == (Timestamp) null) return true ZonedDateTime now = nowTimestamp != (Timestamp) null ? ZonedDateTime.ofInstant(Instant.ofEpochMilli(nowTimestamp.getTime()), ZoneId.systemDefault()) : ZonedDateTime.now() def lastRunDt = ZonedDateTime.ofInstant(Instant.ofEpochMilli(lastRun.getTime()), now.getZone()) ExecutionTime executionTime = getExecutionTime(cronExpression) ZonedDateTime lastSchedule = executionTime.lastExecution(now).get() if (lastSchedule == null) return false if (lastRunDt == null) return true return lastRunDt.isBefore(lastSchedule) } catch (Throwable t) { logger.error("Error processing Cron Expression ${cronExpression} and Last Run ${lastRun} for ${description}, skipping", t) return false } } static String getCronDescription(String cronExpression, Locale locale, boolean handleInvalid) { if (cronExpression == null || cronExpression.isEmpty()) return null if (locale == null) locale = Locale.US try { return CronDescriptor.instance(locale).describe(getCron(cronExpression)) } catch (Exception e) { if (handleInvalid) { return "Invalid cron '${cronExpression}': ${e.message}" } else { throw e } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceCallAsyncImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.moqui.Moqui import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.service.ServiceCallAsync import org.moqui.service.ServiceException import org.moqui.impl.context.ExecutionContextImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.Callable import java.util.concurrent.Future @CompileStatic class ServiceCallAsyncImpl extends ServiceCallImpl implements ServiceCallAsync { protected final static Logger logger = LoggerFactory.getLogger(ServiceCallAsyncImpl.class) protected boolean distribute = false ServiceCallAsyncImpl(ServiceFacadeImpl sfi) { super(sfi) } @Override ServiceCallAsync name(String serviceName) { serviceNameInternal(serviceName); return this } @Override ServiceCallAsync name(String v, String n) { serviceNameInternal(null, v, n); return this } @Override ServiceCallAsync name(String p, String v, String n) { serviceNameInternal(p, v, n); return this } @Override ServiceCallAsync parameters(Map map) { parameters.putAll(map); return this } @Override ServiceCallAsync parameter(String name, Object value) { parameters.put(name, value); return this } @Override ServiceCallAsync distribute(boolean dist) { this.distribute = dist; return this } @Override void call() { ExecutionContextFactoryImpl ecfi = sfi.ecfi ExecutionContextImpl eci = ecfi.getEci() validateCall(eci) AsyncServiceRunnable runnable = new AsyncServiceRunnable(eci, serviceName, parameters) if (distribute && sfi.distributedExecutorService != null) { sfi.distributedExecutorService.execute(runnable) } else { ecfi.workerPool.execute(runnable) } } @Override Future> callFuture() throws ServiceException { ExecutionContextFactoryImpl ecfi = sfi.ecfi ExecutionContextImpl eci = ecfi.getEci() validateCall(eci) AsyncServiceCallable callable = new AsyncServiceCallable(eci, serviceName, parameters) if (distribute && sfi.distributedExecutorService != null) { return sfi.distributedExecutorService.submit(callable) } else { return ecfi.workerPool.submit(callable) } } @Override Runnable getRunnable() { return new AsyncServiceRunnable(sfi.ecfi.getEci(), serviceName, parameters) } @Override Callable> getCallable() { return new AsyncServiceCallable(sfi.ecfi.getEci(), serviceName, parameters) } static class AsyncServiceInfo implements Externalizable { transient ExecutionContextFactoryImpl ecfiLocal String threadUsername String serviceName Map parameters AsyncServiceInfo() { } AsyncServiceInfo(ExecutionContextImpl eci, String serviceName, Map parameters) { ecfiLocal = eci.ecfi threadUsername = eci.userFacade.username this.serviceName = serviceName this.parameters = new HashMap<>(parameters) } AsyncServiceInfo(ExecutionContextFactoryImpl ecfi, String username, String serviceName, Map parameters) { ecfiLocal = ecfi threadUsername = username this.serviceName = serviceName this.parameters = new HashMap<>(parameters) } @Override void writeExternal(ObjectOutput out) throws IOException { out.writeObject(threadUsername) // might be null out.writeUTF(serviceName) // never null out.writeObject(parameters) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { threadUsername = (String) objectInput.readObject() serviceName = objectInput.readUTF() parameters = (Map) objectInput.readObject() } ExecutionContextFactoryImpl getEcfi() { if (ecfiLocal == null) ecfiLocal = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() return ecfiLocal } Map runInternal() throws Exception { return runInternal(null, false) } Map runInternal(Map parameters, boolean skipEcCheck) throws Exception { ExecutionContextImpl threadEci = (ExecutionContextImpl) null try { // check for active Transaction if (getEcfi().transactionFacade.isTransactionInPlace()) { logger.error("In ServiceCallAsync service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit") try { getEcfi().transactionFacade.destroyAllInThread() } catch (Exception e) { logger.error("ServiceCallAsync commit in place transaction failed for thread ${Thread.currentThread().getName()}", e) } } // check for active ExecutionContext if (!skipEcCheck) { ExecutionContextImpl activeEc = getEcfi().activeContext.get() if (activeEc != null) { logger.error("In ServiceCallAsync service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying") try { activeEc.destroy() } catch (Throwable t) { logger.error("Error destroying ExecutionContext already in place in ServiceCallAsync in thread ${Thread.currentThread().id}:${Thread.currentThread().name}", t) } } } threadEci = getEcfi().getEci() if (threadUsername != null && threadUsername.length() > 0) { threadEci.userFacade.internalLoginUser(threadUsername, false) } else { threadEci.userFacade.loginAnonymousIfNoUser() } Map parmsToUse = this.parameters if (parameters != null) { parmsToUse = new HashMap<>(this.parameters) parmsToUse.putAll(parameters) } // NOTE: authz is disabled because authz is checked before queueing Map result = threadEci.serviceFacade.sync().name(serviceName).parameters(parmsToUse).disableAuthz().call() return result } catch (Throwable t) { logger.error("Error in async service", t) throw t } finally { if (threadEci != null) threadEci.destroy() } } } static class AsyncServiceRunnable extends AsyncServiceInfo implements Runnable, Externalizable { AsyncServiceRunnable() { super() } AsyncServiceRunnable(ExecutionContextImpl eci, String serviceName, Map parameters) { super(eci, serviceName, parameters) } @Override void run() { runInternal() } } static class AsyncServiceCallable extends AsyncServiceInfo implements Callable>, Externalizable { AsyncServiceCallable() { super() } AsyncServiceCallable(ExecutionContextImpl eci, String serviceName, Map parameters) { super(eci, serviceName, parameters) } @Override Map call() throws Exception { return runInternal() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceCallImpl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.impl.context.ArtifactExecutionInfoImpl; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.service.ServiceCall; import org.moqui.service.ServiceException; import java.util.HashMap; import java.util.Map; public class ServiceCallImpl implements ServiceCall { protected final ServiceFacadeImpl sfi; protected String path = null; protected String verb = null; protected String noun = null; protected ServiceDefinition sd = null; protected boolean noSd = false; protected String serviceName = null; protected String serviceNameNoHash = null; protected Map parameters = new HashMap<>(); public ServiceCallImpl(ServiceFacadeImpl sfi) { this.sfi = sfi; } protected void serviceNameInternal(String serviceName) { if (serviceName == null || serviceName.isEmpty()) throw new ServiceException("Service name cannot be empty"); sd = sfi.getServiceDefinition(serviceName); if (sd != null) { path = sd.verb; verb = sd.verb; noun = sd.verb; this.serviceName = sd.serviceName; serviceNameNoHash = sd.serviceNameNoHash; } else { path = ServiceDefinition.getPathFromName(serviceName); verb = ServiceDefinition.getVerbFromName(serviceName); noun = ServiceDefinition.getNounFromName(serviceName); // if the service is not found must be an entity auto, but if there is a path then error if (path == null || path.isEmpty()) { noSd = true; } else { throw new ServiceException("Service not found with name " + serviceName); } this.serviceName = serviceName; serviceNameNoHash = serviceName.replace("#", ""); } } protected void serviceNameInternal(String path, String verb, String noun) { if (path == null || path.isEmpty()) { noSd = true; } else { this.path = path; } this.verb = verb; this.noun = noun; StringBuilder sb = new StringBuilder(); if (!noSd) sb.append(path).append('.'); sb.append(verb); if (noun != null && !noun.isEmpty()) sb.append('#').append(noun); serviceName = sb.toString(); if (noSd) { serviceNameNoHash = serviceName.replace("#", ""); } else { sd = sfi.getServiceDefinition(serviceName); if (sd == null) throw new ServiceException("Service not found with name " + serviceName + " (path: " + path + ", verb: " + verb + ", noun: " + noun + ")"); serviceNameNoHash = sd.serviceNameNoHash; } } @Override public String getServiceName() { return serviceName; } @Override public Map getCurrentParameters() { return parameters; } public ServiceDefinition getServiceDefinition() { // this should now never happen, sd now always set on name set // if (sd == null && !noSd) sd = sfi.getServiceDefinition(serviceName); return sd; } public boolean isEntityAutoPattern() { return noSd; // return sfi.isEntityAutoPattern(path, verb, noun); } public void validateCall(ExecutionContextImpl eci) { // Before scheduling the service check a few basic things so they show up sooner than later: ServiceDefinition sd = sfi.getServiceDefinition(getServiceName()); if (sd == null && !isEntityAutoPattern()) throw new ServiceException("Could not find service with name [" + getServiceName() + "]"); if (sd != null) { String serviceType = sd.serviceType; if (serviceType == null || serviceType.isEmpty()) serviceType = "inline"; if ("interface".equals(serviceType)) throw new ServiceException("Cannot run interface service [" + getServiceName() + "]"); ServiceRunner sr = sfi.getServiceRunner(serviceType); if (sr == null) throw new ServiceException("Could not find service runner for type [" + serviceType + "] for service [" + getServiceName() + "]"); // validation parameters = sd.convertValidateCleanParameters(parameters, eci); // if error(s) in parameters, return now with no results if (eci.getMessage().hasError()) return; } // always do an authz before scheduling the job ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(getServiceName(), ArtifactExecutionInfo.AT_SERVICE, ServiceDefinition.getVerbAuthzActionEnum(verb), null); aei.setTrackArtifactHit(false); eci.artifactExecutionFacade.pushInternal(aei, (sd != null && "true".equals(sd.authenticate)), true); // pop immediately, just did the push to to an authz eci.artifactExecutionFacade.pop(aei); parameters.put("authUsername", eci.userFacade.getUsername()); // logger.warn("=========== async call ${serviceName}, parameters: ${parameters}") } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceCallJobImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.json.JsonOutput import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.Moqui import org.moqui.context.NotificationMessage import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.service.ServiceCallJob import org.moqui.service.ServiceCallSync import org.moqui.service.ServiceException import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp import java.util.concurrent.Callable import java.util.concurrent.ExecutionException import java.util.concurrent.Future import java.util.concurrent.TimeUnit import java.util.concurrent.TimeoutException @CompileStatic class ServiceCallJobImpl extends ServiceCallImpl implements ServiceCallJob { protected final static Logger logger = LoggerFactory.getLogger(ServiceCallJobImpl.class) private String jobName private EntityValue serviceJob private Future> runFuture = (Future) null private String withJobRunId = (String) null private Timestamp lastRunTime = (Timestamp) null private boolean clearLock = false private boolean localOnly = false ServiceCallJobImpl(String jobName, ServiceFacadeImpl sfi) { super(sfi) ExecutionContextImpl eci = sfi.ecfi.getEci() // get ServiceJob, make sure exists this.jobName = jobName serviceJob = eci.entityFacade.fastFindOne("moqui.service.job.ServiceJob", true, true, jobName) if (serviceJob == null) throw new BaseArtifactException("No ServiceJob record found for jobName ${jobName}") // set ServiceJobParameter values EntityList serviceJobParameters = eci.entity.find("moqui.service.job.ServiceJobParameter") .condition("jobName", jobName).useCache(true).disableAuthz().list() for (EntityValue serviceJobParameter in serviceJobParameters) parameters.put((String) serviceJobParameter.parameterName, serviceJobParameter.parameterValue) // set the serviceName so rest of ServiceCallImpl works serviceNameInternal((String) serviceJob.serviceName) } @Override ServiceCallJob parameters(Map map) { parameters.putAll(map); return this } @Override ServiceCallJob parameter(String name, Object value) { parameters.put(name, value); return this } @Override ServiceCallJob localOnly(boolean local) { localOnly = local; return this } ServiceCallJobImpl withJobRunId(String jobRunId) { withJobRunId = jobRunId; return this } ServiceCallJobImpl withLastRunTime(Timestamp lastRunTime) { this.lastRunTime = lastRunTime; return this } ServiceCallJobImpl clearLock() { clearLock = true; return this } @Override String run() throws ServiceException { ExecutionContextFactoryImpl ecfi = sfi.ecfi ExecutionContextImpl eci = ecfi.getEci() validateCall(eci) String jobRunId if (withJobRunId == null) { // create the ServiceJobRun record String parametersString = JsonOutput.toJson(parameters) Map jobRunResult = ecfi.service.sync().name("create", "moqui.service.job.ServiceJobRun") .parameters([jobName:jobName, userId:eci.user.userId, parameters:parametersString] as Map) .disableAuthz().requireNewTransaction(true).call() jobRunId = jobRunResult.jobRunId } else { jobRunId = withJobRunId } // run it ServiceJobCallable callable = new ServiceJobCallable(eci, serviceJob, jobRunId, lastRunTime, clearLock, parameters) if (sfi.distributedExecutorService == null || localOnly || "Y".equals(serviceJob.localOnly)) { runFuture = sfi.jobWorkerPool.submit(callable) } else { runFuture = sfi.distributedExecutorService.submit(callable) } return jobRunId } @Override boolean cancel(boolean mayInterruptIfRunning) { if (runFuture == null) throw new IllegalStateException("Must call run() before using Future interface methods") return runFuture.cancel(mayInterruptIfRunning) } @Override boolean isCancelled() { if (runFuture == null) throw new IllegalStateException("Must call run() before using Future interface methods") return runFuture.isCancelled() } @Override boolean isDone() { if (runFuture == null) throw new IllegalStateException("Must call run() before using Future interface methods") return runFuture.isDone() } @Override Map get() throws InterruptedException, ExecutionException { if (runFuture == null) throw new IllegalStateException("Must call run() before using Future interface methods") return runFuture.get() } @Override Map get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { if (runFuture == null) throw new IllegalStateException("Must call run() before using Future interface methods") return runFuture.get(timeout, unit) } static class ServiceJobCallable implements Callable>, Externalizable { transient ExecutionContextFactoryImpl ecfi String threadUsername, currentUserId String jobName, jobDescription, serviceName, topic, jobRunId Map parameters Timestamp lastRunTime = (Timestamp) null boolean clearLock int transactionTimeout // default constructor for deserialization only! ServiceJobCallable() { } ServiceJobCallable(ExecutionContextImpl eci, Map serviceJob, String jobRunId, Timestamp lastRunTime, boolean clearLock, Map parameters) { ecfi = eci.ecfi threadUsername = eci.userFacade.username currentUserId = eci.userFacade.userId jobName = (String) serviceJob.jobName jobDescription = (String) serviceJob.description serviceName = (String) serviceJob.serviceName topic = (String) serviceJob.topic transactionTimeout = (serviceJob.transactionTimeout ?: 1800) as int this.jobRunId = jobRunId this.lastRunTime = lastRunTime this.clearLock = clearLock this.parameters = new HashMap<>(parameters) } @Override void writeExternal(ObjectOutput out) throws IOException { out.writeObject(threadUsername) // might be null out.writeObject(currentUserId) // might be null out.writeUTF(jobName) // never null out.writeObject(jobDescription) // might be null out.writeUTF(serviceName) // never null out.writeObject(topic) // might be null out.writeUTF(jobRunId) // never null out.writeObject(lastRunTime) // might be null out.writeBoolean(clearLock) out.writeInt(transactionTimeout) out.writeObject(parameters) } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { threadUsername = (String) objectInput.readObject() currentUserId = (String) objectInput.readObject() jobName = objectInput.readUTF() jobDescription = objectInput.readObject() serviceName = objectInput.readUTF() topic = (String) objectInput.readObject() jobRunId = objectInput.readUTF() lastRunTime = (Timestamp) objectInput.readObject() clearLock = objectInput.readBoolean() transactionTimeout = objectInput.readInt() parameters = (Map) objectInput.readObject() } ExecutionContextFactoryImpl getEcfi() { if (ecfi == null) ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() return ecfi } @Override Map call() throws Exception { ExecutionContextImpl threadEci = (ExecutionContextImpl) null try { ExecutionContextFactoryImpl ecfi = getEcfi() if (ecfi == null) { String errMsg = "ExecutionContextFactory not initialized, cannot call service job ${jobName} with run ID ${jobRunId}" logger.error(errMsg) throw new IllegalStateException(errMsg) } // check for active Transaction if (ecfi.transactionFacade.isTransactionInPlace()) { logger.error("In ServiceCallJob ${jobName} service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit") try { ecfi.transactionFacade.destroyAllInThread() } catch (Exception e) { logger.error("ServiceCallJob commit in place transaction failed for thread ${Thread.currentThread().getName()}", e) } } // check for active ExecutionContext ExecutionContextImpl activeEc = ecfi.activeContext.get() if (activeEc != null) { logger.error("In ServiceCallJob ${jobName} service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying") try { activeEc.destroy() } catch (Throwable t) { logger.error("Error destroying ExecutionContext already in place in ServiceCallJob in thread ${Thread.currentThread().id}:${Thread.currentThread().name}", t) } } // get a fresh ExecutionContext threadEci = ecfi.getEci() if (threadUsername != null && threadUsername.length() > 0) threadEci.userFacade.internalLoginUser(threadUsername, false) // set hostAddress, hostName, runThread, startTime on ServiceJobRun InetAddress localHost = ecfi.getLocalhostAddress() // NOTE: no need to run async or separate thread, is in separate TX because no wrapping TX for these service calls ecfi.serviceFacade.sync().name("update", "moqui.service.job.ServiceJobRun") .parameters([jobRunId:jobRunId, hostAddress:(localHost?.getHostAddress() ?: '127.0.0.1'), hostName:(localHost?.getHostName() ?: 'localhost'), runThread:Thread.currentThread().getName(), startTime:threadEci.user.nowTimestamp] as Map) .disableAuthz().call() if (lastRunTime != (Object) null) parameters.put("lastRunTime", lastRunTime) // NOTE: authz is disabled because authz is checked before queueing Map results = new HashMap<>() try { results = ecfi.serviceFacade.sync().name(serviceName).parameters(parameters) .transactionTimeout(transactionTimeout).disableAuthz().call() } catch (Throwable t) { logger.error("Error in service job call", t) threadEci.messageFacade.addError(t.toString()) } // set endTime, results, messages, errors on ServiceJobRun String resultString = (String) null if (results != null) { if (results.containsKey(null)) { logger.warn("Service Job ${jobName} results has a null key with value ${results.get(null)}, removing") results.remove(null) } try { resultString = JsonOutput.toJson(results) } catch (Exception e) { logger.warn("Error writing JSON for Service Job ${jobName} results: ${e.toString()}\n${results}") } } boolean hasError = threadEci.messageFacade.hasError() String messages = threadEci.messageFacade.getMessagesString() if (messages != null && messages.length() > 4000) messages = messages.substring(0, 4000) String errors = hasError ? threadEci.messageFacade.getErrorsString() : null if (errors != null && errors.length() > 4000) errors = errors.substring(0, 4000) Timestamp nowTimestamp = threadEci.userFacade.nowTimestamp // before calling other services clear out errors or they won't run if (hasError) threadEci.messageFacade.clearErrors() // clear the ServiceJobRunLock if there is one if (clearLock) { ServiceCallSync scs = ecfi.serviceFacade.sync().name("update", "moqui.service.job.ServiceJobRunLock") .parameter("jobName", jobName).parameter("jobRunId", null) .disableAuthz() // if there was an error set lastRunTime to previous if (hasError) scs.parameter("lastRunTime", lastRunTime) scs.call() } // NOTE: no need to run async or separate thread, is in separate TX because no wrapping TX for these service calls ecfi.serviceFacade.sync().name("update", "moqui.service.job.ServiceJobRun") .parameters([jobRunId:jobRunId, endTime:nowTimestamp, results:resultString, messages:messages, hasError:(hasError ? 'Y' : 'N'), errors:errors] as Map) .disableAuthz().call() // notifications Map msgMap = (Map) null EntityList serviceJobUsers = (EntityList) null if (topic || hasError) { msgMap = new HashMap<>() msgMap.put("serviceCallRun", [jobName:jobName, description:jobDescription, jobRunId:jobRunId, endTime:nowTimestamp, messages:messages, hasError:hasError, errors:errors]) msgMap.put("parameters", parameters) msgMap.put("results", results) serviceJobUsers = threadEci.entityFacade.find("moqui.service.job.ServiceJobUser") .condition("jobName", jobName).useCache(true).disableAuthz().list() } // if topic send NotificationMessage if (topic) { NotificationMessage nm = threadEci.makeNotificationMessage().topic(topic) nm.message(msgMap) if (currentUserId) nm.userId(currentUserId) for (EntityValue serviceJobUser in serviceJobUsers) if (serviceJobUser.receiveNotifications != 'N') nm.userId((String) serviceJobUser.userId) nm.type(hasError ? NotificationMessage.danger : NotificationMessage.success) nm.send() } // if hasError send general error notification if (hasError) { NotificationMessage nm = threadEci.makeNotificationMessage().topic("ServiceJobError") .type(NotificationMessage.danger) .title('''Job Error ${serviceCallRun.jobName?:''} [${serviceCallRun.jobRunId?:''}] ${serviceCallRun.errors?:'N/A'}''') .message(msgMap) if (currentUserId) nm.userId(currentUserId) for (EntityValue serviceJobUser in serviceJobUsers) if (serviceJobUser.receiveNotifications != 'N') nm.userId((String) serviceJobUser.userId) nm.send() } return results } catch (Throwable t) { logger.error("Error in service job handling", t) // better to not throw? seems to cause issue with scheduler: throw t return null } finally { if (threadEci != null) threadEci.destroy() } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceCallSpecialImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.moqui.BaseArtifactException import org.moqui.service.ServiceException import jakarta.transaction.Status import jakarta.transaction.Synchronization import jakarta.transaction.Transaction import jakarta.transaction.TransactionManager import javax.transaction.xa.XAException import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.service.ServiceCallSpecial import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ServiceCallSpecialImpl extends ServiceCallImpl implements ServiceCallSpecial { ServiceCallSpecialImpl(ServiceFacadeImpl sfi) { super(sfi) } @Override ServiceCallSpecial name(String serviceName) { serviceNameInternal(serviceName); return this } @Override ServiceCallSpecial name(String v, String n) { serviceNameInternal(null, v, n); return this } @Override ServiceCallSpecial name(String p, String v, String n) { serviceNameInternal(p, v, n); return this } @Override ServiceCallSpecial parameters(Map map) { parameters.putAll(map); return this } @Override ServiceCallSpecial parameter(String name, Object value) { parameters.put(name, value); return this } @Override void registerOnCommit() { if (getServiceDefinition() == null && !isEntityAutoPattern()) throw new ServiceException("Could not find service with name [${getServiceName()}]") ServiceSynchronization sxr = new ServiceSynchronization(this, sfi.ecfi, true) sxr.enlist() } @Override void registerOnRollback() { if (getServiceDefinition() == null && !isEntityAutoPattern()) throw new ServiceException("Could not find service with name [${getServiceName()}]") ServiceSynchronization sxr = new ServiceSynchronization(this, sfi.ecfi, false) sxr.enlist() } static class ServiceSynchronization implements Synchronization { protected final static Logger logger = LoggerFactory.getLogger(ServiceSynchronization.class) protected ExecutionContextFactoryImpl ecfi protected String serviceName protected Map parameters protected boolean runOnCommit protected Transaction tx = null ServiceSynchronization(ServiceCallSpecialImpl scsi, ExecutionContextFactoryImpl ecfi, boolean runOnCommit) { this.ecfi = ecfi this.serviceName = scsi.getServiceName() this.parameters = new HashMap(scsi.parameters) this.runOnCommit = runOnCommit } void enlist() { TransactionManager tm = ecfi.transactionFacade.getTransactionManager() if (tm == null && tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException("Cannot enlist: no transaction manager or transaction not active") Transaction tx = tm.getTransaction(); if (tx == null) throw new XAException(XAException.XAER_NOTA) this.tx = tx tx.registerSynchronization(this) } @Override void beforeCompletion() { } @Override void afterCompletion(int status) { if (status == Status.STATUS_COMMITTED) { if (runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call() } else { if (!runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call() } } /* Old XAResource (and Thread) approach: protected Xid xid = null protected Integer timeout = null protected boolean active = false protected boolean suspended = false @Override void start(Xid xid, int flag) throws XAException { if (this.active) { if (this.xid != null && this.xid.equals(xid)) { throw new XAException(XAException.XAER_DUPID); } else { throw new XAException(XAException.XAER_PROTO); } } if (this.xid != null && !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) this.active = true this.suspended = false this.xid = xid // start a thread with this object to do something on timeout this.setName("ServiceSynchronizationThread") this.setDaemon(true) this.start() } @Override void end(Xid xid, int flag) throws XAException { if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) if (flag == TMSUSPEND) { if (!this.active) throw new XAException(XAException.XAER_PROTO) this.suspended = true } if (flag == TMSUCCESS || flag == TMFAIL) { // allow a success/fail end if TX is suspended without a resume flagged start first if (!this.active && !this.suspended) throw new XAException(XAException.XAER_PROTO) } this.active = false } @Override void forget(Xid xid) throws XAException { if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) this.xid = null if (active) logger.warn("forget() called without end()") } @Override int prepare(Xid xid) throws XAException { if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) return XA_OK } @Override Xid[] recover(int flag) throws XAException { return this.xid != null ? [this.xid] : [] } @Override boolean isSameRM(XAResource xaResource) throws XAException { return xaResource == this } @Override int getTransactionTimeout() throws XAException { return this.timeout == null ? 0 : this.timeout } @Override boolean setTransactionTimeout(int seconds) throws XAException { this.timeout = (seconds == 0 ? null : seconds) return true } @Override void commit(Xid xid, boolean onePhase) throws XAException { if (this.active) logger.warn("commit() called without end()") if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) if (runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call() this.xid = null this.active = false } @Override void rollback(Xid xid) throws XAException { if (this.active) logger.warn("rollback() called without end()") if (this.xid == null || !this.xid.equals(xid)) throw new XAException(XAException.XAER_NOTA) if (!runOnCommit) ecfi.serviceFacade.async().name(this.serviceName).parameters(this.parameters).call() this.xid = null this.active = false } @Override void run() { try { if (timeout != null) { // sleep until the transaction times out sleep(timeout.intValue() * 1000) if (active) { String statusString = ecfi.transactionFacade.getStatusString() logger.warn("Transaction timeout [${timeout}] status [${statusString}] xid [${this.xid}], service [${serviceName}] did NOT run") // NOTE: what to do, if anything, when the we timeout and the service hasn't been run? } } } catch (InterruptedException e) { logger.warn("Service Call Special Interrupted", e) } catch (Throwable t) { logger.warn("Service Call Special Error", t) } } */ } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceCallSyncImpl.java ================================================ package org.moqui.impl.service; import groovy.lang.Closure; import org.moqui.BaseException; import org.moqui.context.*; import org.moqui.entity.EntityValue; import org.moqui.impl.context.*; import org.moqui.impl.entity.EntityDefinition; import org.moqui.impl.entity.EntitySqlException; import org.moqui.impl.service.runner.EntityAutoServiceRunner; import org.moqui.service.ServiceCallSync; import org.moqui.service.ServiceException; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import jakarta.transaction.Status; import java.sql.Timestamp; import java.util.ArrayList; import java.util.HashMap; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; public class ServiceCallSyncImpl extends ServiceCallImpl implements ServiceCallSync { private static final Logger logger = LoggerFactory.getLogger(ServiceCallSyncImpl.class); private static final boolean traceEnabled = logger.isTraceEnabled(); private boolean ignoreTransaction = false; private boolean requireNewTransaction = false; private Boolean useTransactionCache = null; private Integer transactionTimeout = null; private boolean ignorePreviousError = false; private boolean softValidate = false; private boolean multi = false; private boolean rememberParameters = true; protected boolean disableAuthz = false; public ServiceCallSyncImpl(ServiceFacadeImpl sfi) { super(sfi); } @Override public ServiceCallSync name(String serviceName) { serviceNameInternal(serviceName); return this; } @Override public ServiceCallSync name(String v, String n) { serviceNameInternal(null, v, n); return this; } @Override public ServiceCallSync name(String p, String v, String n) { serviceNameInternal(p, v, n); return this; } @Override public ServiceCallSync parameters(Map map) { if (map != null) parameters.putAll(map); return this; } @Override public ServiceCallSync parameter(String name, Object value) { parameters.put(name, value); return this; } @Override public ServiceCallSync ignoreTransaction(boolean it) { this.ignoreTransaction = it; return this; } @Override public ServiceCallSync requireNewTransaction(boolean rnt) { this.requireNewTransaction = rnt; return this; } @Override public ServiceCallSync useTransactionCache(boolean utc) { this.useTransactionCache = utc; return this; } @Override public ServiceCallSync transactionTimeout(int timeout) { this.transactionTimeout = timeout; return this; } @Override public ServiceCallSync ignorePreviousError(boolean ipe) { this.ignorePreviousError = ipe; return this; } @Override public ServiceCallSync softValidate(boolean sv) { this.softValidate = sv; return this; } @Override public ServiceCallSync multi(boolean mlt) { this.multi = mlt; return this; } @Override public ServiceCallSync disableAuthz() { disableAuthz = true; return this; } @Override public ServiceCallSync noRememberParameters() { rememberParameters = false; return this; } @Override public Map call() { ExecutionContextFactoryImpl ecfi = sfi.ecfi; ExecutionContextImpl eci = ecfi.getEci(); boolean enableAuthz = disableAuthz && !eci.artifactExecutionFacade.disableAuthz(); try { if (multi) { ArrayList inParameterNames = null; if (sd != null) { inParameterNames = sd.getInParameterNames(); } else if (isEntityAutoPattern()) { EntityDefinition ed = ecfi.entityFacade.getEntityDefinition(noun); if (ed != null) inParameterNames = ed.getAllFieldNames(); } int inParameterNamesSize = inParameterNames != null ? inParameterNames.size() : 0; // run all service calls in a single transaction for multi form submits, ie all succeed or fail together boolean beganTransaction = eci.transactionFacade.begin(null); try { Map result = new HashMap<>(); for (int i = 0; ; i++) { Map currentParms = new HashMap<>(); for (int paramIndex = 0; paramIndex < inParameterNamesSize; paramIndex++) { String ipn = inParameterNames.get(paramIndex); String key = ipn + "_" + i; if (parameters.containsKey(key)) currentParms.put(ipn, parameters.get(key)); } // if the map stayed empty we have no parms, so we're done if (currentParms.size() == 0) break; if (("true".equals(parameters.get("_useRowSubmit")) || "true".equals(parameters.get("_useRowSubmit_" + i))) && !"true".equals(parameters.get("_rowSubmit_" + i))) continue; // now that we have checked the per-row parameters, add in others available for (int paramIndex = 0; paramIndex < inParameterNamesSize; paramIndex++) { String ipn = inParameterNames.get(paramIndex); if (!ObjectUtilities.isEmpty(currentParms.get(ipn))) continue; if (!ObjectUtilities.isEmpty(parameters.get(ipn))) { currentParms.put(ipn, parameters.get(ipn)); } else if (!ObjectUtilities.isEmpty(result.get(ipn))) { currentParms.put(ipn, result.get(ipn)); } } // call the service Map singleResult = callSingle(currentParms, sd, eci); if (singleResult != null) result.putAll(singleResult); // ... and break if there are any errors if (eci.messageFacade.hasError()) break; } return result; } catch (Throwable t) { eci.transactionFacade.rollback(beganTransaction, "Uncaught error running service " + serviceName + " in multi mode", t); throw t; } finally { if (eci.transactionFacade.isTransactionInPlace()) { if (eci.messageFacade.hasError()) { eci.transactionFacade.rollback(beganTransaction, "Error message found running service " + serviceName + " in multi mode", null); } else { eci.transactionFacade.commit(beganTransaction); } } } } else { return callSingle(parameters, sd, eci); } } finally { if (enableAuthz) eci.artifactExecutionFacade.enableAuthz(); } } private Map callSingle(Map currentParameters, ServiceDefinition sd, final ExecutionContextImpl eci) { if (ignorePreviousError) eci.messageFacade.pushErrors(); // NOTE: checking this here because service won't generally run after input validation, etc anyway if (eci.messageFacade.hasError()) { logger.warn("Found error(s) before service " + serviceName + ", so not running service. Errors: " + eci.messageFacade.getErrorsString()); return null; } TransactionFacadeImpl tf = eci.transactionFacade; int transactionStatus = tf.getStatus(); if (!requireNewTransaction && transactionStatus == Status.STATUS_MARKED_ROLLBACK) { logger.warn("Transaction marked for rollback, not running service " + serviceName + ". Errors: [" + eci.messageFacade.getErrorsString() + "] Artifact stack: " + eci.artifactExecutionFacade.getStackNameString()); if (ignorePreviousError) { eci.messageFacade.popErrors(); } else if (!eci.messageFacade.hasError()) { eci.messageFacade.addError("Transaction marked for rollback, not running service " + serviceName); } return null; } if (traceEnabled) logger.trace("Calling service " + serviceName + " initial input: " + currentParameters); // get these before cleaning up the parameters otherwise will be removed String username = null; String password = null; if (currentParameters.containsKey("authUsername")) { username = (String) currentParameters.get("authUsername"); password = (String) currentParameters.get("authPassword"); } else if (currentParameters.containsKey("authUserAccount")) { Map authUserAccount = (Map) currentParameters.get("authUserAccount"); username = (String) authUserAccount.get("username"); if (username == null || username.isEmpty()) username = (String) currentParameters.get("authUsername"); password = (String) authUserAccount.get("currentPassword"); if (password == null || password.isEmpty()) password = (String) currentParameters.get("authPassword"); } final String serviceType = sd != null ? sd.serviceType : "entity-implicit"; ArrayList secaRules = sfi.secaRules(serviceNameNoHash); boolean hasSecaRules = secaRules != null && secaRules.size() > 0; // in-parameter validation if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, "pre-validate", secaRules, eci); if (sd != null) { if (softValidate) eci.messageFacade.pushErrors(); currentParameters = sd.convertValidateCleanParameters(currentParameters, eci); if (softValidate) { if (eci.messageFacade.hasError()) { eci.messageFacade.moveErrorsToDangerMessages(); eci.messageFacade.popErrors(); return null; } eci.messageFacade.popErrors(); } } // if error(s) in parameters, return now with no results if (eci.messageFacade.hasError()) { StringBuilder errMsg = new StringBuilder("Found error(s) when validating input parameters for service " + serviceName + ", so not running service. Errors: " + eci.messageFacade.getErrorsString() + "; the artifact stack is:\n"); for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack()) { errMsg.append(stackItem.toString()).append("\n"); } logger.warn(errMsg.toString()); if (ignorePreviousError) eci.messageFacade.popErrors(); return null; } boolean userLoggedIn = false; // always try to login the user if parameters are specified if (username != null && password != null && username.length() > 0 && password.length() > 0) { userLoggedIn = eci.getUser().loginUser(username, password); // if user was not logged in we should already have an error message in place so just return if (!userLoggedIn) return null; } if (sd != null && "true".equals(sd.authenticate) && eci.userFacade.getUsername() == null && !eci.userFacade.getLoggedInAnonymous()) { if (ignorePreviousError) eci.messageFacade.popErrors(); throw new AuthenticationRequiredException("User must be logged in to call service " + serviceName); } if (sd == null) { if (sfi.isEntityAutoPattern(path, verb, noun)) { try { return runImplicitEntityAuto(currentParameters, secaRules, eci); } finally { if (ignorePreviousError) eci.messageFacade.popErrors(); } } else { logger.info("No service with name " + serviceName + ", isEntityAutoPattern=" + isEntityAutoPattern() + ", path=" + path + ", verb=" + verb + ", noun=" + noun + ", noun is entity? " + eci.getEntityFacade().isEntityDefined(noun)); if (ignorePreviousError) eci.messageFacade.popErrors(); throw new ServiceException("Could not find service with name " + serviceName); } } if ("interface".equals(serviceType)) { if (ignorePreviousError) eci.messageFacade.popErrors(); throw new ServiceException("Service " + serviceName + " is an interface and cannot be run"); } ServiceRunner serviceRunner = sd.serviceRunner; if (serviceRunner == null) { if (ignorePreviousError) eci.messageFacade.popErrors(); throw new ServiceException("Could not find service runner for type " + serviceType + " for service " + serviceName); } // pre authentication and authorization SECA rules if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, "pre-auth", secaRules, eci); // push service call artifact execution, checks authz too // NOTE: don't require authz if the service def doesn't authenticate // NOTE: if no sd then requiresAuthz is false, ie let the authz get handled at the entity level (but still put // the service on the stack) ArtifactExecutionInfo.AuthzAction authzAction = sd != null ? sd.authzAction : ServiceDefinition.verbAuthzActionEnumMap.get(verb); if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL; ArtifactExecutionInfoImpl aei = new ArtifactExecutionInfoImpl(serviceName, ArtifactExecutionInfo.AT_SERVICE, authzAction, serviceType); if (rememberParameters && !sd.noRememberParameters) aei.setParameters(currentParameters); eci.artifactExecutionFacade.pushInternal(aei, (sd != null && "true".equals(sd.authenticate)), true); // if error in auth or for other reasons, return now with no results if (eci.messageFacade.hasError()) { eci.artifactExecutionFacade.pop(aei); if (ignorePreviousError) eci.messageFacade.popErrors(); logger.warn("Found error(s) when checking authc for service " + serviceName + ", so not running service. Errors: " + eci.messageFacade.getErrorsString() + "; the artifact stack is:\n " + eci.getArtifactExecution().getStack()); return null; } // must be done after the artifact execution push so that AEII object to set anonymous authorized is in place boolean loggedInAnonymous = false; if (sd != null && "anonymous-all".equals(sd.authenticate)) { eci.artifactExecutionFacade.setAnonymousAuthorizedAll(); loggedInAnonymous = eci.userFacade.loginAnonymousIfNoUser(); } else if (sd != null && "anonymous-view".equals(sd.authenticate)) { eci.artifactExecutionFacade.setAnonymousAuthorizedView(); loggedInAnonymous = eci.userFacade.loginAnonymousIfNoUser(); } // handle sd.serviceNode."@semaphore"; do this BEFORE local transaction created, etc so waiting for this doesn't cause TX timeout if (sd.hasSemaphore) { try { checkAddSemaphore(eci, currentParameters, true); } catch (Throwable t) { eci.artifactExecutionFacade.pop(aei); throw t; } } // start with the settings for the default: use-or-begin boolean pauseResumeIfNeeded = false; boolean beginTransactionIfNeeded = true; if (ignoreTransaction || sd.txIgnore) beginTransactionIfNeeded = false; if (requireNewTransaction || sd.txForceNew) pauseResumeIfNeeded = true; boolean suspendedTransaction = false; Map result = new HashMap<>(); try { if (pauseResumeIfNeeded && transactionStatus != Status.STATUS_NO_TRANSACTION) { suspendedTransaction = tf.suspend(); transactionStatus = tf.getStatus(); } boolean beganTransaction = false; if (beginTransactionIfNeeded && transactionStatus != Status.STATUS_ACTIVE) { // logger.warn("Service " + serviceName + " begin TX timeout " + transactionTimeout + " SD txTimeout " + sd.txTimeout); beganTransaction = tf.begin(transactionTimeout != null ? transactionTimeout : sd.txTimeout); transactionStatus = tf.getStatus(); } if (sd.noTxCache) { tf.flushAndDisableTransactionCache(); } else { if (useTransactionCache != null ? useTransactionCache : sd.txUseCache) tf.initTransactionCache(false); // alternative to use read only TX cache by default, not functional yet: tf.initTransactionCache(!(useTransactionCache != null ? useTransactionCache : sd.txUseCache)); } try { if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, "pre-service", secaRules, eci); if (traceEnabled) logger.trace("Calling service " + serviceName + " pre-call input: " + currentParameters); // if error(s) in pre-service or anything else before actual run then return now with no results if (eci.messageFacade.hasError()) { StringBuilder errMsg = new StringBuilder("Found error(s) before running service " + serviceName + " so not running. Errors: " + eci.messageFacade.getErrorsString() + "; the artifact stack is:\n"); for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack()) errMsg.append(stackItem.toString()).append("\n"); logger.warn(errMsg.toString()); if (ignorePreviousError) eci.messageFacade.popErrors(); return null; } try { // run the service through the ServiceRunner result = serviceRunner.runService(sd, currentParameters); } finally { if (hasSecaRules) sfi.registerTxSecaRules(serviceNameNoHash, currentParameters, result, secaRules); } // logger.warn("Called " + serviceName + " has error message " + eci.messageFacade.hasError() + " began TX " + beganTransaction + " TX status " + tf.getStatusString()); // post-service SECA rules if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, "post-service", secaRules, eci); // registered callbacks, no Throwable sfi.callRegisteredCallbacks(serviceName, currentParameters, result); // if we got any errors added to the message list in the service, rollback for that too if (eci.messageFacade.hasError()) { tf.rollback(beganTransaction, "Error running service " + serviceName + " (message): " + eci.messageFacade.getErrorsString(), null); transactionStatus = tf.getStatus(); } if (traceEnabled) logger.trace("Calling service " + serviceName + " result: " + result); } catch (ArtifactAuthorizationException e) { // this is a local call, pass certain exceptions through throw e; } catch (Throwable t) { BaseException.filterStackTrace(t); // registered callbacks with Throwable sfi.callRegisteredCallbacksThrowable(serviceName, currentParameters, t); // rollback the transaction tf.rollback(beganTransaction, "Error running service " + serviceName + " (Throwable)", t); transactionStatus = tf.getStatus(); logger.warn("Error running service " + serviceName + " (Throwable) Artifact stack: " + eci.artifactExecutionFacade.getStackNameString(), t); // add all exception messages to the error messages list eci.messageFacade.addError(t.getMessage()); Throwable parent = t.getCause(); while (parent != null) { eci.messageFacade.addError(parent.getMessage()); parent = parent.getCause(); } } finally { try { if (beganTransaction) { transactionStatus = tf.getStatus(); if (transactionStatus == Status.STATUS_ACTIVE) { tf.commit(); } else if (transactionStatus == Status.STATUS_MARKED_ROLLBACK) { if (!eci.messageFacade.hasError()) eci.messageFacade.addError("Cannot commit transaction for service " + serviceName + ", marked rollback-only"); // will rollback based on marked rollback only tf.commit(); } /* most likely in this case is no transaction in place, already rolled back above, do nothing: else { logger.warn("In call to service " + serviceName + " transaction not Active or Marked Rollback-Only (" + tf.getStatusString() + "), doing commit to make sure TX closed"); tf.commit(); } */ } } catch (Throwable t) { logger.warn("Error committing transaction for service " + serviceName, t); // add all exception messages to the error messages list eci.messageFacade.addError(t.getMessage()); Throwable parent = t.getCause(); while (parent != null) { eci.messageFacade.addError(parent.getMessage()); parent = parent.getCause(); } } if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, "post-commit", secaRules, eci); } return result; } finally { // clear the semaphore if (sd.hasSemaphore) clearSemaphore(eci, currentParameters); try { if (suspendedTransaction) tf.resume(); } catch (Throwable t) { logger.error("Error resuming parent transaction after call to service " + serviceName, t); } try { if (userLoggedIn) eci.userFacade.logoutLocal(); } catch (Throwable t) { logger.error("Error logging out user after call to service " + serviceName, t); } if (loggedInAnonymous) eci.userFacade.logoutAnonymousOnly(); // all done so pop the artifact info eci.artifactExecutionFacade.pop(aei); // restore error messages if needed if (ignorePreviousError) eci.messageFacade.popErrors(); if (traceEnabled) logger.trace("Finished call to service " + serviceName + (eci.messageFacade.hasError() ? " with " + (eci.messageFacade.getErrors().size() + eci.messageFacade.getValidationErrors().size()) + " error messages" : ", was successful")); } } @SuppressWarnings("unused") private void clearSemaphore(final ExecutionContextImpl eci, Map currentParameters) { final String semaphoreName = sd.semaphoreName != null && !sd.semaphoreName.isEmpty() ? sd.semaphoreName : serviceName; String semParameter = sd.semaphoreParameter; String parameterValue; if (semParameter == null || semParameter.isEmpty()) { parameterValue = "_NA_"; } else { Object parmObj = currentParameters.get(semParameter); parameterValue = parmObj != null ? parmObj.toString() : "_NULL_"; } eci.transactionFacade.runRequireNew(null, "Error in clear service semaphore", new Closure(this, this) { EntityValue doCall(Object it) { boolean authzDisabled = eci.artifactExecutionFacade.disableAuthz(); try { return eci.getEntity().makeValue("moqui.service.semaphore.ServiceParameterSemaphore") .set("serviceName", semaphoreName).set("parameterValue", parameterValue) .set("lockThread", null).set("lockTime", null).update(); } finally { if (!authzDisabled) eci.artifactExecutionFacade.enableAuthz(); } } public EntityValue doCall() { return doCall(null); } }); } /* A good test case is the place#Order service which is used in the AssetReservationMultipleThreads.groovy tests: conflicting lock: segemented lock (bad in practice, good test with transacitonal ID): */ @SuppressWarnings("unused") private void checkAddSemaphore(final ExecutionContextImpl eci, Map currentParameters, boolean allowRetry) { final String semaphore = sd.semaphore; final String semaphoreName = sd.semaphoreName != null && !sd.semaphoreName.isEmpty() ? sd.semaphoreName : serviceName; String semaphoreParameter = sd.semaphoreParameter; final String parameterValue; if (semaphoreParameter == null || semaphoreParameter.isEmpty()) { parameterValue = "_NA_"; } else { Object parmObj = currentParameters.get(semaphoreParameter); parameterValue = parmObj != null ? parmObj.toString() : "_NULL_"; } final long semaphoreIgnoreMillis = sd.semaphoreIgnoreMillis; final long semaphoreSleepTime = sd.semaphoreSleepTime; final long semaphoreTimeoutTime = sd.semaphoreTimeoutTime; final int txTimeout = Math.toIntExact(sd.semaphoreTimeoutTime / 1000) * 2; // NOTE: get Thread name outside runRequireNew otherwise will always be RequireNewTx final String lockThreadName = Thread.currentThread().getName(); // support a single wait/retry on error creating semaphore record AtomicBoolean retrySemaphore = new AtomicBoolean(false); eci.transactionFacade.runRequireNew(txTimeout, "Error in check/add service semaphore", new Closure(this, this) { EntityValue doCall(Object it) { boolean authzDisabled = eci.artifactExecutionFacade.disableAuthz(); try { final long startTime = System.currentTimeMillis(); // look up semaphore, note that is no forUpdate, we want to loop wait below instead of doing a database lock wait EntityValue serviceSemaphore = eci.getEntity().find("moqui.service.semaphore.ServiceParameterSemaphore") .condition("serviceName", semaphoreName).condition("parameterValue", parameterValue).useCache(false).one(); // if there is an active semaphore but lockTime is too old reset and ignore it if (serviceSemaphore != null && (serviceSemaphore.getNoCheckSimple("lockThread") != null || serviceSemaphore.getNoCheckSimple("lockTime") != null)) { Timestamp lockTime = serviceSemaphore.getTimestamp("lockTime"); if (startTime > (lockTime.getTime() + semaphoreIgnoreMillis)) { serviceSemaphore.set("lockThread", null).set("lockTime", null).update(); } } if (serviceSemaphore != null && (serviceSemaphore.getNoCheckSimple("lockThread") != null || serviceSemaphore.getNoCheckSimple("lockTime") != null)) { if ("fail".equals(semaphore)) { throw new ServiceException("An instance of service semaphore " + semaphoreName + " with parameter value " + "[" + parameterValue + "] is already running (thread [" + serviceSemaphore.get("lockThread") + "], locked at " + serviceSemaphore.get("lockTime") + ") and it is setup to fail on semaphore conflict."); } else { boolean semaphoreCleared = false; while (System.currentTimeMillis() < (startTime + semaphoreTimeoutTime)) { // sleep, watch for interrupt try { Thread.sleep(semaphoreSleepTime); } catch (InterruptedException e) { Thread.currentThread().interrupt(); } // get updated semaphore and see if it has been cleared serviceSemaphore = eci.getEntity().find("moqui.service.semaphore.ServiceParameterSemaphore") .condition("serviceName", semaphoreName).condition("parameterValue", parameterValue).useCache(false).one(); if (serviceSemaphore == null || (serviceSemaphore.getNoCheckSimple("lockThread") == null && serviceSemaphore.getNoCheckSimple("lockTime") == null)) { semaphoreCleared = true; break; } } if (!semaphoreCleared) { throw new ServiceException("An instance of service semaphore " + semaphoreName + " with parameter value [" + parameterValue + "] is already running (thread [" + serviceSemaphore.get("lockThread") + "], locked at " + serviceSemaphore.get("lockTime") + ") and it is setup to wait on semaphore conflict, but the semaphore did not clear in " + (semaphoreTimeoutTime / 1000) + " seconds."); } } } // if we got to here the semaphore didn't exist or has cleared, so update existing or create new // do a for-update find now to make sure we own the record if one exists serviceSemaphore = eci.getEntity().find("moqui.service.semaphore.ServiceParameterSemaphore") .condition("serviceName", semaphoreName).condition("parameterValue", parameterValue) .useCache(false).forUpdate(true).one(); final Timestamp lockTime = new Timestamp(System.currentTimeMillis()); if (serviceSemaphore != null) { return serviceSemaphore.set("lockThread", lockThreadName).set("lockTime", lockTime).update(); } else { try { return eci.getEntity().makeValue("moqui.service.semaphore.ServiceParameterSemaphore") .set("serviceName", semaphoreName).set("parameterValue", parameterValue) .set("lockThread", lockThreadName).set("lockTime", lockTime).create(); } catch (EntitySqlException e) { if ("23505".equals(e.getSQLState())) { logger.warn("Record exists error creating semaphore " + semaphoreName + " parameter " + parameterValue + ", retrying: " + e.toString()); retrySemaphore.set(true); return null; } else { throw new ServiceException("Error creating semaphore " + semaphoreName + " with parameter value [" + parameterValue + "]", e); } } } } finally { if (!authzDisabled) eci.artifactExecutionFacade.enableAuthz(); } } public EntityValue doCall() { return doCall(null); } }); if (allowRetry && retrySemaphore.get()) { checkAddSemaphore(eci, currentParameters, false); } } private Map runImplicitEntityAuto(Map currentParameters, ArrayList secaRules, ExecutionContextImpl eci) { // NOTE: no authentication, assume not required for this; security settings can override this and require // permissions, which will require authentication // done in calling method: sfi.runSecaRules(serviceName, currentParameters, null, "pre-auth") boolean hasSecaRules = secaRules != null && secaRules.size() > 0; if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, "pre-validate", secaRules, eci); // start with the settings for the default: use-or-begin boolean pauseResumeIfNeeded = false; boolean beginTransactionIfNeeded = true; if (ignoreTransaction) beginTransactionIfNeeded = false; if (requireNewTransaction) pauseResumeIfNeeded = true; TransactionFacadeImpl tf = eci.transactionFacade; boolean suspendedTransaction = false; Map result = new HashMap<>(); try { if (pauseResumeIfNeeded && tf.isTransactionInPlace()) suspendedTransaction = tf.suspend(); boolean beganTransaction = beginTransactionIfNeeded && tf.begin(null); if (useTransactionCache != null && useTransactionCache) tf.initTransactionCache(false); // alternative to use read only TX cache by default, not functional yet: tf.initTransactionCache(useTransactionCache == null || !useTransactionCache); try { if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, null, "pre-service", secaRules, eci); // if error(s) in pre-service or anything else before actual run then return now with no results if (eci.messageFacade.hasError()) { StringBuilder errMsg = new StringBuilder("Found error(s) before running service " + serviceName + " so not running. Errors: " + eci.messageFacade.getErrorsString() + "; the artifact stack is:\n"); for (ArtifactExecutionInfo stackItem : eci.artifactExecutionFacade.getStack()) errMsg.append(stackItem.toString()).append("\n"); logger.warn(errMsg.toString()); if (ignorePreviousError) eci.messageFacade.popErrors(); return null; } try { EntityDefinition ed = eci.getEntityFacade().getEntityDefinition(noun); if ("create".equals(verb)) { EntityAutoServiceRunner.createEntity(eci, ed, currentParameters, result, null); } else if ("update".equals(verb)) { EntityAutoServiceRunner.updateEntity(eci, ed, currentParameters, result, null, null); } else if ("delete".equals(verb)) { EntityAutoServiceRunner.deleteEntity(eci, ed, currentParameters); } else if ("store".equals(verb)) { EntityAutoServiceRunner.storeEntity(eci, ed, currentParameters, result, null); } // NOTE: no need to throw exception for other verbs, checked in advance when looking for valid service name by entity auto pattern } finally { if (hasSecaRules) sfi.registerTxSecaRules(serviceNameNoHash, currentParameters, result, secaRules); } if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, "post-service", secaRules, eci); } catch (ArtifactAuthorizationException e) { tf.rollback(beganTransaction, "Authorization error running service " + serviceName, e); // this is a local call, pass certain exceptions through throw e; } catch (Throwable t) { logger.error("Error running service " + serviceName, t); tf.rollback(beganTransaction, "Error running service " + serviceName + " (Throwable)", t); // add all exception messages to the error messages list eci.messageFacade.addError(t.getMessage()); Throwable parent = t.getCause(); while (parent != null) { eci.messageFacade.addError(parent.getMessage()); parent = parent.getCause(); } } finally { try { if (beganTransaction && tf.isTransactionActive()) tf.commit(); } catch (Throwable t) { logger.warn("Error committing transaction for entity-auto service " + serviceName, t); // add all exception messages to the error messages list eci.messageFacade.addError(t.getMessage()); Throwable parent = t.getCause(); while (parent != null) { eci.messageFacade.addError(parent.getMessage()); parent = parent.getCause(); } } if (hasSecaRules) ServiceFacadeImpl.runSecaRules(serviceNameNoHash, currentParameters, result, "post-commit", secaRules, eci); } } finally { if (suspendedTransaction) tf.resume(); } return result; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceDefinition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service; import org.apache.commons.validator.routines.CreditCardValidator; import org.apache.commons.validator.routines.EmailValidator; import org.apache.commons.validator.routines.UrlValidator; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.entity.EntityList; import org.moqui.entity.EntityValue; import org.moqui.impl.actions.XmlAction; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.impl.entity.EntityDefinition; import org.moqui.service.ServiceException; import org.moqui.util.CollectionUtilities; import org.moqui.util.MNode; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.math.BigInteger; import java.util.*; public class ServiceDefinition { protected static final Logger logger = LoggerFactory.getLogger(ServiceDefinition.class); private static final EmailValidator emailValidator = EmailValidator.getInstance(); private static final UrlValidator urlValidator = new UrlValidator(UrlValidator.ALLOW_ALL_SCHEMES); public final ServiceFacadeImpl sfi; public final MNode serviceNode; private final LinkedHashMap inParameterInfoMap = new LinkedHashMap<>(); private final ParameterInfo[] inParameterInfoArray; // private final boolean inParameterHasDefault; private final LinkedHashMap outParameterInfoMap = new LinkedHashMap<>(); public final ArrayList inParameterNameList = new ArrayList<>(); public final ArrayList outParameterNameList = new ArrayList<>(); public final String[] outParameterNameArray; public final String path; public final String verb; public final String noun; public final String serviceName; public final String serviceNameNoHash; public final String location; public final String method; public final XmlAction xmlAction; public final String authenticate; public final ArtifactExecutionInfo.AuthzAction authzAction; public final String serviceType; public final ServiceRunner serviceRunner; public final boolean txIgnore; public final boolean txForceNew; public final boolean txUseCache; public final boolean noTxCache; public final Integer txTimeout; public final boolean validate; public final boolean allowRemote; public final boolean noRememberParameters; public final boolean hasSemaphore; public final String semaphore, semaphoreName, semaphoreParameter; public final long semaphoreIgnoreMillis, semaphoreSleepTime, semaphoreTimeoutTime; public ServiceDefinition(ServiceFacadeImpl sfi, String path, MNode sn) { this.sfi = sfi; this.serviceNode = sn.deepCopy(null); this.path = path; this.verb = serviceNode.attribute("verb"); this.noun = serviceNode.attribute("noun"); serviceName = makeServiceName(path, verb, noun); serviceNameNoHash = makeServiceNameNoHash(path, verb, noun); location = serviceNode.attribute("location"); method = serviceNode.attribute("method"); ArtifactExecutionInfo.AuthzAction tempAction = null; String authzActionAttr = serviceNode.attribute("authz-action"); if (authzActionAttr != null && !authzActionAttr.isEmpty()) tempAction = ArtifactExecutionInfo.authzActionByName.get(authzActionAttr); if (tempAction == null) tempAction = verbAuthzActionEnumMap.get(verb); if (tempAction == null) tempAction = ArtifactExecutionInfo.AUTHZA_ALL; authzAction = tempAction; MNode inParameters = new MNode("in-parameters", null); MNode outParameters = new MNode("out-parameters", null); boolean noRememberParmsTemp = "true".equals(serviceNode.attribute("no-remember-parameters")); // handle implements elements if (serviceNode.hasChild("implements")) for (MNode implementsNode : serviceNode.children("implements")) { final String implServiceName = implementsNode.attribute("service"); String implRequired = implementsNode.attribute("required");// no default here, only used if has a value if (implRequired != null && implRequired.isEmpty()) implRequired = null; ServiceDefinition sd = sfi.getServiceDefinition(implServiceName); if (sd == null) throw new ServiceException("Service " + implServiceName + " not found, specified in service.implements in service " + serviceName); // while most attributes aren't passed through do pass through no-remember-parameters if true if (sd.noRememberParameters) noRememberParmsTemp = true; // these are the first params to be set, so just deep copy them over MNode implInParms = sd.serviceNode.first("in-parameters"); if (implInParms != null && implInParms.hasChild("parameter")) { for (MNode parameter : implInParms.children("parameter")) { MNode newParameter = parameter.deepCopy(null); if (implRequired != null) newParameter.getAttributes().put("required", implRequired); inParameters.append(newParameter); } } MNode implOutParms = sd.serviceNode.first("out-parameters"); if (implOutParms != null && implOutParms.hasChild("parameter")) { for (MNode parameter : implOutParms.children("parameter")) { MNode newParameter = parameter.deepCopy(null); if (implRequired != null) newParameter.getAttributes().put("required", implRequired); outParameters.append(newParameter); } } } noRememberParameters = noRememberParmsTemp; // expand auto-parameters and merge parameter in in-parameters and out-parameters // if noun is a valid entity name set it on parameters with valid field names on it EntityDefinition ed = null; if (sfi.ecfi.entityFacade.isEntityDefined(this.noun)) ed = sfi.ecfi.entityFacade.getEntityDefinition(this.noun); if (serviceNode.hasChild("in-parameters")) { for (MNode paramNode : serviceNode.first("in-parameters").getChildren()) { if ("auto-parameters".equals(paramNode.getName())) { mergeAutoParameters(inParameters, paramNode); } else if (paramNode.getName().equals("parameter")) { mergeParameter(inParameters, paramNode, ed); } } } if (serviceNode.hasChild("out-parameters")) { for (MNode paramNode : serviceNode.first("out-parameters").getChildren()) { if ("auto-parameters".equals(paramNode.getName())) { mergeAutoParameters(outParameters, paramNode); } else if ("parameter".equals(paramNode.getName())) { mergeParameter(outParameters, paramNode, ed); } } } // replace the in-parameters and out-parameters Nodes for the service if (serviceNode.hasChild("in-parameters")) serviceNode.remove("in-parameters"); serviceNode.append(inParameters); if (serviceNode.hasChild("out-parameters")) serviceNode.remove("out-parameters"); serviceNode.append(outParameters); if (logger.isTraceEnabled()) logger.trace("After merge for service " + serviceName + " node is:\n" + serviceNode.toString()); // if this is an inline service, get that now if (serviceNode.hasChild("actions")) { xmlAction = new XmlAction(sfi.ecfi, serviceNode.first("actions"), serviceName); } else { xmlAction = null; } final String authenticateAttr = serviceNode.attribute("authenticate"); authenticate = authenticateAttr != null && !authenticateAttr.isEmpty() ? authenticateAttr : "true"; final String typeAttr = serviceNode.attribute("type"); serviceType = typeAttr != null && !typeAttr.isEmpty() ? typeAttr : "inline"; serviceRunner = sfi.getServiceRunner(serviceType); String transactionAttr = serviceNode.attribute("transaction"); txIgnore = "ignore".equals(transactionAttr); txForceNew = "force-new".equals(transactionAttr) || "force-cache".equals(transactionAttr); txUseCache = "cache".equals(transactionAttr) || "force-cache".equals(transactionAttr); noTxCache = "true".equals(serviceNode.attribute("no-tx-cache")); String txTimeoutAttr = serviceNode.attribute("transaction-timeout"); if (txTimeoutAttr != null && !txTimeoutAttr.isEmpty()) { txTimeout = Integer.valueOf(txTimeoutAttr); } else { txTimeout = null; } semaphore = serviceNode.attribute("semaphore"); semaphoreName = serviceNode.attribute("semaphore-name"); hasSemaphore = semaphore != null && semaphore.length() > 0 && !"none".equals(semaphore); semaphoreParameter = serviceNode.attribute("semaphore-parameter"); String ignoreAttr = serviceNode.attribute("semaphore-ignore"); if (ignoreAttr == null || ignoreAttr.isEmpty()) ignoreAttr = "3600"; semaphoreIgnoreMillis = Long.parseLong(ignoreAttr) * 1000; String sleepAttr = serviceNode.attribute("semaphore-sleep"); if (sleepAttr == null || sleepAttr.isEmpty()) sleepAttr = "5"; semaphoreSleepTime = Long.parseLong(sleepAttr) * 1000; String timeoutAttr = serviceNode.attribute("semaphore-timeout"); if (timeoutAttr == null || timeoutAttr.isEmpty()) timeoutAttr = "120"; semaphoreTimeoutTime = Long.parseLong(timeoutAttr) * 1000; // validate defaults to true validate = !"false".equals(serviceNode.attribute("validate")); allowRemote = "true".equals(serviceNode.attribute("allow-remote")); MNode inParametersNode = serviceNode.first("in-parameters"); MNode outParametersNode = serviceNode.first("out-parameters"); if (inParametersNode != null) for (MNode parameter : inParametersNode.children("parameter")) { String parameterName = parameter.attribute("name"); inParameterInfoMap.put(parameterName, new ParameterInfo(this, parameter)); inParameterNameList.add(parameterName); } int inParameterNameListSize = inParameterNameList.size(); inParameterInfoArray = new ParameterInfo[inParameterNameListSize]; // boolean tempHasDefault = false; for (int i = 0; i < inParameterNameListSize; i++) { String parmName = inParameterNameList.get(i); ParameterInfo pi = inParameterInfoMap.get(parmName); inParameterInfoArray[i] = pi; // if (pi.thisOrChildHasDefault) tempHasDefault = true; } // inParameterHasDefault = tempHasDefault; if (outParametersNode != null) for (MNode parameter : outParametersNode.children("parameter")) { String parameterName = parameter.attribute("name"); outParameterInfoMap.put(parameterName, new ParameterInfo(this, parameter)); outParameterNameList.add(parameterName); } outParameterNameArray = new String[outParameterNameList.size()]; outParameterNameList.toArray(outParameterNameArray); } private void mergeAutoParameters(MNode parametersNode, MNode autoParameters) { String entityName = autoParameters.attribute("entity-name"); if (entityName == null || entityName.isEmpty()) entityName = noun; if (entityName == null || entityName.isEmpty()) throw new ServiceException("Error in auto-parameters in service " + serviceName + ", no auto-parameters.@entity-name and no service.@noun for a default"); EntityDefinition ed = sfi.ecfi.entityFacade.getEntityDefinition(entityName); if (ed == null) throw new ServiceException("Error in auto-parameters in service " + serviceName + ", the entity-name or noun [" + entityName + "] is not a valid entity name"); Set fieldsToExclude = new HashSet<>(); for (MNode excludeNode : autoParameters.children("exclude")) { fieldsToExclude.add(excludeNode.attribute("field-name")); } String includeStr = autoParameters.attribute("include"); if (includeStr == null || includeStr.isEmpty()) includeStr = "all"; String requiredStr = autoParameters.attribute("required"); if (requiredStr == null || requiredStr.isEmpty()) requiredStr = "false"; String allowHtmlStr = autoParameters.attribute("allow-html"); if (allowHtmlStr == null || allowHtmlStr.isEmpty()) allowHtmlStr = "none"; for (String fieldName : ed.getFieldNames("all".equals(includeStr) || "pk".equals(includeStr), "all".equals(includeStr) || "nonpk".equals(includeStr))) { if (fieldsToExclude.contains(fieldName)) continue; String javaType = sfi.ecfi.entityFacade.getFieldJavaType(ed.getFieldInfo(fieldName).type, ed); Map map = new LinkedHashMap<>(5); map.put("type", javaType); map.put("required", requiredStr); map.put("allow-html", allowHtmlStr); map.put("entity-name", ed.fullEntityName); map.put("field-name", fieldName); mergeParameter(parametersNode, fieldName, map); } } private void mergeParameter(MNode parametersNode, MNode overrideParameterNode, EntityDefinition ed) { MNode baseParameterNode = mergeParameter(parametersNode, overrideParameterNode.attribute("name"), overrideParameterNode.getAttributes()); // merge description, ParameterValidations for (MNode childNode : overrideParameterNode.getChildren()) { if ("description".equals(childNode.getName())) { if (baseParameterNode.hasChild(childNode.getName())) baseParameterNode.remove(childNode.getName()); } if ("auto-parameters".equals(childNode.getName())) { mergeAutoParameters(baseParameterNode, childNode); } else if ("parameter".equals(childNode.getName())) { mergeParameter(baseParameterNode, childNode, ed); } else { // is a validation, just add it in, or the original has been removed so add the new one baseParameterNode.append(childNode); } } String entityNameAttr = baseParameterNode.attribute("entity-name"); if (entityNameAttr != null && !entityNameAttr.isEmpty()) { String fieldNameAttr = baseParameterNode.attribute("field-name"); if (fieldNameAttr == null || fieldNameAttr.isEmpty()) baseParameterNode.getAttributes().put("field-name", baseParameterNode.attribute("name")); } else if (ed != null && ed.isField(baseParameterNode.attribute("name"))) { baseParameterNode.getAttributes().put("entity-name", ed.fullEntityName); baseParameterNode.getAttributes().put("field-name", baseParameterNode.attribute("name")); } } private static MNode mergeParameter(MNode parametersNode, final String parameterName, Map attributeMap) { MNode baseParameterNode = parametersNode.first("parameter", "name", parameterName); if (baseParameterNode == null) { Map map = new HashMap<>(1); map.put("name", parameterName); baseParameterNode = parametersNode.append("parameter", map); } baseParameterNode.getAttributes().putAll(attributeMap); return baseParameterNode; } public static String makeServiceName(String path, String verb, String noun) { return (path != null && !path.isEmpty() ? path + "." : "") + verb + (noun != null && !noun.isEmpty() ? "#" + noun : ""); } public static String makeServiceNameNoHash(String path, String verb, String noun) { return (path != null && !path.isEmpty() ? path + "." : "") + verb + (noun != null ? noun : ""); } public static String getPathFromName(String serviceName) { String p = serviceName; // do hash first since a noun following hash may have dots in it int hashIndex = p.indexOf('#'); if (hashIndex > 0) p = p.substring(0, hashIndex); int lastDotIndex = p.lastIndexOf('.'); if (lastDotIndex <= 0) return null; return p.substring(0, lastDotIndex); } public static String getVerbFromName(String serviceName) { String v = serviceName; // do hash first since a noun following hash may have dots in it int hashIndex = v.indexOf('#'); if (hashIndex > 0) v = v.substring(0, hashIndex); int lastDotIndex = v.lastIndexOf('.'); if (lastDotIndex > 0) v = v.substring(lastDotIndex + 1); return v; } public static String getNounFromName(String serviceName) { int hashIndex = serviceName.lastIndexOf('#'); if (hashIndex < 0) return null; return serviceName.substring(hashIndex + 1); } public static ArtifactExecutionInfo.AuthzAction getVerbAuthzActionEnum(String theVerb) { // default to require the "All" authz action, and for special verbs default to something more appropriate ArtifactExecutionInfo.AuthzAction authzAction = verbAuthzActionEnumMap.get(theVerb); if (authzAction == null) authzAction = ArtifactExecutionInfo.AUTHZA_ALL; return authzAction; } public MNode getInParameter(String name) { ParameterInfo pi = inParameterInfoMap.get(name); if (pi == null) return null; return pi.parameterNode; } public ArrayList getInParameterNames() { return inParameterNameList; } public MNode getOutParameter(String name) { ParameterInfo pi = outParameterInfoMap.get(name); if (pi == null) return null; return pi.parameterNode; } public ArrayList getOutParameterNames() { return outParameterNameList; } public Map convertValidateCleanParameters(Map parameters, ExecutionContextImpl eci) { // logger.warn("BEFORE ${serviceName} convertValidateCleanParameters: ${parameters.toString()}") // checkParameterMap("", parameters, parameters, inParameterInfoMap, eci); return nestedParameterClean("", parameters, inParameterInfoArray, eci); // logger.warn("AFTER ${serviceName} convertValidateCleanParameters: ${parameters.toString()}") } @SuppressWarnings("unchecked") private Map nestedParameterClean(String namePrefix, Map parameters, ParameterInfo[] parameterInfoArray, ExecutionContextImpl eci) { // a copy of the parameters Map to retain unknown entries to add at the end when NOT validating (pass through unknown parameters) HashMap parametersCopy = validate ? null : new HashMap<>(parameters); // the new Map that will be populated and returned HashMap newMap = new HashMap<>(); for (int i = 0; i < parameterInfoArray.length; i++) { ParameterInfo parameterInfo = parameterInfoArray[i]; String parameterName = parameterInfo.name; boolean hasParameter = parameters.containsKey(parameterName); Object parameterValue = hasParameter ? parameters.get(parameterName) : null; if (hasParameter && parametersCopy != null) parametersCopy.remove(parameterName); boolean parameterIsEmpty; boolean isString = false; boolean isCollection = false; boolean isMap = false; Class parameterClass = null; if (parameterValue != null) { if (parameterValue instanceof CharSequence) { String stringValue = parameterValue.toString(); parameterValue = stringValue; isString = true; parameterClass = String.class; parameterIsEmpty = stringValue.isEmpty(); } else { parameterClass = parameterValue.getClass(); if (parameterValue instanceof Map) { isMap = true; parameterIsEmpty = ((Map) parameterValue).isEmpty(); } else if (parameterValue instanceof Collection) { isCollection = true; parameterIsEmpty = ((Collection) parameterValue).isEmpty(); } else { parameterIsEmpty = false; } } } else { parameterIsEmpty = true; } // set the default if applicable if (parameterIsEmpty) { if (parameterInfo.hasDefault) { // TODO: consider doing this as a second pass so newMap has all parameters if (parameterInfo.defaultStr != null) { Map combinedMap = new HashMap<>(parameters); combinedMap.putAll(newMap); parameterValue = eci.resourceFacade.expression(parameterInfo.defaultStr, null, combinedMap); if (parameterValue != null) { hasParameter = true; isString = false; isCollection = false; isMap = false; if (parameterValue instanceof CharSequence) { String stringValue = parameterValue.toString(); parameterValue = stringValue; isString = true; parameterClass = String.class; parameterIsEmpty = stringValue.isEmpty(); } else { parameterClass = parameterValue.getClass(); if (parameterValue instanceof Map) { isMap = true; parameterIsEmpty = ((Map) parameterValue).isEmpty(); } else if (parameterValue instanceof Collection) { isCollection = true; parameterIsEmpty = ((Collection) parameterValue).isEmpty(); } else { parameterIsEmpty = false; } } } } else if (parameterInfo.defaultValue != null) { String stringValue; if (parameterInfo.defaultValueNeedsExpand) { Map combinedMap = new HashMap<>(parameters); combinedMap.putAll(newMap); stringValue = eci.resourceFacade.expand(parameterInfo.defaultValue, null, combinedMap, false); } else { stringValue = parameterInfo.defaultValue; } hasParameter = true; parameterValue = stringValue; isString = true; parameterClass = String.class; parameterIsEmpty = stringValue.isEmpty(); } } else { // if empty but not null and types don't match set to null instead of trying to convert if (parameterValue != null) { boolean typeMatches; if (parameterInfo.parmClass != null) { typeMatches = parameterClass == parameterInfo.parmClass || parameterInfo.parmClass.isInstance(parameterValue); } else { typeMatches = ObjectUtilities.isInstanceOf(parameterValue, parameterInfo.type); } if (!typeMatches) parameterValue = null; } } // if required and still empty (nothing from default), complain if (parameterIsEmpty && validate && parameterInfo.required) eci.messageFacade.addValidationError(null, namePrefix + parameterName, serviceName, eci.getL10n().localize("Field cannot be empty"), null); } // NOTE: not else because parameterIsEmpty may be changed if (!parameterIsEmpty) { boolean typeMatches; if (parameterInfo.parmClass != null) { typeMatches = parameterClass == parameterInfo.parmClass || parameterInfo.parmClass.isInstance(parameterValue); } else { typeMatches = ObjectUtilities.isInstanceOf(parameterValue, parameterInfo.type); } if (!typeMatches) { // convert type, at this point parameterValue is not empty and doesn't match parameter type parameterValue = parameterInfo.convertType(namePrefix, parameterValue, isString, eci); isString = false; isCollection = false; isMap = false; if (parameterValue instanceof CharSequence) { parameterValue = parameterValue.toString(); isString = true; } else if (parameterValue instanceof Map) { isMap = true; } else if (parameterValue instanceof Collection) { isCollection = true; } } if (validate) { if ((isString || isCollection) && ParameterInfo.ParameterAllowHtml.ANY != parameterInfo.allowHtml) { Object htmlValidated = parameterInfo.validateParameterHtml(namePrefix, parameterValue, isString, eci); // put the final parameterValue back into the parameters Map if (htmlValidated != null) { parameterValue = htmlValidated; } } // check against validation sub-elements (do this after the convert so we can deal with objects when needed) if (parameterInfo.validationNodeList != null) { int valListSize = parameterInfo.validationNodeList.size(); for (int valIdx = 0; valIdx < valListSize; valIdx++) { MNode valNode = parameterInfo.validationNodeList.get(valIdx); // NOTE don't break on fail, we want to get a list of all failures for the user to see try { // validateParameterSingle calls eci.message.addValidationError as needed so nothing else to do here validateParameterSingle(valNode, parameterName, parameterValue, eci); } catch (Throwable t) { logger.error("Error in validation", t); Map map = new HashMap<>(3); map.put("parameterValue", parameterValue); map.put("valNode", valNode); map.put("t", t); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered failed ${valNode.name} validation: ${t.message}", "", map), null); } } } } if (isMap && parameterInfo.childParameterInfoArray != null && parameterInfo.childParameterInfoArray.length > 0) { parameterValue = nestedParameterClean(namePrefix + parameterName + ".", (Map) parameterValue, parameterInfo.childParameterInfoArray, eci); } } if (hasParameter) newMap.put(parameterName, parameterValue); } // if we are not validating and there are parameters remaining, add them to the newMap if (!validate && parametersCopy != null && parametersCopy.size() > 0) { newMap.putAll(parametersCopy); } return newMap; } private boolean validateParameterSingle(MNode valNode, String parameterName, Object pv, ExecutionContextImpl eci) { // should never be null (caller checks) but check just in case if (pv == null) return true; String validateName = valNode.getName(); if ("val-or".equals(validateName)) { boolean anyPass = false; for (MNode child : valNode.getChildren()) if (validateParameterSingle(child, parameterName, pv, eci)) anyPass = true; return anyPass; } else if ("val-and".equals(validateName)) { boolean allPass = true; for (MNode child : valNode.getChildren()) if (!validateParameterSingle(child, parameterName, pv, eci)) allPass = false; return allPass; } else if ("val-not".equals(validateName)) { boolean allPass = true; for (MNode child : valNode.getChildren()) if (!validateParameterSingle(child, parameterName, pv, eci)) allPass = false; return !allPass; } else if ("matches".equals(validateName)) { if (!(pv instanceof CharSequence)) { Map map = new HashMap<>(1); map.put("pv", pv); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${pv}) is not a string, cannot do matches validation.", "", map), null); return false; } String pvString = pv.toString(); String regexp = valNode.attribute("regexp"); if (regexp != null && !regexp.isEmpty() && !pvString.matches(regexp)) { // a message attribute should always be there, but just in case we'll have a default final String message = valNode.attribute("message"); Map map = new HashMap<>(2); map.put("pv", pv); map.put("regexp", regexp); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message != null && !message.isEmpty() ? message : "Value entered (${pv}) did not match expression: ${regexp}", "", map), null); return false; } return true; } else if ("number-range".equals(validateName)) { BigDecimal bdVal = new BigDecimal(pv.toString()); String message = valNode.attribute("message"); String minStr = valNode.attribute("min"); if (minStr != null && !minStr.isEmpty()) { BigDecimal min = new BigDecimal(minStr); if ("false".equals(valNode.attribute("min-include-equals"))) { if (bdVal.compareTo(min) <= 0) { Map map = new HashMap<>(2); map.put("pv", pv); map.put("min", min); if (message == null || message.isEmpty()) message = "Value entered (${pv}) is less than or equal to ${min}, must be greater than."; eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message, "", map), null); return false; } } else { if (bdVal.compareTo(min) < 0) { Map map = new HashMap<>(2); map.put("pv", pv); map.put("min", min); if (message == null || message.isEmpty()) message = "Value entered (${pv}) is less than ${min} and must be greater than or equal to."; eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message, "", map), null); return false; } } } String maxStr = valNode.attribute("max"); if (maxStr != null && !maxStr.isEmpty()) { BigDecimal max = new BigDecimal(maxStr); if ("true".equals(valNode.attribute("max-include-equals"))) { if (bdVal.compareTo(max) > 0) { Map map = new HashMap<>(2); map.put("pv", pv); map.put("max", max); if (message == null || message.isEmpty()) message = "Value entered (${pv}) is greater than ${max} and must be less than or equal to."; eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message, "", map), null); return false; } } else { if (bdVal.compareTo(max) >= 0) { Map map = new HashMap<>(2); map.put("pv", pv); map.put("max", max); if (message == null || message.isEmpty()) message = "Value entered (${pv}) is greater than or equal to ${max} and must be less than."; eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand(message, "", map), null); return false; } } } return true; } else if ("number-integer".equals(validateName)) { try { new BigInteger(pv.toString()); } catch (NumberFormatException e) { if (logger.isTraceEnabled()) logger.trace("Adding error message for NumberFormatException for BigInteger parse: " + e.toString()); Map map = new HashMap<>(1); map.put("pv", pv); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value [${pv}] is not a whole (integer) number.", "", map), null); return false; } return true; } else if ("number-decimal".equals(validateName)) { try { new BigDecimal(pv.toString()); } catch (NumberFormatException e) { if (logger.isTraceEnabled()) logger.trace("Adding error message for NumberFormatException for BigDecimal parse: " + e.toString()); Map map = new HashMap<>(1); map.put("pv", pv); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value [${pv}] is not a decimal number.", "", map), null); return false; } return true; } else if ("text-length".equals(validateName)) { String str = pv.toString(); String minStr = valNode.attribute("min"); if (minStr != null && !minStr.isEmpty()) { int min = Integer.parseInt(minStr); if (str.length() < min) { Map map = new HashMap<>(3); map.put("pv", pv); map.put("str", str); map.put("minStr", minStr); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${pv}), length ${str.length()}, is shorter than ${minStr} characters.", "", map), null); return false; } } String maxStr = valNode.attribute("max"); if (maxStr != null && !maxStr.isEmpty()) { int max = Integer.parseInt(maxStr); if (str.length() > max) { Map map = new HashMap<>(3); map.put("pv", pv); map.put("str", str); map.put("maxStr", maxStr); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${pv}), length ${str.length()}, is longer than ${maxStr} characters.", "", map), null); return false; } } return true; } else if ("text-email".equals(validateName)) { String str = pv.toString(); if (!emailValidator.isValid(str)) { Map map = new HashMap<>(1); map.put("str", str); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${str}) is not a valid email address.", "", map), null); return false; } return true; } else if ("text-url".equals(validateName)) { String str = pv.toString(); if (!urlValidator.isValid(str)) { Map map = new HashMap<>(1); map.put("str", str); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${str}) is not a valid URL.", "", map), null); return false; } return true; } else if ("text-letters".equals(validateName)) { String str = pv.toString(); for (char c : str.toCharArray()) { if (!Character.isLetter(c)) { Map map = new HashMap<>(1); map.put("str", str); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${str}) must have only letters.", "", map), null); return false; } } return true; } else if ("text-digits".equals(validateName)) { String str = pv.toString(); for (char c : str.toCharArray()) { if (!Character.isDigit(c)) { Map map = new HashMap<>(1); map.put("str", str); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value [${str}] must have only digits.", "", map), null); return false; } } return true; } else if ("time-range".equals(validateName)) { Calendar cal; String format = valNode.attribute("format"); if (pv instanceof CharSequence) { cal = eci.getL10n().parseDateTime(pv.toString(), format); } else { // try letting groovy convert it cal = Calendar.getInstance(); // TODO: not sure if this will work: ((pv as java.util.Date).getTime()) cal.setTimeInMillis((DefaultGroovyMethods.asType(pv, Date.class)).getTime()); } String after = valNode.attribute("after"); if (after != null && !after.isEmpty()) { // handle after date/time/date-time depending on type of parameter, support "now" too Calendar compareCal; if ("now".equals(after)) { compareCal = eci.getL10n().parseDateTime(eci.getL10n().format(eci.getUser().getNowTimestamp(), format), format); } else { compareCal = eci.getL10n().parseDateTime(after, format); } if (cal != null && cal.compareTo(compareCal) < 0) { Map map = new HashMap<>(2); map.put("pv", pv); map.put("after", after); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${pv}) is before ${after}.", "", map), null); return false; } } String before = valNode.attribute("before"); if (before != null && !before.isEmpty()) { // handle after date/time/date-time depending on type of parameter, support "now" too Calendar compareCal; if ("now".equals(before)) { compareCal = eci.getL10n().parseDateTime(eci.getL10n().format(eci.getUser().getNowTimestamp(), format), format); } else { compareCal = eci.getL10n().parseDateTime(before, format); } if (cal != null && cal.compareTo(compareCal) > 0) { Map map = new HashMap<>(1); map.put("pv", pv); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered (${pv}) is after ${before}.", "", map), null); return false; } } return true; } else if ("credit-card".equals(validateName)) { long creditCardTypes = 0; String types = valNode.attribute("types"); if (types != null && !types.isEmpty()) { for (String cts : types.split(",")) creditCardTypes += creditCardTypeMap.get(cts.trim()); } else { creditCardTypes = allCreditCards; } CreditCardValidator ccv = new CreditCardValidator(creditCardTypes); String str = pv.toString(); if (!ccv.isValid(str)) { Map map = new HashMap<>(1); map.put("str", str); eci.getMessage().addValidationError(null, parameterName, serviceName, eci.getResource().expand("Value entered is not a valid credit card number.", "", map), null); return false; } return true; } // shouldn't get here, but just in case return true; } private static final HashMap creditCardTypeMap; static { HashMap map = new HashMap<>(5); map.put("visa", CreditCardValidator.VISA); map.put("mastercard", CreditCardValidator.MASTERCARD); map.put("amex", CreditCardValidator.AMEX); map.put("discover", CreditCardValidator.DISCOVER); map.put("dinersclub", CreditCardValidator.DINERS); creditCardTypeMap = map; } private static final long allCreditCards = CreditCardValidator.VISA + CreditCardValidator.MASTERCARD + CreditCardValidator.AMEX + CreditCardValidator.DISCOVER + CreditCardValidator.DINERS; public static final HashMap verbAuthzActionEnumMap; static { HashMap map = new HashMap<>(6); map.put("create", ArtifactExecutionInfo.AUTHZA_CREATE); map.put("update", ArtifactExecutionInfo.AUTHZA_UPDATE); map.put("store", ArtifactExecutionInfo.AUTHZA_UPDATE); map.put("delete", ArtifactExecutionInfo.AUTHZA_DELETE); map.put("view", ArtifactExecutionInfo.AUTHZA_VIEW); map.put("find", ArtifactExecutionInfo.AUTHZA_VIEW); map.put("get", ArtifactExecutionInfo.AUTHZA_VIEW); map.put("search", ArtifactExecutionInfo.AUTHZA_VIEW); verbAuthzActionEnumMap = map; } @SuppressWarnings("unchecked") public static void nestedRemoveNullsFromResultMap(Map result) { if (result == null) return; Iterator> iter = result.entrySet().iterator(); while (iter.hasNext()) { Map.Entry entry = iter.next(); Object value = entry.getValue(); if (value == null) { iter.remove(); continue; } if (value instanceof EntityValue) { entry.setValue(CollectionUtilities.removeNullsFromMap(((EntityValue) value).getMap())); } else if (value instanceof EntityList) { entry.setValue(((EntityList) value).getValueMapList()); } else if (value instanceof Collection) { boolean foundEv = false; Collection valCol = (Collection) value; for (Object colEntry : valCol) { if (colEntry instanceof EntityValue) { foundEv = true; } else if (colEntry instanceof Map) { CollectionUtilities.removeNullsFromMap((Map) colEntry); } else { break; } } if (foundEv) { ArrayList newCol = new ArrayList(valCol.size()); for (Object colEntry : valCol) { if (colEntry instanceof EntityValue) { newCol.add(CollectionUtilities.removeNullsFromMap(((EntityValue) colEntry).getMap())); } else { newCol.add(colEntry); } } entry.setValue(newCol); } } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceEcaRule.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.moqui.impl.actions.XmlAction import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.moqui.util.StringUtilities import jakarta.transaction.Status import jakarta.transaction.Synchronization import jakarta.transaction.Transaction import jakarta.transaction.TransactionManager import javax.transaction.xa.XAException import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class ServiceEcaRule { protected final static Logger logger = LoggerFactory.getLogger(ServiceEcaRule.class) protected final MNode secaNode public final String location, serviceName, serviceNameNoHash, when public final int priority protected final boolean nameIsPattern, runOnError protected final XmlAction condition protected final XmlAction actions ServiceEcaRule(ExecutionContextFactoryImpl ecfi, MNode secaNode, String location) { this.secaNode = secaNode this.location = location serviceName = secaNode.attribute("service") serviceNameNoHash = serviceName.replace("#", "") when = secaNode.attribute("when") nameIsPattern = secaNode.attribute("name-is-pattern") == "true" runOnError = secaNode.attribute("run-on-error") == "true" priority = (secaNode.attribute("priority") ?: "5") as int // prep condition if (secaNode.hasChild("condition") && secaNode.first("condition").children) { // the script is effectively the first child of the condition element condition = new XmlAction(ecfi, secaNode.first("condition").children.get(0), location + ".condition") } else { condition = (XmlAction) null } // prep actions if (secaNode.hasChild("actions")) { String actionsLocation = null String secaId = secaNode.attribute("id") if (secaId != null && !secaId.isEmpty()) actionsLocation = "seca." + secaId + "." + StringUtilities.getRandomString(8) + ".actions" actions = new XmlAction(ecfi, secaNode.first("actions"), actionsLocation) } else { actions = (XmlAction) null } } void runIfMatches(String serviceName, Map parameters, Map results, String when, ExecutionContextImpl ec) { // see if we match this event and should run if (!nameIsPattern && !serviceNameNoHash.equals(serviceName)) return if (nameIsPattern && !serviceName.matches(this.serviceNameNoHash)) return if (!this.when.equals(when)) return if (!runOnError && ec.getMessage().hasError()) return standaloneRun(parameters, results, ec) } void standaloneRun(Map parameters, Map results, ExecutionContextImpl ec) { try { ec.context.push() ec.context.putAll(parameters) ec.context.put("parameters", parameters) if (results != null) { ec.context.putAll(results) ec.context.put("results", results) } // run the condition and if passes run the actions boolean conditionPassed = true if (condition) conditionPassed = condition.checkCondition(ec) if (conditionPassed) { if (actions) actions.run(ec) } } finally { ec.context.pop() } } void registerTx(String serviceName, Map parameters, Map results, ExecutionContextFactoryImpl ecfi) { if (!this.serviceNameNoHash.equals(serviceName)) return def sxr = new SecaSynchronization(this, parameters, results, ecfi) sxr.enlist() } @Override String toString() { return secaNode.toString() } static class SecaSynchronization implements Synchronization { protected final static Logger logger = LoggerFactory.getLogger(SecaSynchronization.class) protected ExecutionContextFactoryImpl ecfi protected ServiceEcaRule sec protected Map parameters protected Map results protected Transaction tx = null SecaSynchronization(ServiceEcaRule sec, Map parameters, Map results, ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi this.sec = sec this.parameters = new HashMap(parameters) this.results = new HashMap(results) } void enlist() { TransactionManager tm = ecfi.transactionFacade.getTransactionManager() if (tm == null || tm.getStatus() != Status.STATUS_ACTIVE) throw new XAException("Cannot enlist: no transaction manager or transaction not active") Transaction tx = tm.getTransaction() if (tx == null) throw new XAException(XAException.XAER_NOTA) this.tx = tx tx.registerSynchronization(this) } @Override void beforeCompletion() { } @Override void afterCompletion(int status) { if (status == Status.STATUS_COMMITTED) { if ("tx-commit".equals(sec.when)) runInThreadAndTx() } else { if ("tx-rollback".equals(sec.when)) runInThreadAndTx() } } void runInThreadAndTx() { ExecutionContextImpl.ThreadPoolRunnable runnable = new ExecutionContextImpl.ThreadPoolRunnable(ecfi, { boolean beganTransaction = ecfi.transactionFacade.begin(null) try { sec.standaloneRun(parameters, results, ecfi.getEci()) } catch (Throwable t) { logger.error("Error running Service TX ECA rule", t) ecfi.transactionFacade.rollback(beganTransaction, "Error running Service TX ECA rule", t) } finally { if (beganTransaction && ecfi.transactionFacade.isTransactionInPlace()) ecfi.transactionFacade.commit() } }) ecfi.workerPool.submit(runnable) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceFacadeImpl.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import groovy.transform.CompileStatic import org.moqui.Moqui import org.moqui.impl.context.ArtifactExecutionInfoImpl import org.moqui.impl.context.ArtifactExecutionInfoImpl.ArtifactTypeStats import org.moqui.impl.context.ContextJavaUtil import org.moqui.impl.context.ContextJavaUtil.CustomScheduledExecutor import org.moqui.resource.ResourceReference import org.moqui.context.ToolFactory import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.resource.ClasspathResourceReference import org.moqui.impl.service.runner.EntityAutoServiceRunner import org.moqui.impl.service.runner.RemoteJsonRpcServiceRunner import org.moqui.service.* import org.moqui.util.CollectionUtilities import org.moqui.util.MNode import org.moqui.util.ObjectUtilities import org.moqui.util.RestClient import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.mail.internet.MimeMessage import javax.cache.Cache import java.sql.Timestamp import java.util.concurrent.* import java.util.concurrent.atomic.AtomicInteger import java.util.concurrent.locks.ReentrantLock @CompileStatic class ServiceFacadeImpl implements ServiceFacade { protected final static Logger logger = LoggerFactory.getLogger(ServiceFacadeImpl.class) public final ExecutionContextFactoryImpl ecfi protected final Cache serviceLocationCache protected final ReentrantLock locationLoadLock = new ReentrantLock() protected Map> secaRulesByServiceName = new HashMap<>() protected final List emecaRuleList = new ArrayList<>() public final RestApi restApi protected final Map serviceRunners = new HashMap<>() private ScheduledJobRunner jobRunner = null public final ThreadPoolExecutor jobWorkerPool private LoadRunner loadRunner = null /** Distributed ExecutorService for async services, etc */ protected ExecutorService distributedExecutorService = null protected final ConcurrentMap> callbackRegistry = new ConcurrentHashMap<>() ServiceFacadeImpl(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi serviceLocationCache = ecfi.cacheFacade.getCache("service.location", String.class, ServiceDefinition.class) MNode serviceFacadeNode = ecfi.confXmlRoot.first("service-facade") serviceFacadeNode.setSystemExpandAttributes(true) // load service runners from configuration for (MNode serviceType in serviceFacadeNode.children("service-type")) { ServiceRunner sr = (ServiceRunner) Thread.currentThread().getContextClassLoader() .loadClass(serviceType.attribute("runner-class")).newInstance() serviceRunners.put(serviceType.attribute("name"), sr.init(this)) } // load REST API restApi = new RestApi(ecfi) jobWorkerPool = makeWorkerPool() } private ThreadPoolExecutor makeWorkerPool() { MNode serviceFacadeNode = ecfi.confXmlRoot.first("service-facade") int jobQueueMax = (serviceFacadeNode.attribute("job-queue-max") ?: "0") as int int coreSize = (serviceFacadeNode.attribute("job-pool-core") ?: "2") as int int maxSize = (serviceFacadeNode.attribute("job-pool-max") ?: "8") as int int availableProcessorsSize = Runtime.getRuntime().availableProcessors() * 2 if (availableProcessorsSize > maxSize) { logger.info("Setting Service Job worker pool size to ${availableProcessorsSize} based on available processors * 2") maxSize = availableProcessorsSize } long aliveTime = (serviceFacadeNode.attribute("worker-pool-alive") ?: "120") as long logger.info("Initializing Service Job ThreadPoolExecutor: queue limit ${jobQueueMax}, pool-core ${coreSize}, pool-max ${maxSize}, pool-alive ${aliveTime}s") // make the actual queue at least maxSize to allow for stuffing the queue to get it to add threads to the pool BlockingQueue workQueue = new LinkedBlockingQueue<>(jobQueueMax < maxSize ? maxSize : jobQueueMax) return new ContextJavaUtil.WorkerThreadPoolExecutor(ecfi, coreSize, maxSize, aliveTime, TimeUnit.SECONDS, workQueue, new ContextJavaUtil.JobThreadFactory()) } void postFacadeInit() { // load Service ECA rules loadSecaRulesAll() // load Email ECA rules loadEmecaRulesAll() MNode serviceFacadeNode = ecfi.confXmlRoot.first("service-facade") // get distributed ExecutorService String distEsFactoryName = serviceFacadeNode.attribute("distributed-factory") if (distEsFactoryName) { logger.info("Getting Async Distributed Service ExecutorService (using ToolFactory ${distEsFactoryName})") ToolFactory esToolFactory = ecfi.getToolFactory(distEsFactoryName) if (esToolFactory == null) { logger.warn("Could not find ExecutorService ToolFactory with name ${distEsFactoryName}, distributed async service calls will be run local only") distributedExecutorService = null } else { distributedExecutorService = esToolFactory.getInstance() } } else { logger.info("No distributed-factory specified, distributed async service calls will be run local only") distributedExecutorService = null } // setup service job runner long jobRunnerRate = (serviceFacadeNode.attribute("scheduled-job-check-time") ?: "60") as long if (jobRunnerRate > 0L) { // wait before first run to make sure all is loaded and we're past an initial activity burst long initialDelay = 120L logger.info("Starting Scheduled Service Job Runner, checking for jobs every ${jobRunnerRate} seconds after a ${initialDelay} second initial delay") jobRunner = new ScheduledJobRunner(ecfi) ecfi.scheduleAtFixedRate(jobRunner, initialDelay, jobRunnerRate) } else { logger.warn("Not starting Scheduled Service Job Runner (config:${jobRunnerRate})") jobRunner = null } } void setDistributedExecutorService(ExecutorService executorService) { logger.info("Setting DistributedExecutorService to ${executorService.class.name}, was ${this.distributedExecutorService?.class?.name}") this.distributedExecutorService = executorService } void warmCache() { logger.info("Warming cache for all service definitions") long startTime = System.currentTimeMillis() Set serviceNames = getKnownServiceNames() for (String serviceName in serviceNames) { try { getServiceDefinition(serviceName) } catch (Throwable t) { logger.warn("Error warming service cache: ${t.toString()}") } } logger.info("Warmed service definition cache for ${serviceNames.size()} services in ${System.currentTimeMillis() - startTime}ms") } void destroy() { // destroy all service runners for (ServiceRunner sr in serviceRunners.values()) sr.destroy() } ServiceRunner getServiceRunner(String type) { serviceRunners.get(type) } // NOTE: this is used in the ServiceJobList screen ScheduledJobRunner getJobRunner() { jobRunner } boolean isServiceDefined(String serviceName) { ServiceDefinition sd = getServiceDefinition(serviceName) if (sd != null) return true String path = ServiceDefinition.getPathFromName(serviceName) String verb = ServiceDefinition.getVerbFromName(serviceName) String noun = ServiceDefinition.getNounFromName(serviceName) return isEntityAutoPattern(path, verb, noun) } boolean isEntityAutoPattern(String serviceName) { return isEntityAutoPattern(ServiceDefinition.getPathFromName(serviceName), ServiceDefinition.getVerbFromName(serviceName), ServiceDefinition.getNounFromName(serviceName)) } boolean isEntityAutoPattern(String path, String verb, String noun) { // if no path, verb is create|update|delete and noun is a valid entity name, do an implicit entity-auto return (path == null || path.isEmpty()) && EntityAutoServiceRunner.verbSet.contains(verb) && ecfi.entityFacade.isEntityDefined(noun) } ServiceDefinition getServiceDefinition(String serviceName) { if (serviceName == null) return null ServiceDefinition sd = (ServiceDefinition) serviceLocationCache.get(serviceName) if (sd != null) return sd // now try some acrobatics to find the service, these take longer to run hence trying to avoid String path = ServiceDefinition.getPathFromName(serviceName) String verb = ServiceDefinition.getVerbFromName(serviceName) String noun = ServiceDefinition.getNounFromName(serviceName) // logger.warn("Getting service definition for [${serviceName}], path=[${path}] verb=[${verb}] noun=[${noun}]") String cacheKey = makeCacheKey(path, verb, noun) boolean cacheKeySame = serviceName.equals(cacheKey) if (!cacheKeySame) { sd = (ServiceDefinition) serviceLocationCache.get(cacheKey) if (sd != null) return sd } // at this point sd is null (from serviceName and cacheKey), so if contains key we know the service doesn't exist; do in lock to avoid reload issues locationLoadLock.lock() try { if (serviceLocationCache.containsKey(serviceName)) return (ServiceDefinition) serviceLocationCache.get(serviceName) if (!cacheKeySame && serviceLocationCache.containsKey(cacheKey)) return (ServiceDefinition) serviceLocationCache.get(cacheKey) } finally { locationLoadLock.unlock() } return makeServiceDefinition(serviceName, path, verb, noun) } protected ServiceDefinition makeServiceDefinition(String origServiceName, String path, String verb, String noun) { locationLoadLock.lock() try { String cacheKey = makeCacheKey(path, verb, noun) if (serviceLocationCache.containsKey(cacheKey)) { // NOTE: this could be null if it's a known non-existing service return (ServiceDefinition) serviceLocationCache.get(cacheKey) } MNode serviceNode = findServiceNode(path, verb, noun) if (serviceNode == null) { // NOTE: don't throw an exception for service not found (this is where we know there is no def), let service caller handle that // Put null in the cache to remember the non-existing service serviceLocationCache.put(cacheKey, null) if (!origServiceName.equals(cacheKey)) serviceLocationCache.put(origServiceName, null) return null } ServiceDefinition sd = new ServiceDefinition(this, path, serviceNode) serviceLocationCache.put(cacheKey, sd) if (!origServiceName.equals(cacheKey)) serviceLocationCache.put(origServiceName, sd) return sd } finally { locationLoadLock.unlock() } } protected static String makeCacheKey(String path, String verb, String noun) { // use a consistent format as the key in the cache, keeping in mind that the verb and noun may be merged in the serviceName passed in // no # here so that it doesn't matter if the caller used one or not return (path != null && !path.isEmpty() ? path + '.' : '') + verb + (noun != null ? noun : '') } protected MNode findServiceNode(String path, String verb, String noun) { if (path == null || path.isEmpty()) return null // make a file location from the path String partialLocation = path.replace('.', '/') + '.xml' String servicePathLocation = 'service/' + partialLocation MNode serviceNode = (MNode) null ResourceReference foundRr = (ResourceReference) null // search for the service def XML file in the classpath LAST (allow components to override, same as in entity defs) ResourceReference serviceComponentRr = new ClasspathResourceReference().init(servicePathLocation) if (serviceComponentRr.supportsExists() && serviceComponentRr.exists) { serviceNode = findServiceNode(serviceComponentRr, verb, noun) if (serviceNode != null) foundRr == serviceComponentRr } // search for the service def XML file in the components for (String location in this.ecfi.getComponentBaseLocations().values()) { // logger.warn("Finding service node for location=[${location}], servicePathLocation=[${servicePathLocation}]") serviceComponentRr = this.ecfi.resourceFacade.getLocationReference(location + "/" + servicePathLocation) if (serviceComponentRr.supportsExists()) { if (serviceComponentRr.exists) { MNode tempNode = findServiceNode(serviceComponentRr, verb, noun) if (tempNode != null) { if (foundRr != null) logger.info("Found service ${verb}#${noun} at ${serviceComponentRr.location} which overrides service at ${foundRr.location}") serviceNode = tempNode foundRr = serviceComponentRr } } } else { // only way to see if it is a valid location is to try opening the stream, so no extra conditions here MNode tempNode = findServiceNode(serviceComponentRr, verb, noun) if (tempNode != null) { if (foundRr != null) logger.info("Found service ${verb}#${noun} at ${serviceComponentRr.location} which overrides service at ${foundRr.location}") serviceNode = tempNode foundRr = serviceComponentRr } } // NOTE: don't quit on finding first, allow later components to override earlier: if (serviceNode != null) break } if (serviceNode == null) logger.warn("Service ${path}.${verb}#${noun} not found; used relative location [${servicePathLocation}]") return serviceNode } protected MNode findServiceNode(ResourceReference serviceComponentRr, String verb, String noun) { if (serviceComponentRr == null || (serviceComponentRr.supportsExists() && !serviceComponentRr.exists)) return null MNode serviceRoot = MNode.parse(serviceComponentRr) MNode serviceNode if (noun) { // only accept the separated names serviceNode = serviceRoot.first({ MNode it -> ("service".equals(it.name) || "service-include".equals(it.name)) && it.attribute("verb") == verb && it.attribute("noun") == noun }) } else { // we just have a verb, this should work if the noun field is empty, or if noun + verb makes up the verb passed in serviceNode = serviceRoot.first({ MNode it -> ("service".equals(it.name) || "service-include".equals(it.name)) && (it.attribute("verb") + (it.attribute("noun") ?: "")) == verb }) } // if we found a service-include look up the referenced service node if (serviceNode != null && "service-include".equals(serviceNode.name)) { String includeLocation = serviceNode.attribute("location") if (includeLocation == null || includeLocation.isEmpty()) { logger.error("Ignoring service-include with no location for verb ${verb} noun ${noun} in ${serviceComponentRr.location}") return null } ResourceReference includeRr = ecfi.resourceFacade.getLocationReference(includeLocation) // logger.warn("includeLocation: ${includeLocation}\nincludeRr: ${includeRr}") return findServiceNode(includeRr, verb, noun) } return serviceNode } Set getKnownServiceNames() { Set sns = new TreeSet() // search declared service-file elements in Moqui Conf XML for (MNode serviceFile in ecfi.confXmlRoot.first("service-facade").children("service-file")) { String location = serviceFile.attribute("location") ResourceReference entryRr = ecfi.resourceFacade.getLocationReference(location) findServicesInFile("classpath://service", entryRr, sns) } // search for service def XML files in the components for (String location in this.ecfi.getComponentBaseLocations().values()) { //String location = "component://${componentName}/service" ResourceReference serviceRr = this.ecfi.resourceFacade.getLocationReference(location + "/service") if (serviceRr.supportsExists() && serviceRr.exists && serviceRr.supportsDirectory()) { findServicesInDir(serviceRr.location, serviceRr, sns) } } // TODO: how to search for service def XML files in the classpath? perhaps keep a list of service files that // have been found on the classpath so we at least have those? return sns } List getAllServiceInfo(int levels) { Map serviceInfoMap = [:] for (String serviceName in getKnownServiceNames()) { int lastDotIndex = 0 for (int i = 0; i < levels; i++) lastDotIndex = serviceName.indexOf(".", lastDotIndex+1) String name = lastDotIndex == -1 ? serviceName : serviceName.substring(0, lastDotIndex) Map curInfo = serviceInfoMap.get(name) if (curInfo) { CollectionUtilities.addToBigDecimalInMap("services", 1.0, curInfo) } else { serviceInfoMap.put(name, [name:name, services:1]) } } TreeSet nameSet = new TreeSet(serviceInfoMap.keySet()) List serviceInfoList = [] for (String name in nameSet) serviceInfoList.add(serviceInfoMap.get(name)) return serviceInfoList } protected void findServicesInDir(String baseLocation, ResourceReference dir, Set sns) { // logger.warn("Finding services in [${dir.location}]") for (ResourceReference entryRr in dir.directoryEntries) { if (entryRr.directory) { findServicesInDir(baseLocation, entryRr, sns) } else if (entryRr.fileName.endsWith(".xml")) { // logger.warn("Finding services in [${entryRr.location}], baseLocation=[${baseLocation}]") if (entryRr.fileName.endsWith(".secas.xml") || entryRr.fileName.endsWith(".emecas.xml") || entryRr.fileName.endsWith(".rest.xml")) continue findServicesInFile(baseLocation, entryRr, sns) } } } protected void findServicesInFile(String baseLocation, ResourceReference entryRr, Set sns) { MNode serviceRoot = MNode.parse(entryRr) if ((serviceRoot.getName()) in ["secas", "emecas", "resource"]) return if (serviceRoot.getName() != "services") { logger.info("While finding service ignoring XML file [${entryRr.location}] in a services directory because the root element is ${serviceRoot.name} and not services") return } // get the service file location without the .xml and without everything up to the "service" directory String location = entryRr.location.substring(0, entryRr.location.lastIndexOf(".")) if (location.startsWith(baseLocation)) location = location.substring(baseLocation.length()) if (location.charAt(0) == '/' as char) location = location.substring(1) location = location.replace('/', '.') for (MNode serviceNode in serviceRoot.children) { String nodeName = serviceNode.name if (!"service".equals(nodeName) && !"service-include".equals(nodeName)) continue sns.add(location + "." + serviceNode.attribute("verb") + (serviceNode.attribute("noun") ? "#" + serviceNode.attribute("noun") : "")) } } void loadSecaRulesAll() { int numLoaded = 0 int numFiles = 0 HashMap ruleByIdMap = new HashMap<>() LinkedList ruleNoIdList = new LinkedList<>() // search for the service def XML file in the components for (String location in this.ecfi.getComponentBaseLocations().values()) { ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + "/service") if (serviceDirRr.supportsAll()) { // if for some weird reason this isn't a directory, skip it if (!serviceDirRr.isDirectory()) continue for (ResourceReference rr in serviceDirRr.directoryEntries) { if (!rr.fileName.endsWith(".secas.xml")) continue numLoaded += loadSecaRulesFile(rr, ruleByIdMap, ruleNoIdList) numFiles++ } } else { logger.warn("Can't load SECA rules from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc") } } if (logger.infoEnabled) logger.info("Loaded ${numLoaded} Service ECA rules from ${numFiles} .secas.xml files, ${ruleNoIdList.size()} rules have no id, ${ruleNoIdList.size() + ruleByIdMap.size()} SECA rules active") Map> ruleMap = new HashMap<>() ruleNoIdList.addAll(ruleByIdMap.values()) for (ServiceEcaRule ecaRule in ruleNoIdList) { // find all matching services if the name is a pattern, otherwise just add the service name to the list boolean nameIsPattern = ecaRule.nameIsPattern List serviceNameList = new ArrayList<>() if (nameIsPattern) { String serviceNamePattern = ecaRule.serviceName for (String ksn : knownServiceNames) { if (ksn.matches(serviceNamePattern)) { serviceNameList.add(ksn) } } } else { serviceNameList.add(ecaRule.serviceName) } // add each of the services in the list to the rule map for (String serviceName in serviceNameList) { // remove the hash if there is one to more consistently match the service name serviceName = StringUtilities.removeChar(serviceName, (char) '#') ArrayList lst = ruleMap.get(serviceName) if (lst == null) { lst = new ArrayList<>() ruleMap.put(serviceName, lst) } // insert by priority int insertIdx = 0 for (int i = 0; i < lst.size(); i++) { ServiceEcaRule lstSer = (ServiceEcaRule) lst.get(i) if (lstSer.priority <= ecaRule.priority) { insertIdx++ } else { break } } lst.add(insertIdx, ecaRule) } } // replace entire SECA rules Map in one operation secaRulesByServiceName = ruleMap } protected int loadSecaRulesFile(ResourceReference rr, HashMap ruleByIdMap, LinkedList ruleNoIdList) { MNode serviceRoot = MNode.parse(rr) int numLoaded = 0 for (MNode secaNode in serviceRoot.children("seca")) { // a service name is valid if it is not a pattern and represents a defined service or if it is a pattern and // matches at least one of the known service names String serviceName = secaNode.attribute("service") boolean nameIsPattern = secaNode.attribute("name-is-pattern") == "true" boolean serviceDefined = false if (nameIsPattern) { for (String ksn : knownServiceNames) { serviceDefined = ksn.matches(serviceName) if (serviceDefined) break } } else { serviceDefined = isServiceDefined(serviceName) } if (!serviceDefined) { logger.warn("Invalid service name ${serviceName} found in SECA file ${rr.location}, skipping") continue } ServiceEcaRule ecaRule = new ServiceEcaRule(ecfi, secaNode, rr.location) String ruleId = secaNode.attribute("id") if (ruleId != null && !ruleId.isEmpty()) ruleByIdMap.put(ruleId, ecaRule) else ruleNoIdList.add(ecaRule) numLoaded++ } if (logger.isTraceEnabled()) logger.trace("Loaded ${numLoaded} Service ECA rules from [${rr.location}]") return numLoaded } ArrayList secaRules(String serviceName) { // NOTE: no need to remove the hash, ServiceCallSyncImpl now passes a service name with no hash return (ArrayList) secaRulesByServiceName.get(serviceName) } static void runSecaRules(String serviceName, Map parameters, Map results, String when, ArrayList lst, ExecutionContextImpl eci) { int lstSize = lst.size() for (int i = 0; i < lstSize; i++) { ServiceEcaRule ser = (ServiceEcaRule) lst.get(i) ser.runIfMatches(serviceName, parameters, results, when, eci) } } void registerTxSecaRules(String serviceName, Map parameters, Map results, ArrayList lst) { int lstSize = lst.size() for (int i = 0; i < lstSize; i++) { ServiceEcaRule ser = (ServiceEcaRule) lst.get(i) if (ser.when.startsWith("tx-")) ser.registerTx(serviceName, parameters, results, ecfi) } } int getSecaRuleCount() { int count = 0 for (List ruleList in secaRulesByServiceName.values()) count += ruleList.size() return count } protected void loadEmecaRulesAll() { if (emecaRuleList.size() > 0) emecaRuleList.clear() // search for the service def XML file in the components for (String location in this.ecfi.getComponentBaseLocations().values()) { ResourceReference serviceDirRr = this.ecfi.resourceFacade.getLocationReference(location + "/service") if (serviceDirRr.supportsAll()) { // if for some weird reason this isn't a directory, skip it if (!serviceDirRr.isDirectory()) continue for (ResourceReference rr in serviceDirRr.directoryEntries) { if (!rr.fileName.endsWith(".emecas.xml")) continue loadEmecaRulesFile(rr) } } else { logger.warn("Can't load Email ECA rules from component at [${serviceDirRr.location}] because it doesn't support exists/directory/etc") } } } protected void loadEmecaRulesFile(ResourceReference rr) { MNode emecasRoot = MNode.parse(rr) int numLoaded = 0 for (MNode emecaNode in emecasRoot.children("emeca")) { EmailEcaRule eer = new EmailEcaRule(ecfi, emecaNode, rr.location) emecaRuleList.add(eer) numLoaded++ } if (logger.infoEnabled) logger.info("Loaded [${numLoaded}] Email ECA rules from [${rr.location}]") } void runEmecaRules(MimeMessage message, String emailServerId) { ExecutionContextImpl eci = ecfi.getEci() for (EmailEcaRule eer in emecaRuleList) eer.runIfMatches(message, emailServerId, eci) } @Override ServiceCallSync sync() { return new ServiceCallSyncImpl(this) } @Override ServiceCallAsync async() { return new ServiceCallAsyncImpl(this) } @Override ServiceCallJob job(String jobName) { return new ServiceCallJobImpl(jobName, this) } @Override ServiceCallSpecial special() { return new ServiceCallSpecialImpl(this) } @Override Map callJsonRpc(String location, String method, Map parameters) { return RemoteJsonRpcServiceRunner.runJsonService(null, location, method, parameters, ecfi.getExecutionContext()) } @Override RestClient rest() { return new RestClient() } @Override void registerCallback(String serviceName, ServiceCallback serviceCallback) { List callbackList = callbackRegistry.get(serviceName) if (callbackList == null) { callbackList = new CopyOnWriteArrayList() callbackRegistry.putIfAbsent(serviceName, callbackList) callbackList = callbackRegistry.get(serviceName) } callbackList.add(serviceCallback) } void callRegisteredCallbacks(String serviceName, Map context, Map result) { List callbackList = callbackRegistry.get(serviceName) if (callbackList != null && callbackList.size() > 0) for (ServiceCallback scb in callbackList) scb.receiveEvent(context, result) } void callRegisteredCallbacksThrowable(String serviceName, Map context, Throwable t) { List callbackList = callbackRegistry.get(serviceName) if (callbackList != null && callbackList.size() > 0) for (ServiceCallback scb in callbackList) scb.receiveEvent(context, t) } // ========================== // Service LoadRunner Classes // ========================== synchronized LoadRunner getLoadRunner() { if (loadRunner == null) loadRunner = new LoadRunner(ecfi) return loadRunner } static class LoadRunnerServiceRunnable implements Runnable, Externalizable { volatile ExecutionContextFactoryImpl ecfi volatile LoadRunner loadRunner String serviceName, parametersExpr LoadRunnerServiceRunnable() { // init the other objects that can't be serialized ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() loadRunner = ecfi.serviceFacade.getLoadRunner() } LoadRunnerServiceRunnable(String serviceName, String parametersExpr, LoadRunner loadRunner) { this.loadRunner = loadRunner this.ecfi = loadRunner.ecfi this.serviceName = serviceName this.parametersExpr = parametersExpr } @Override void writeExternal(ObjectOutput out) throws IOException { out.writeUTF(serviceName) // never null out.writeObject(parametersExpr) // may be null } @Override void readExternal(ObjectInput objectInput) throws IOException, ClassNotFoundException { serviceName = objectInput.readUTF() parametersExpr = objectInput.readObject() } @Override void run() { try { runInternal() } catch (Throwable t) { logger.error("Error in LoadRunner service run", t) } } void runInternal() { // check for active Transaction if (getEcfi().transactionFacade.isTransactionInPlace()) { logger.error("In LoadRunner service ${serviceName} a transaction is in place for thread ${Thread.currentThread().getName()}, trying to commit") try { getEcfi().transactionFacade.destroyAllInThread() } catch (Exception e) { logger.error("LoadRunner commit in place transaction failed for thread ${Thread.currentThread().getName()}", e) } } // check for active ExecutionContext ExecutionContextImpl activeEc = getEcfi().activeContext.get() if (activeEc != null) { logger.error("In LoadRunner service ${serviceName} there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread ${Thread.currentThread().id}:${Thread.currentThread().name}, destroying") try { activeEc.destroy() } catch (Throwable t) { logger.error("Error destroying LoadRunner already in place in ServiceCallAsync in thread ${Thread.currentThread().id}:${Thread.currentThread().name}", t) } } LoadRunnerServiceInfo serviceInfo = loadRunner.getServiceInfo(serviceName, parametersExpr) if (serviceInfo == null) { logger.error("Service Info not found for ${serviceName} ${parametersExpr}, not running") return } String parametersExpr = serviceInfo.parametersExpr Map parameters = [index:loadRunner.execIndex.getAndIncrement()] as Map if (parametersExpr != null && !parametersExpr.isEmpty()) { try { Map exprMap = (Map) loadRunner.ecfi.getEci().resourceFacade .expression(parametersExpr, null, parameters) if (exprMap != null) parameters.putAll(exprMap) } catch (Throwable t) { logger.error("Error in Service LoadRunner parameter expression: ${parametersExpr}", t) } } // before starting, and tracking the startTime, do a small random delay for variation in run times if (serviceInfo.runDelayVaryMs != 0) Thread.sleep(ThreadLocalRandom.current().nextInt(serviceInfo.runDelayVaryMs)) long startTime = System.currentTimeMillis() ExecutionContextImpl threadEci = ecfi.getEci() try { // always login anonymous, disable authz below threadEci.userFacade.loginAnonymousIfNoUser() // run the service try { serviceInfo.lastResult = threadEci.serviceFacade.sync().name(serviceName) .parameters(parameters).disableAuthz().call() } catch (Throwable t) { // logged elsewhere, just count and swallow serviceInfo.errorCount++ } // count the run and accumulate stats serviceInfo.countRun(loadRunner, startTime, System.currentTimeMillis(), threadEci.artifactExecutionFacade.getArtifactTypeStats()) } finally { if (threadEci != null) threadEci.destroy() } } } static class LoadRunnerServiceStats { long lastRunTime = 0, beginTime = 0, totalTime = 0, totalSquaredTime = 0, minTime = Long.MAX_VALUE, maxTime = 0 int runCount = 0, errorCount = 0 ArtifactTypeStats artifactTypeStats = new ArtifactTypeStats() Map getMap() { Map newMap = [lastRunTime:lastRunTime, beginTime:beginTime, totalTime:totalTime, totalSquaredTime:totalSquaredTime, minTime:minTime, maxTime:maxTime, runCount:runCount, errorCount:errorCount] as Map newMap.put("artifactTypeStats", ObjectUtilities.objectToMap(artifactTypeStats)) return newMap } } static class LoadRunnerServiceInfo extends LoadRunnerServiceStats { String serviceName, parametersExpr int targetThreads, runDelayMs, runDelayVaryMs, rampDelayMs, timeBinLength, timeBinsKeep AtomicInteger currentThreads = new AtomicInteger(0) Map lastResult = null ConcurrentLinkedDeque timeBinList = new ConcurrentLinkedDeque<>() ArrayList runFutures = new ArrayList<>() ScheduledFuture rampFuture = null LoadRunnerServiceInfo(String serviceName, String parametersExpr, int targetThreads, int runDelayMs, int runDelayVaryMs, int rampDelayMs, int timeBinLength, int timeBinsKeep) { this.serviceName = serviceName; this.parametersExpr = parametersExpr this.targetThreads = targetThreads this.runDelayMs = runDelayMs; this.runDelayVaryMs = runDelayVaryMs; this.rampDelayMs = rampDelayMs this.timeBinLength = timeBinLength; this.timeBinsKeep = timeBinsKeep } void countRun(LoadRunner loadRunner, long startTime, long endTime, ArtifactTypeStats stats) { long runTime = endTime - startTime // logger.info("count run ${serviceName} ${runTime} ${Thread.currentThread().name}") LoadRunnerServiceStats curBin = null // find the current time bin in a semaphore locked section, the rest is increments and can be run multithreaded loadRunner.mutateLock.lock() // logger.info("count run after lock ${serviceName} ${runTime} ${Thread.currentThread().name}") try { if (beginTime == 0) beginTime = startTime curBin = timeBinList.isEmpty() ? null : timeBinList.getLast() // create and add a new bin if there are none or if this hit is after the bin's end time (need to advance the bin) if (curBin == null || curBin.beginTime + timeBinLength < startTime) { curBin = new LoadRunnerServiceStats() curBin.beginTime = startTime timeBinList.add(curBin) if (timeBinList.size() > timeBinsKeep) { LoadRunnerServiceStats removeBin = timeBinList.removeFirst() // logger.info("Removed time bin starting ${new Timestamp(removeBin.beginTime)} count ${removeBin.runCount}") } } // some exceptions, these are multiple operations and need to be in the locked section to be accurate, maybe don't need to be... if (runTime < this.minTime) this.minTime = runTime if (runTime > this.maxTime) this.maxTime = runTime if (runTime < curBin.minTime) curBin.minTime = runTime if (runTime > curBin.maxTime) curBin.maxTime = runTime } finally { loadRunner.mutateLock.unlock() // logger.info("count run after unlock ${serviceName} ${runTime} ${Thread.currentThread().name}") // loadRunner.logFutures() } // for all runs this.runCount++ this.lastRunTime = endTime this.totalTime += runTime this.totalSquaredTime += runTime * runTime this.artifactTypeStats.add(stats) // same thing for just this bin curBin.runCount++ curBin.lastRunTime = endTime curBin.totalTime += runTime curBin.totalSquaredTime += runTime * runTime curBin.artifactTypeStats.add(stats) } void addThread(LoadRunner loadRunner) { LoadRunnerServiceRunnable runnable = new LoadRunnerServiceRunnable(serviceName, parametersExpr, loadRunner) // NOTE: use scheduleWithFixedDelay so delay is wait between terminate of one and start of another, better for both short and long running test runs ScheduledFuture future = loadRunner.scheduledExecutor.scheduleWithFixedDelay(runnable, 1, runDelayMs, TimeUnit.MILLISECONDS) // logger.info("Added run thread runDelayMs ${runDelayMs} ${runDelayMs?.class?.name} done ${future.done} ${future.toString()}") runFutures.add(future) } void addRampThread(LoadRunner loadRunner) { if (rampFuture != null && !rampFuture) beginTime = System.currentTimeMillis() LoadRunnerRamperRunnable runnable = new LoadRunnerRamperRunnable(loadRunner, this) // NOTE: use scheduleAtFixedRate so one is added each delay period regardless of how long it takes (generally not long) rampFuture = loadRunner.scheduledExecutor.scheduleAtFixedRate(runnable, 1, rampDelayMs, TimeUnit.MILLISECONDS) } void resetStats() { lastRunTime = 0; beginTime = 0; totalTime = 0; totalSquaredTime = 0; minTime = Long.MAX_VALUE; maxTime = 0 runCount = 0; errorCount = 0 artifactTypeStats = new ArtifactTypeStats() lastResult = null timeBinList = new ConcurrentLinkedDeque<>() } } static class LoadRunnerRamperRunnable implements Runnable { LoadRunner loadRunner LoadRunnerServiceInfo serviceInfo LoadRunnerRamperRunnable(LoadRunner loadRunner, LoadRunnerServiceInfo serviceInfo) { this.loadRunner = loadRunner this.serviceInfo = serviceInfo } @Override void run() { if (serviceInfo.currentThreads < serviceInfo.targetThreads) { serviceInfo.addThread(loadRunner) // may not actually need AtomicInteger here, but for ramp down will need a list of ScheduledFuture objects and might be useful there serviceInfo.currentThreads.incrementAndGet() } // TODO add delayed ramp-down, useful for some performance behavior patterns but usually redundant with delayed ramp up to look for elbows in the response time over time } } static class LoadRunnerThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("LoadRunner") private final AtomicInteger threadNumber = new AtomicInteger(1) Thread newThread(Runnable r) { return new Thread(workerGroup, r, "LoadRunner-" + threadNumber.getAndIncrement()) } } static class LoadRunner { ExecutionContextFactoryImpl ecfi CustomScheduledExecutor scheduledExecutor = null ArrayList serviceInfos = new ArrayList<>() Integer corePoolSize = 4, maxPoolSize = null AtomicInteger execIndex = new AtomicInteger(1) ReentrantLock mutateLock = new ReentrantLock() LoadRunner(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi } LoadRunnerServiceInfo getServiceInfo(String serviceName, String parametersExpr) { for (int i = 0; i < serviceInfos.size(); i++) { LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i) if (curInfo.serviceName == serviceName && curInfo.parametersExpr == parametersExpr) return curInfo } return null } void setServiceInfo(String serviceName, String parametersExpr, int targetThreads, int runDelayMs, int runDelayVaryMs, int rampDelayMs, int timeBinLength, int timeBinsKeep) { mutateLock.lock() try { LoadRunnerServiceInfo serviceInfo = getServiceInfo(serviceName, parametersExpr) if (serviceInfo == null) { serviceInfo = new LoadRunnerServiceInfo(serviceName, parametersExpr, targetThreads, runDelayMs, runDelayVaryMs, rampDelayMs, timeBinLength, timeBinsKeep) serviceInfos.add(serviceInfo) if (scheduledExecutor != null) { // begin() already called, get this started serviceInfo.addRampThread(this) } } else { serviceInfo.targetThreads = targetThreads serviceInfo.runDelayMs = runDelayMs serviceInfo.rampDelayMs = rampDelayMs serviceInfo.timeBinLength = timeBinLength serviceInfo.timeBinsKeep = timeBinsKeep } } finally { mutateLock.unlock() } } void begin() { mutateLock.lock() try { if (scheduledExecutor == null) { // restart index execIndex = new AtomicInteger(1) for (int i = 0; i < serviceInfos.size(); i++) { LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i) // clear out stats before start curInfo.currentThreads = new AtomicInteger(0) curInfo.resetStats() } scheduledExecutor = new CustomScheduledExecutor(corePoolSize, new LoadRunnerThreadFactory()) if (maxPoolSize == null) maxPoolSize = Runtime.getRuntime().availableProcessors() * 4 scheduledExecutor.setMaximumPoolSize(maxPoolSize) for (int i = 0; i < serviceInfos.size(); i++) { LoadRunnerServiceInfo curInfo = (LoadRunnerServiceInfo) serviceInfos.get(i) // get the ramp thread started curInfo.addRampThread(this) } } } finally { mutateLock.unlock() } } void stopNow() { mutateLock.lock() try { if (scheduledExecutor != null) { logger.info("Shutting down LoadRunner ScheduledExecutorService now") scheduledExecutor.shutdownNow() scheduledExecutor = null } } finally { mutateLock.unlock() } } void stopWait() { mutateLock.lock() try { if (scheduledExecutor != null) { logger.info("Shutting down LoadRunner ScheduledExecutorService") scheduledExecutor.shutdown() } if (scheduledExecutor != null) { scheduledExecutor.awaitTermination(30, TimeUnit.SECONDS) if (scheduledExecutor.isTerminated()) logger.info("LoadRunner Scheduled executor shut down and terminated") else logger.warn("LoadRunner Scheduled executor NOT YET terminated, waited 30 seconds") scheduledExecutor = null } } finally { mutateLock.unlock() } } void logFutures() { for (int si = 0; si < serviceInfos.size(); si++) { LoadRunnerServiceInfo serviceInfo = serviceInfos.get(si) logger.info("LoadRunner RAMP Future done ${serviceInfo.rampFuture?.done} canceled ${serviceInfo.rampFuture?.cancelled} ${serviceInfo.rampFuture?.toString()}") for (int i = 0; i < serviceInfo.runFutures.size(); i++) { ScheduledFuture future = serviceInfo.runFutures.get(i) logger.info("LoadRunner RUN Future done ${future.done} canceled ${future.cancelled} ${future.toString()}") } } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceJsonRpcDispatcher.groovy ================================================ package org.moqui.impl.service import groovy.transform.CompileStatic /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.moqui.context.ArtifactAuthorizationException import org.moqui.impl.context.ExecutionContextImpl import org.slf4j.Logger import org.slf4j.LoggerFactory /* NOTE: see JSON-RPC2 specs at: http://www.jsonrpc.org/specification */ @CompileStatic public class ServiceJsonRpcDispatcher { protected final static Logger logger = LoggerFactory.getLogger(ServiceJsonRpcDispatcher.class) final static int PARSE_ERROR = -32700 // Invalid JSON was received by the server. An error occurred on the server while parsing the JSON text. final static int INVALID_REQUEST = -32600 // The JSON sent is not a valid Request object. final static int METHOD_NOT_FOUND = -32601 // The method does not exist / is not available. final static int INVALID_PARAMS = -32602 // Invalid method parameter(s). final static int INTERNAL_ERROR = -32603 // Internal JSON-RPC error. private final ExecutionContextImpl eci public ServiceJsonRpcDispatcher(ExecutionContextImpl eci) { this.eci = eci } public void dispatch() { Map callMap = eci.web.getRequestParameters() if (callMap._requestBodyJsonList) { List callList = (List) callMap._requestBodyJsonList List jsonRespList = [] for (Object callSingleObj in callList) { if (callSingleObj instanceof Map) { Map callSingleMap = (Map) callSingleObj jsonRespList.add(callSingle(callSingleMap.method as String, callSingleMap.params, callSingleMap.id ?: null)) } else { jsonRespList.add(callSingle(null, callSingleObj, null)) } } } else { // logger.info("========= JSON-RPC request with map: ${callMap}") Map jsonResp = callSingle(callMap.method as String, callMap.params, callMap.id ?: null) eci.getWeb().sendJsonResponse(jsonResp) } } protected Map callSingle(String method, Object paramsObj, Object id) { // logger.warn("========= JSON-RPC call method=[${method}], id=[${id}], params=${paramsObj}") String errorMessage = null Integer errorCode = null ServiceDefinition sd = method ? eci.serviceFacade.getServiceDefinition(method) : null if (eci.web.getRequestParameters()._requestBodyJsonParseError) { errorMessage = eci.web.getRequestParameters()._requestBodyJsonParseError errorCode = PARSE_ERROR } else if (!method) { errorMessage = "No method specified" errorCode = INVALID_REQUEST } else if (sd == null) { errorMessage = "Unknown service [${method}]" errorCode = METHOD_NOT_FOUND } else if (!(paramsObj instanceof Map)) { // We expect named parameters (JSON object) errorMessage = "Parameters must be named parameters (JSON object, Java Map), got type [${paramsObj.class.getName()}]" errorCode = INVALID_PARAMS } else if (!sd.allowRemote) { errorMessage = "Service [${sd.serviceName}] does not allow remote calls" errorCode = METHOD_NOT_FOUND } Map result = null if (errorMessage == null) { try { result = eci.service.sync().name(sd.serviceName).parameters((Map) paramsObj).call() if (eci.getMessage().hasError()) { logger.warn("Got errors in JSON-RPC call to service [${sd.serviceName}]: ${eci.message.errorsString}") errorMessage = eci.message.errorsString // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily errorCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR } } catch (ArtifactAuthorizationException e) { logger.error("Authz error calling service ${sd.serviceName} from JSON-RPC request: ${e.toString()}", e) errorMessage = e.getMessage() // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily errorCode = HttpServletResponse.SC_FORBIDDEN } catch (Exception e) { logger.error("Error calling service ${sd.serviceName} from JSON-RPC request: ${e.toString()}", e) errorMessage = e.getMessage() // could use whatever code here as long as it is not -32768 to -32000, this was chosen somewhat arbitrarily errorCode = HttpServletResponse.SC_INTERNAL_SERVER_ERROR } } if (errorMessage == null) { return [jsonrpc:"2.0", id:id, result:result] } else { logger.warn("Responding with JSON-RPC error code [${errorCode}]: ${errorMessage}") return [jsonrpc:"2.0", id:id, error:[code:errorCode, message:errorMessage]] } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/ServiceRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service import org.moqui.service.ServiceException interface ServiceRunner { ServiceRunner init(ServiceFacadeImpl sfi); Map runService(ServiceDefinition sd, Map parameters) throws ServiceException; void destroy(); } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/EntityAutoServiceRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner import groovy.transform.CompileStatic import org.moqui.BaseException import org.moqui.context.ExecutionContext import org.moqui.context.ExecutionContextFactory import org.moqui.entity.EntityException import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.entity.EntityValueNotFoundException import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.impl.entity.EntityValueBase import org.moqui.impl.entity.FieldInfo import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.impl.service.ServiceRunner import org.moqui.service.ServiceException import org.moqui.util.ObjectUtilities import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp @CompileStatic class EntityAutoServiceRunner implements ServiceRunner { protected final static Logger logger = LoggerFactory.getLogger(EntityAutoServiceRunner.class) final static Set verbSet = new HashSet(['create', 'update', 'delete', 'store']) final static Set otherFieldsToSkip = new HashSet(['ec', '_entity', 'authUsername', 'authPassword']) private ServiceFacadeImpl sfi = null private ExecutionContextFactoryImpl ecfi = null EntityAutoServiceRunner() {} @Override ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; ecfi = sfi.ecfi; return this } @Override void destroy() { } // TODO: add update-expire and delete-expire entity-auto service verbs for entities with from/thru dates // TODO: add find (using search input parameters) and find-one (using literal PK, or as many PK fields as are passed on) entity-auto verbs Map runService(ServiceDefinition sd, Map parameters) { // check the verb and noun if (sd.verb == null || !verbSet.contains(sd.verb)) throw new ServiceException("In service ${sd.serviceName} the verb must be one of ${verbSet} for entity-auto type services.") if (sd.noun == null || sd.noun.isEmpty()) throw new ServiceException("In service ${sd.serviceName} you must specify a noun for entity-auto service calls") ExecutionContextImpl eci = ecfi.getEci() EntityDefinition ed = eci.entityFacade.getEntityDefinition(sd.noun) if (ed == null) throw new ServiceException("In service ${sd.serviceName} the specified noun ${sd.noun} is not a valid entity name") Map result = new HashMap() try { boolean allPksInOnly = true for (String pkFieldName in ed.getPkFieldNames()) { if (!sd.getInParameter(pkFieldName) || sd.getOutParameter(pkFieldName)) { allPksInOnly = false; break } } if ("create".equals(sd.verb)) { createEntity(eci, ed, parameters, result, sd.getOutParameterNames()) } else if ("update".equals(sd.verb)) { /* */ if (!allPksInOnly) throw new ServiceException("In entity-auto type service ${sd.serviceName} with update noun, not all pk fields have the mode IN") updateEntity(eci, ed, parameters, result, sd.getOutParameterNames(), null) } else if ("delete".equals(sd.verb)) { /* */ if (!allPksInOnly) throw new ServiceException("In entity-auto type service ${sd.serviceName} with delete noun, not all pk fields have the mode IN") deleteEntity(eci, ed, parameters) } else if ("store".equals(sd.verb)) { storeEntity(eci, ed, parameters, result, sd.getOutParameterNames()) } else if ("update-expire".equals(sd.verb)) { // TODO } else if ("delete-expire".equals(sd.verb)) { // TODO } else if ("find".equals(sd.verb)) { // TODO } else if ("find-one".equals(sd.verb)) { // TODO } } catch (BaseException e) { throw new ServiceException("Error doing entity-auto operation for entity [${ed.fullEntityName}] in service [${sd.serviceName}]", e) } return result } protected static void checkFromDate(EntityDefinition ed, Map parameters, Map result, ExecutionContextFactoryImpl ecfi) { List pkFieldNames = ed.getPkFieldNames() // always make fromDate optional, whether or not part of the pk; do this before the allPksIn check if (pkFieldNames.contains("fromDate") && parameters.get("fromDate") == null) { Timestamp fromDate = ecfi.getExecutionContext().getUser().getNowTimestamp() parameters.put("fromDate", fromDate) result.put("fromDate", fromDate) // logger.info("Set fromDate field to default [${parameters.fromDate}]") } } protected static boolean checkAllPkFields(EntityDefinition ed, Map parameters, Map tempResult, EntityValue newEntityValue, ArrayList outParamNames) { FieldInfo[] pkFieldInfos = ed.entityInfo.pkFieldInfoArray // see if all PK fields were passed in boolean allPksIn = true int pkSize = pkFieldInfos.length ArrayList missingPkFields = (ArrayList) null for (int i = 0; i < pkSize; i++) { FieldInfo fieldInfo = (FieldInfo) pkFieldInfos[i] Object pkValue = parameters.get(fieldInfo.name) if (ObjectUtilities.isEmpty(pkValue) && (fieldInfo.defaultStr == null || fieldInfo.defaultStr.isEmpty())) { allPksIn = false if (missingPkFields == null) missingPkFields = new ArrayList<>() missingPkFields.add(fieldInfo.name) } } boolean isSinglePk = pkSize == 1 boolean isDoublePk = pkSize == 2 // logger.info("======= checkAllPkFields for ${ed.getEntityName()} allPksIn=${allPksIn}, isSinglePk=${isSinglePk}, isDoublePk=${isDoublePk}; parameters: ${parameters}") if (isSinglePk) { /* **** primary sequenced primary key **** */ /* **** primary sequenced key with optional override passed in **** */ FieldInfo singlePkField = pkFieldInfos[0] Object pkValue = parameters.get(singlePkField.name) if (!ObjectUtilities.isEmpty(pkValue)) { // convert from String if parameter type is String, PK field type may not be if (pkValue instanceof CharSequence) newEntityValue.setString(singlePkField.name, pkValue.toString()) else newEntityValue.set(singlePkField.name, pkValue) } else { // if it has a default value don't sequence the PK if (singlePkField.defaultStr == null || singlePkField.defaultStr.isEmpty()) { newEntityValue.setSequencedIdPrimary() pkValue = newEntityValue.getNoCheckSimple(singlePkField.name) } } if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(singlePkField.name)) tempResult.put(singlePkField.name, pkValue) } else if (isDoublePk && !allPksIn) { /* **** secondary sequenced primary key **** */ // don't do it this way, currently only supports second pk fields: String doublePkSecondaryName = parameters.get(pkFieldNames.get(0)) ? pkFieldNames.get(1) : pkFieldNames.get(0) FieldInfo doublePkSecondary = pkFieldInfos[1] newEntityValue.setFields(parameters, true, null, true) // if it has a default value don't sequence the PK if (doublePkSecondary.defaultStr == null || doublePkSecondary.defaultStr.isEmpty()) { newEntityValue.setSequencedIdSecondary() if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains(doublePkSecondary.name)) tempResult.put(doublePkSecondary.name, newEntityValue.getNoCheckSimple(doublePkSecondary.name)) } } else if (allPksIn) { /* **** plain specified primary key **** */ newEntityValue.setFields(parameters, true, null, true) } else { logger.error("Entity [${ed.fullEntityName}] auto create pk fields ${ed.getPkFieldNames()} incomplete: ${parameters}" + "\nCould not find a valid combination of primary key settings to do a create operation; options include: " + "1. a single entity primary-key field for primary auto-sequencing with or without matching in-parameter, and with or without matching out-parameter for the possibly sequenced value, " + "2. a 2-part entity primary-key with one part passed in as an in-parameter (existing primary pk value) and with or without the other part defined as an out-parameter (the secodnary pk to sub-sequence), " + "3. all entity pk fields are passed into the service") if (missingPkFields.size() == 1) { throw new ServiceException("Required field ${StringUtilities.camelCaseToPretty(missingPkFields.get(0))} is missing, cannot create ${StringUtilities.camelCaseToPretty(ed.entityName)}") } else { throw new ServiceException("Required fields ${missingPkFields.collect({ StringUtilities.camelCaseToPretty(it) }).join(', ')} are missing, cannot create ${StringUtilities.camelCaseToPretty(ed.entityName)}") } } // logger.info("In auto createEntity allPksIn [${allPksIn}] isSinglePk [${isSinglePk}] isDoublePk [${isDoublePk}] newEntityValue final [${newEntityValue}]") return allPksIn } static void createEntity(ExecutionContextImpl eci, EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames) { createRecursive(eci.ecfi, eci.entityFacade, ed, parameters, result, outParamNames, null) } static void createRecursive(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames, Map parentPks) { EntityValue newEntityValue = ed.makeEntityValue() // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to // allow them to be left out of related records if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!parameters.containsKey(entry.key)) parameters.put(entry.key, entry.value) } checkFromDate(ed, parameters, result, ecfi) Map tempResult = [:] checkAllPkFields(ed, parameters, tempResult, newEntityValue, outParamNames) newEntityValue.setFields(parameters, true, null, false) try { newEntityValue.create() } catch (Exception e) { if (e.getMessage().contains("primary key")) { long[] bank = (long[]) efi.entitySequenceBankCache.get(ed.getFullEntityName()) EntityValue svi = efi.find("moqui.entity.SequenceValueItem").condition("seqName", ed.getFullEntityName()) .useCache(false).disableAuthz().one() logger.warn("Got PK violation, current bank is ${bank}, PK is ${newEntityValue.getPrimaryKeys()}, current SequenceValueItem: ${svi}") } throw e } // NOTE: keep a separate Map of parent PK values to pass down, can't just be current record's PK fields because // we allow other entities to be nested, and they may have nested records that depend ANY ancestor's PKs // this returns a clone or new Map, so we'll modify it freely Map sharedPkMap = newEntityValue.getPrimaryKeys() if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value) } // if a PK field has a @default get it and return it ArrayList pkFieldNames = ed.getPkFieldNames() int size = pkFieldNames.size() for (int i = 0; i < size; i++) { String pkName = (String) pkFieldNames.get(i) FieldInfo pkInfo = ed.getFieldInfo(pkName) if (pkInfo.defaultStr != null && !pkInfo.defaultStr.isEmpty()) { tempResult.put(pkName, newEntityValue.getNoCheckSimple(pkName)) } } // check parameters Map for relationships and other entities Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(parameters, null) for (Map.Entry entry in nonFieldEntries.entrySet()) { Object relParmObj = entry.getValue() if (relParmObj == null) continue // if the entry is not a Map or List ignore it, we're only looking for those if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue String entryName = (String) entry.getKey() if (parentPks != null && parentPks.containsKey(entryName)) continue if (otherFieldsToSkip.contains(entryName)) continue EntityDefinition subEd = null Map pkMap = null RelationshipInfo relInfo = ed.getRelationshipInfo(entryName) if (relInfo != null) { if (!relInfo.mutable) { if (logger.isTraceEnabled()) logger.trace("In create entity auto service found key [${entryName}] which is a non-mutable relationship of [${ed.getFullEntityName()}], skipping") continue } subEd = relInfo.relatedEd // this is a relationship so add mapped key fields to the parentPks if any field names are different pkMap = new HashMap<>(sharedPkMap) pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap)) } else if (efi.isEntityDefined(entryName)) { subEd = efi.getEntityDefinition(entryName) pkMap = sharedPkMap } if (subEd == null) { // this happens a lot, extra stuff passed to the service call, so be quiet unless trace is on if (logger.isTraceEnabled()) logger.trace("In create entity auto service found key [${entryName}] which is not a field or relationship of [${ed.getFullEntityName()}] and is not a defined entity") continue } boolean isEntityValue = relParmObj instanceof EntityValue if (relParmObj instanceof Map && !isEntityValue) { Map relResults = new HashMap() createRecursive(ecfi, efi, subEd, (Map) relParmObj, relResults, null, pkMap) tempResult.put(entryName, relResults) } else if (relParmObj instanceof List) { List relResultList = [] for (Object relParmEntry in relParmObj) { Map relResults = new HashMap() if (relParmEntry instanceof Map) { createRecursive(ecfi, efi, subEd, (Map) relParmEntry, relResults, null, pkMap) } else { logger.warn("In entity auto create for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}") } relResultList.add(relResults) } tempResult.put(entryName, relResultList) } else { if (isEntityValue) { if (logger.isTraceEnabled()) logger.trace("In entity auto create for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } else { logger.warn("In entity auto create for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } } } result.putAll(tempResult) } /** Does a create if record does not exist, or update if it does. */ static void storeEntity(ExecutionContextImpl eci, EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames) { storeRecursive(eci.ecfi, eci.getEntityFacade(), ed, parameters, result, outParamNames, null) } static void storeRecursive(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames, Map parentPks) { EntityValue newEntityValue = efi.makeValue(ed.getFullEntityName()) // add in all of the main entity's primary key fields, this is necessary for auto-generated, and to // allow them to be left out of related records if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!parameters.containsKey(entry.key)) parameters.put(entry.key, entry.value) } checkFromDate(ed, parameters, result, ecfi) Map tempResult = [:] boolean allPksIn = checkAllPkFields(ed, parameters, tempResult, newEntityValue, outParamNames) if (result != null) result.putAll(tempResult) if (!allPksIn) { // we had to fill some stuff in, so do a create newEntityValue.setFields(parameters, true, null, false) newEntityValue.create() storeRelated(ecfi, efi, (EntityValueBase) newEntityValue, parameters, result, parentPks) return } EntityValue lookedUpValue = null if (parameters.containsKey("statusId") && ed.isField("statusId")) { // do the actual query so we'll have the current statusId lookedUpValue = efi.find(ed.fullEntityName) .condition(newEntityValue).useCache(false).one() if (lookedUpValue != null) { checkStatus(ed, parameters, result, outParamNames, lookedUpValue, efi) } else { // no lookedUpValue at this point? doesn't exist so create newEntityValue.setFields(parameters, true, null, false) newEntityValue.create() storeRelated(ecfi, efi, (EntityValueBase) newEntityValue, parameters, result, parentPks) return } } if (lookedUpValue == null) lookedUpValue = newEntityValue lookedUpValue.setFields(parameters, true, null, false) // logger.info("In auto updateEntity lookedUpValue final [${lookedUpValue}] for parameters [${parameters}]") lookedUpValue.createOrUpdate() storeRelated(ecfi, efi, (EntityValueBase) lookedUpValue, parameters, result, parentPks) } static void storeRelated(ExecutionContextFactoryImpl ecfi, EntityFacadeImpl efi, EntityValueBase parentValue, Map parameters, Map result, Map parentPks) { EntityDefinition ed = parentValue.getEntityDefinition() // NOTE: keep a separate Map of parent PK values to pass down, can't just be current record's PK fields because // we allow other entities to be nested, and they may have nested records that depend ANY ancestor's PKs // this returns a clone or new Map, so we'll modify it freely Map sharedPkMap = parentValue.getPrimaryKeys() if (parentPks != null) { for (Map.Entry entry in parentPks.entrySet()) if (!sharedPkMap.containsKey(entry.key)) sharedPkMap.put(entry.key, entry.value) } Map nonFieldEntries = ed.entityInfo.cloneMapRemoveFields(parameters, null) if (nonFieldEntries.size() > 0) for (Map.Entry entry in nonFieldEntries.entrySet()) { Object relParmObj = entry.getValue() if (relParmObj == null) continue // if the entry is not a Map or List ignore it, we're only looking for those if (!(relParmObj instanceof Map) && !(relParmObj instanceof List)) continue String entryName = (String) entry.getKey() if (parentPks != null && parentPks.containsKey(entryName)) continue if (otherFieldsToSkip.contains(entryName)) continue EntityDefinition subEd = null Map pkMap = null RelationshipInfo relInfo = ed.getRelationshipInfo(entryName) if (relInfo != null) { if (!relInfo.mutable) { if (logger.isTraceEnabled()) logger.trace("In store entity auto service found key [${entryName}] which is a non-mutable relationship of [${ed.getFullEntityName()}], skipping") continue } subEd = relInfo.relatedEd // this is a relationship so add mapped key fields to the parentPks if any field names are different pkMap = new HashMap<>(sharedPkMap) pkMap.putAll(relInfo.getTargetParameterMap(sharedPkMap)) } else if (efi.isEntityDefined(entryName)) { subEd = efi.getEntityDefinition(entryName) pkMap = sharedPkMap } if (subEd == null) { // this happens a lot, extra stuff passed to the service call, so be quiet unless trace is on if (logger.isTraceEnabled()) logger.trace("In store entity auto service found key [${entryName}] which is not a field or relationship of [${ed.getFullEntityName()}] and is not a defined entity") continue } boolean isEntityValue = relParmObj instanceof EntityValue if (relParmObj instanceof Map && !isEntityValue) { Map relResults = new HashMap() storeRecursive(ecfi, efi, subEd, (Map) relParmObj, relResults, null, pkMap) result.put(entryName, relResults) } else if (relParmObj instanceof List) { List relResultList = [] for (Object relParmEntry in relParmObj) { Map relResults = new HashMap() if (relParmEntry instanceof Map) { storeRecursive(ecfi, efi, subEd, (Map) relParmEntry, relResults, null, pkMap) } else { logger.warn("In entity auto create for entity ${ed.getFullEntityName()} found list for sub-object ${entryName} with a non-Map entry: ${relParmEntry}") } relResultList.add(relResults) } result.put(entryName, relResultList) } else { if (isEntityValue) { if (logger.isTraceEnabled()) logger.trace("In entity auto store for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } else { logger.warn("In entity auto store for entity ${ed.getFullEntityName()} found sub-object ${entryName} which is not a Map or List: ${relParmObj}") } } } } /* This should only be called if statusId is a field of the entity and lookedUpValue != null */ protected static void checkStatus(EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames, EntityValue lookedUpValue, EntityFacadeImpl efi) { if (!parameters.containsKey("statusId")) return // populate the oldStatusId out if there is a service parameter for it, and before we do the set non-pk fields if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains("oldStatusId")) { result.put("oldStatusId", lookedUpValue.getNoCheckSimple("statusId")) } if (outParamNames == null || outParamNames.size() == 0 || outParamNames.contains("statusChanged")) { result.put("statusChanged", !(lookedUpValue.getNoCheckSimple("statusId") == parameters.get("statusId"))) // logger.warn("========= oldStatusId=${result.oldStatusId}, statusChanged=${result.statusChanged}, lookedUpValue.statusId=${lookedUpValue.statusId}, parameters.statusId=${parameters.statusId}, lookedUpValue=${lookedUpValue}") } // do the StatusValidChange check String parameterStatusId = (String) parameters.get("statusId") if (parameterStatusId) { String lookedUpStatusId = (String) lookedUpValue.getNoCheckSimple("statusId") if (lookedUpStatusId && !parameterStatusId.equals(lookedUpStatusId)) { ExecutionContext eci = efi.ecfi.getEci() // there was an old status, and in this call we are trying to change it, so do the StatusFlowTransition check // NOTE that we are using a cached list from a common pattern so it should generally be there instead of a count that wouldn't EntityList statusFlowTransitionList = efi.find("moqui.basic.StatusFlowTransition") .condition("statusId", lookedUpStatusId).condition("toStatusId", parameterStatusId).useCache(true).list() // check userPermissionId for each int statusFlowTransitionListSize = statusFlowTransitionList.size() int validTransitionCount = 0 List transitionCheckMessages = new LinkedList() for (int i = 0; i < statusFlowTransitionListSize; i++) { EntityValue statusFlowTransition = (EntityValue) statusFlowTransitionList.get(i) // NOTE: could check the old conditionExpression field here as well but there are issues with context definition (check here, in screens, etc), may have limited use anyway, may be better to remove String userPermissionId = (String) statusFlowTransition.getNoCheckSimple("userPermissionId") if (userPermissionId == null || userPermissionId.isEmpty()) { validTransitionCount++ } else { if (eci.userFacade.hasPermission(userPermissionId)) { validTransitionCount++ } else { transitionCheckMessages.add("User ${eci.userFacade.username} (${eci.userFacade.userId}) does not have permission ${userPermissionId} to change status in flow ${statusFlowTransition.statusFlowId} from ${lookedUpStatusId} to ${parameterStatusId} for ${ed.getFullEntityName()} ${lookedUpValue.getPrimaryKeys()}".toString()) } } } if (validTransitionCount == 0) { // uh-oh, no valid change... EntityValue lookedUpStatus = efi.find("moqui.basic.StatusItem") .condition("statusId", lookedUpStatusId).useCache(true).one() EntityValue parameterStatus = efi.find("moqui.basic.StatusItem") .condition("statusId", parameterStatusId).useCache(true).one() logger.warn("Status transition not allowed from ${lookedUpStatusId} to ${parameterStatusId} on entity ${ed.fullEntityName} with PK ${lookedUpValue.getPrimaryKeys()}\n${transitionCheckMessages.join('\n')}") throw new ServiceException(eci.resource.expand('StatusFlowTransitionNotFoundTemplate', "", [fullEntityName:eci.l10n.localize(ed.fullEntityName + '##EntityName'), lookedUpStatusId:lookedUpStatusId, parameterStatusId:parameterStatusId, lookedUpStatusName:lookedUpStatus?.getNoCheckSimple("description"), parameterStatusName:parameterStatus?.getNoCheckSimple("description")])) } } } // NOTE: nothing here to maintain the status history, that should be done with a custom service called by SECA rule or with audit log on field } static void updateEntity(ExecutionContextImpl eci, EntityDefinition ed, Map parameters, Map result, ArrayList outParamNames, EntityValue preLookedUpValue) { ExecutionContextFactoryImpl ecfi = eci.ecfi EntityFacadeImpl efi = eci.getEntityFacade() EntityValue lookedUpValue = preLookedUpValue ?: efi.makeValue(ed.getFullEntityName()).setFields(parameters, true, null, true) // this is much slower, and we don't need to do the query: sfi.getEcfi().getEntityFacade().find(ed.entityName).condition(parameters).useCache(false).one() if (lookedUpValue == null) throw new EntityValueNotFoundException("In entity-auto update service for entity [${ed.fullEntityName}] value not found, cannot update; using parameters [${parameters}]") if (parameters.containsKey("statusId") && ed.isField("statusId")) { // do the actual query so we'll have the current statusId Map pkParms = ed.getPrimaryKeys(parameters) lookedUpValue = preLookedUpValue ?: efi.find(ed.getFullEntityName()).condition(pkParms).useCache(false).one() if (lookedUpValue == null) throw new EntityValueNotFoundException("In entity-auto update service for entity [${ed.fullEntityName}] value not found, cannot update; using parameters [${parameters}]") checkStatus(ed, parameters, result, outParamNames, lookedUpValue, efi) } lookedUpValue.setFields(parameters, true, null, false) // logger.info("In auto updateEntity lookedUpValue final [${((EntityValueBase) lookedUpValue).getValueMap()}] for parameters [${parameters}]") lookedUpValue.update() storeRelated(ecfi, efi, (EntityValueBase) lookedUpValue, parameters, result, null) } static void deleteEntity(ExecutionContextImpl eci, EntityDefinition ed, Map parameters) { if (!ed.containsPrimaryKey(parameters)) throw new EntityException("Must specify all primary key fields to delete, can use wildcard of '*' in one or more PK fields to delete multiple records") Map newParms = new HashMap<>(parameters) boolean hasWildcard = false ArrayList fieldNameList = ed.getPkFieldNames() int size = fieldNameList.size() for (int i = 0; i < size; i++) { String fieldName = (String) fieldNameList.get(i) if ("*".equals(newParms.get(fieldName))) { hasWildcard = true newParms.remove(fieldName) } } if (hasWildcard) { // long deleted = eci.entityFacade.find(ed.fullEntityName).condition(newParms).deleteAll() // logger.info("Deleted ${deleted} ${ed.fullEntityName} records with PK wildcard: ${parameters}") } else { EntityValue ev = eci.entityFacade.makeValue(ed.fullEntityName).setFields(parameters, true, null, true) ev.delete() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/InlineServiceRunner.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.impl.service.ServiceDefinition; import org.moqui.impl.service.ServiceFacadeImpl; import org.moqui.impl.service.ServiceRunner; import org.moqui.service.ServiceException; import org.moqui.util.ContextStack; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; public class InlineServiceRunner implements ServiceRunner { protected static final Logger logger = LoggerFactory.getLogger(InlineServiceRunner.class); private ExecutionContextFactoryImpl ecfi = null; public InlineServiceRunner() { } @Override public ServiceRunner init(ServiceFacadeImpl sfi) { ecfi = sfi.ecfi; return this; } @Override @SuppressWarnings("unchecked") public Map runService(ServiceDefinition sd, Map parameters) { if (sd.xmlAction == null) throw new ServiceException("Service" + sd.serviceName + " run inline but has no actions"); ExecutionContextImpl ec = ecfi.getEci(); ContextStack cs = ec.contextStack; // push the entire context to isolate the context for the service call cs.pushContext(); try { // add the parameters to this service call; copy instead of pushing, faster with newer ContextStack cs.putAll(parameters); // we have an empty context so add the ec cs.put("ec", ec); // add a convenience Map to explicitly put results in Map autoResult = new HashMap<>(); cs.put("result", autoResult); Object result = sd.xmlAction.run(ec); if (result instanceof Map) { return (Map) result; } else { ScriptServiceRunner.combineResults(sd, autoResult, cs.getCombinedMap()); return autoResult; } /* ServiceCallSyncImpl logs this anyway, no point logging it here: } catch (Throwable t) { logger.error("Error running inline XML Actions in service [${sd.serviceName}]: ", t); throw t */ } finally { // pop the entire context to get back to where we were before isolating the context with pushContext cs.popContext(); } } @Override public void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/JavaServiceRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.ObjectUtilities import java.lang.reflect.Method import java.lang.reflect.Modifier import java.lang.reflect.InvocationTargetException import org.moqui.context.ExecutionContext import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.service.ServiceException import org.moqui.impl.service.ServiceRunner import org.moqui.util.ContextStack @CompileStatic public class JavaServiceRunner implements ServiceRunner { private ServiceFacadeImpl sfi = null private ExecutionContextFactoryImpl ecfi = null JavaServiceRunner() {} public ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi ecfi = sfi.ecfi return this } public Map runService(ServiceDefinition sd, Map parameters) { if (!sd.location || !sd.method) throw new ServiceException("Service [" + sd.serviceName + "] is missing location and/or method attributes and they are required for running a java service.") ExecutionContextImpl ec = ecfi.getEci() ContextStack cs = ec.contextStack Map result = (Map) null // push the entire context to isolate the context for the service call cs.pushContext() try { // we have an empty context so add the ec cs.put("ec", ec) // now add the parameters to this service call; copy instead of pushing, faster with newer ContextStack cs.putAll(parameters) Class c = (Class) ObjectUtilities.getClass(sd.location) if (c == null) c = Thread.currentThread().getContextClassLoader().loadClass(sd.location) Method m = c.getMethod(sd.method, ExecutionContext.class) if (Modifier.isStatic(m.getModifiers())) { result = (Map) m.invoke(null, ec) } else { result = (Map) m.invoke(c.newInstance(), ec) } } catch (ClassNotFoundException e) { throw new ServiceException("Could not find class for java service [${sd.serviceName}]", e) } catch (NoSuchMethodException e) { throw new ServiceException("Java Service [${sd.serviceName}] specified method [${sd.method}] that does not exist in class [${sd.location}]", e) } catch (SecurityException e) { throw new ServiceException("Access denied in service [${sd.serviceName}]", e) } catch (IllegalAccessException e) { throw new ServiceException("Method not accessible in service [${sd.serviceName}]", e) } catch (IllegalArgumentException e) { throw new ServiceException("Invalid parameter match in service [${sd.serviceName}]", e) } catch (NullPointerException e) { throw new ServiceException("Null pointer in service [${sd.serviceName}]", e) } catch (ExceptionInInitializerError e) { throw new ServiceException("Initialization failed for service [${sd.serviceName}]", e) } catch (InvocationTargetException e) { throw new ServiceException("Java method for service [${sd.serviceName}] threw an exception", e.getTargetException()) } catch (Throwable t) { throw new ServiceException("Error or unknown exception in service [${sd.serviceName}]", t) } finally { // pop the entire context to get back to where we were before isolating the context with pushContext cs.popContext() } return result } public void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/RemoteJsonRpcServiceRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner import groovy.json.JsonBuilder import groovy.json.JsonSlurper import groovy.transform.CompileStatic import org.moqui.context.ExecutionContext import org.moqui.util.WebUtilities import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.impl.service.ServiceRunner import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic public class RemoteJsonRpcServiceRunner implements ServiceRunner { protected final static Logger logger = LoggerFactory.getLogger(RemoteJsonRpcServiceRunner.class) protected ServiceFacadeImpl sfi = null RemoteJsonRpcServiceRunner() {} public ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; return this } public Map runService(ServiceDefinition sd, Map parameters) { ExecutionContext ec = sfi.ecfi.getExecutionContext() String location = sd.location String method = sd.method if (!location) throw new IllegalArgumentException("Cannot call remote service [${sd.serviceName}] because it has no location specified.") if (!method) throw new IllegalArgumentException("Cannot call remote service [${sd.serviceName}] because it has no method specified.") return runJsonService(sd.serviceNameNoHash, location, method, parameters, ec) } static Map runJsonService(String serviceName, String location, String method, Map parameters, ExecutionContext ec) { Map jsonRequestMap = [jsonrpc:"2.0", id:1, method:method, params:parameters] JsonBuilder jb = new JsonBuilder() jb.call(jsonRequestMap) String jsonRequest = jb.toString() // logger.warn("======== JSON-RPC remote service request to location [${location}]: ${jsonRequest}") String jsonResponse = WebUtilities.simpleHttpStringRequest(location, jsonRequest, "application/json") // logger.info("JSON-RPC remote service [${sd.getServiceName()}] request: ${httpPost.getRequestLine()}, ${httpPost.getAllHeaders()}, ${httpPost.getEntity().contentLength} bytes") // logger.warn("======== JSON-RPC remote service request entity [length:${httpPost.getEntity().contentLength}]: ${EntityUtils.toString(httpPost.getEntity())}") // logger.warn("======== JSON-RPC remote service response from location [${location}]: ${jsonResponse}") // parse and return the results JsonSlurper slurper = new JsonSlurper() Object jsonObj try { // logger.warn("========== JSON-RPC response: ${jsonResponse}") jsonObj = slurper.parseText(jsonResponse) } catch (Throwable t) { String errMsg = ec.resource.expand('Error parsing JSON-RPC response for service [${serviceName ?: method}]: ${t.toString()}','',[serviceName:serviceName, method:method, t:t]) logger.error(errMsg, t) ec.message.addError(errMsg) return null } if (jsonObj instanceof Map) { Map responseMap = (Map) jsonObj if (responseMap.error) { logger.error("JSON-RPC service [${serviceName ?: method}] returned an error: ${responseMap.error}") ec.message.addError((String) ((Map) responseMap.error)?.message ?: ec.resource.expand('JSON-RPC error with no message, code [${responseMap.error?.code}]','',[responseMap:responseMap])) return null } else { Object jr = responseMap.result if (jr instanceof Map) { return (Map) jr } else { return [response:jr] } } } else { String errMsg = ec.resource.expand('JSON-RPC response was not a object/Map for service [${serviceName ?: method}]: ${jsonObj}','',[serviceName:serviceName,method:method,jsonObj:jsonObj]) logger.error(errMsg) ec.message.addError(errMsg) return null } } public void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/RemoteRestServiceRunner.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.service.ServiceDefinition import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.impl.service.ServiceRunner import org.moqui.util.RestClient import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class RemoteRestServiceRunner implements ServiceRunner { protected final static Logger logger = LoggerFactory.getLogger(RemoteRestServiceRunner.class) protected ServiceFacadeImpl sfi = null RemoteRestServiceRunner() {} ServiceRunner init(ServiceFacadeImpl sfi) { this.sfi = sfi; return this } Map runService(ServiceDefinition sd, Map parameters) { ExecutionContextImpl eci = sfi.ecfi.getEci() String location = sd.location if (!location) throw new IllegalArgumentException("Location required to call remote service ${sd.serviceName}") String method = sd.method if (method == null || method.isEmpty()) { // default to verb IFF it is a valid method, otherwise default to POST if (RestClient.METHOD_SET.contains(sd.verb.toUpperCase())) method = sd.verb else method = "POST" } RestClient rc = eci.serviceFacade.rest().method(method) if (location.contains('${')) { // TODO: consider somehow removing parameters used in location from the parameters Map, // thinking of something like a ContextStack feature to watch for field names (keys) used, // and then remove those from parameters Map location = eci.resourceFacade.expand(location, null, parameters, false) } if (RestClient.GET.is(rc.getMethod())) { String parmsStr = RestClient.parametersMapToString(parameters) if (parmsStr != null && !parmsStr.isEmpty()) location = location + "?" + parmsStr rc.uri(location) } else { rc.uri(location) // NOTE: another option for parameters might be addBodyParameters(parameters), but a JSON body in the request is more common except for GET if (parameters != null && !parameters.isEmpty()) rc.jsonObject(parameters) } // logger.warn("remote-rest service call to ${rc.getUriString()}") // TODO/FUTURE: other options for remote authentication with headers/etc? a big limitation here, needs to be in parameters for now RestClient.RestResponse response = rc.call() if (response.statusCode < 200 || response.statusCode >= 300) { logger.warn("Remote REST service " + sd.serviceName + " error " + response.statusCode + " (" + response.reasonPhrase + ") in response to " + rc.method + " to " + rc.uriString + ", response text:\n" + response.text()) eci.messageFacade.addError("Remote service error ${response.statusCode}: ${response.reasonPhrase}") return null } Object responseObj = response.jsonObject() if (responseObj instanceof Map) return (Map) responseObj else return [response:responseObj] } void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/service/runner/ScriptServiceRunner.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.service.runner; import groovy.transform.CompileStatic; import org.moqui.impl.context.ExecutionContextFactoryImpl; import org.moqui.impl.context.ExecutionContextImpl; import org.moqui.impl.service.ServiceDefinition; import org.moqui.impl.service.ServiceFacadeImpl; import org.moqui.impl.service.ServiceRunner; import org.moqui.util.ContextStack; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.util.HashMap; import java.util.Map; @CompileStatic public class ScriptServiceRunner implements ServiceRunner { protected static final Logger logger = LoggerFactory.getLogger(ScriptServiceRunner.class); private ExecutionContextFactoryImpl ecfi = null; public ScriptServiceRunner() { } @Override public ServiceRunner init(ServiceFacadeImpl sfi) { ecfi = sfi.ecfi; return this; } @Override @SuppressWarnings("unchecked") public Map runService(ServiceDefinition sd, Map parameters) { ExecutionContextImpl ec = ecfi.getEci(); ContextStack cs = ec.contextStack; // push the entire context to isolate the context for the service call cs.pushContext(); try { // now add the parameters to this service call; copy instead of pushing, faster with newer ContextStack cs.putAll(parameters); // we have an empty context so add the ec cs.put("ec", ec); // add a convenience Map to explicitly put results in Map autoResult = new HashMap<>(); cs.put("result", autoResult); Object result = ec.getResource().script(sd.location, sd.method); if (result instanceof Map) { return (Map) result; } else { combineResults(sd, autoResult, cs.getCombinedMap()); return autoResult; } } finally { // pop the entire context to get back to where we were before isolating the context with pushContext cs.popContext(); } } static void combineResults(ServiceDefinition sd, Map autoResult, Map csMap) { // if there are fields in ec.context that match out-parameters but that aren't in the result, set them boolean autoResultUsed = autoResult.size() > 0; String[] outParameterNames = sd.outParameterNameArray; int outParameterNamesSize = outParameterNames.length; for (int i = 0; i < outParameterNamesSize; i++) { String outParameterName = outParameterNames[i]; Object outValue = csMap.get(outParameterName); if ((!autoResultUsed || !autoResult.containsKey(outParameterName)) && outValue != null) autoResult.put(outParameterName, outValue); } } @Override public void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/tools/H2ServerToolFactory.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.tools import groovy.transform.CompileStatic import org.h2.tools.Server import org.moqui.context.ExecutionContextFactory import org.moqui.context.ToolFactory import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.util.MNode import org.moqui.util.SystemBinding import org.slf4j.Logger import org.slf4j.LoggerFactory import java.lang.reflect.Field /** Initializes H2 Database server if any datasource is configured to use H2. */ @CompileStatic class H2ServerToolFactory implements ToolFactory { protected final static Logger logger = LoggerFactory.getLogger(H2ServerToolFactory.class) protected ExecutionContextFactoryImpl ecfi = null // for the embedded H2 server to allow remote access, used to stop server on destroy protected Server h2Server = null /** Default empty constructor */ H2ServerToolFactory() { } @Override void init(ExecutionContextFactory ecf) { this.ecfi = (ExecutionContextFactoryImpl) ecf for (MNode datasourceNode in ecfi.getConfXmlRoot().first("entity-facade").children("datasource")) { String dbConfName = datasourceNode.attribute("database-conf-name") if (!"h2".equals(dbConfName)) continue String argsString = datasourceNode.attribute("start-server-args") if (argsString == null || argsString.isEmpty()) { MNode dbNode = ecfi.confXmlRoot.first("database-list") .first({ MNode it -> "database".equals(it.name) && "h2".equals(it.attribute("name")) }) argsString = dbNode.attribute("default-start-server-args") } if (argsString) { String[] args = argsString.split(" ") for (int i = 0; i < args.length; i++) while (args[i].contains('${')) args[i] = SystemBinding.expand(args[i]) try { h2Server = Server.createTcpServer(args).start(); logger.info("Started H2 remote server on port ${h2Server.getPort()} status: ${h2Server.getStatus()}") logger.info("H2 args: ${args}") // only start one server break } catch (Throwable t) { logger.warn("Error starting H2 server (may already be running): ${t.toString()}") } } } } @Override Server getInstance(Object... parameters) { if (h2Server == null) throw new IllegalStateException("H2ServerToolFactory not initialized") return h2Server } @Override void postFacadeDestroy() { // NOTE: using shutdown() instead of stop() so it shuts down the DB and stops the TCP server if (h2Server != null) { h2Server.shutdown() System.out.println("Shut down H2 Server") } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/tools/JCSCacheToolFactory.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.tools import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.ToolFactory import org.slf4j.Logger import org.slf4j.LoggerFactory import javax.cache.CacheManager import javax.cache.Caching import javax.cache.spi.CachingProvider /** A factory for getting a JCS CacheManager; this has no compile time dependency on Commons JCS, just add the jar files * Current artifact: org.apache.commons:commons-jcs-jcache:2.0-beta-1 */ @CompileStatic class JCSCacheToolFactory implements ToolFactory { protected final static Logger logger = LoggerFactory.getLogger(JCSCacheToolFactory.class) final static String TOOL_NAME = "JCSCache" protected ExecutionContextFactory ecf = null protected CacheManager cacheManager = null /** Default empty constructor */ JCSCacheToolFactory() { } @Override String getName() { return TOOL_NAME } @Override void init(ExecutionContextFactory ecf) { } @Override void preFacadeInit(ExecutionContextFactory ecf) { this.ecf = ecf // always use the server caching provider, the client one always goes over a network interface and is slow ClassLoader cl = Thread.currentThread().getContextClassLoader() CachingProvider providerInternal = Caching.getCachingProvider("org.apache.commons.jcs.jcache.JCSCachingProvider", cl) URL cmUrl = cl.getResource("cache.ccf") logger.info("JCS config URI: ${cmUrl}") cacheManager = providerInternal.getCacheManager(cmUrl.toURI(), cl) logger.info("Initialized JCS CacheManager") } @Override CacheManager getInstance(Object... parameters) { if (cacheManager == null) throw new IllegalStateException("JCSCacheToolFactory not initialized") return cacheManager } @Override void destroy() { // do nothing? } ExecutionContextFactory getEcf() { return ecf } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/tools/JackrabbitRunToolFactory.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.tools import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.ToolFactory import org.slf4j.Logger import org.slf4j.LoggerFactory /** ToolFactory to initialize Apache Jackrabbit and get a java.lang.Process for the Jackrabbit instance */ @CompileStatic class JackrabbitRunToolFactory implements ToolFactory { protected final static Logger logger = LoggerFactory.getLogger(JackrabbitRunToolFactory.class) final static String TOOL_NAME = "JackrabbitRun" protected ExecutionContextFactory ecf = null /** Jackrabbit Process */ protected Process jackrabbitProcess = null /** Default empty constructor */ JackrabbitRunToolFactory() { } @Override String getName() { return TOOL_NAME } @Override void init(ExecutionContextFactory ecf) { } @Override void preFacadeInit(ExecutionContextFactory ecf) { this.ecf = ecf logger.info("Initializing Jackrabbit") Properties jackrabbitProperties = new Properties() URL jackrabbitProps = this.class.getClassLoader().getResource("jackrabbit_moqui.properties") if (jackrabbitProps != null) { InputStream is = jackrabbitProps.openStream(); jackrabbitProperties.load(is); is.close(); } String jackrabbitWorkingDir = System.getProperty("moqui.jackrabbit_working_dir") if (!jackrabbitWorkingDir) jackrabbitWorkingDir = jackrabbitProperties.getProperty("moqui.jackrabbit_working_dir") if (!jackrabbitWorkingDir) jackrabbitWorkingDir = "jackrabbit" String jackrabbitJar = System.getProperty("moqui.jackrabbit_jar") if (!jackrabbitJar) jackrabbitJar = jackrabbitProperties.getProperty("moqui.jackrabbit_jar") if (!jackrabbitJar) throw new IllegalArgumentException( "No moqui.jackrabbit_jar property found in jackrabbit_moqui.ini or in a system property (with: -Dmoqui.jackrabbit_jar=... on the command line)") String jackrabbitJarFullPath = ecf.runtimePath + "/" + jackrabbitWorkingDir + "/" + jackrabbitJar String jackrabbitConfFile = System.getProperty("moqui.jackrabbit_configuration_file") if (!jackrabbitConfFile) jackrabbitConfFile = jackrabbitProperties.getProperty("moqui.jackrabbit_configuration_file") if (!jackrabbitConfFile) jackrabbitConfFile = "repository.xml" String jackrabbitConfFileFullPath = ecf.runtimePath + "/" + jackrabbitWorkingDir + "/" + jackrabbitConfFile String jackrabbitPort = System.getProperty("moqui.jackrabbit_port") if (!jackrabbitPort) jackrabbitPort = jackrabbitProperties.getProperty("moqui.jackrabbit_port") if (!jackrabbitPort) jackrabbitPort = "8081" logger.info("Starting Jackrabbit") ProcessBuilder pb = new ProcessBuilder("java", "-jar", jackrabbitJarFullPath, "-p", jackrabbitPort, "-c", jackrabbitConfFileFullPath) pb.directory(new File(ecf.runtimePath + "/" + jackrabbitWorkingDir)) jackrabbitProcess = pb.start(); while(!hostAvailabilityCheck("localhost", jackrabbitPort.toInteger())) { sleep(500) } } @Override Process getInstance(Object... parameters) { if (jackrabbitProcess == null) throw new IllegalStateException("JackrabbitRunToolFactory not initialized") return jackrabbitProcess } @Override void destroy() { // Stop Jackrabbit process if (jackrabbitProcess != null) try { jackrabbitProcess.destroy() logger.info("Jackrabbit process destroyed") } catch (Throwable t) { logger.error("Error in JackRabbit process destroy", t) } } ExecutionContextFactory getEcf() { return ecf } private static boolean hostAvailabilityCheck(String hostname, int port) { Socket s = null try { s = new Socket(hostname, port) return true } catch (IOException e ) { /* ignore */ } finally { if (s != null) try { s.close() } catch (Exception e) {} } return false } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/tools/MCacheToolFactory.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.tools; import org.moqui.context.ExecutionContextFactory; import org.moqui.context.ToolFactory; import org.moqui.jcache.MCacheManager; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.cache.CacheManager; /** A factory for getting a MCacheManager */ public class MCacheToolFactory implements ToolFactory { protected final static Logger logger = LoggerFactory.getLogger(MCacheToolFactory.class); public final static String TOOL_NAME = "MCache"; protected ExecutionContextFactory ecf = null; private MCacheManager cacheManager = null; /** Default empty constructor */ public MCacheToolFactory() { } @Override public String getName() { return TOOL_NAME; } @Override public void init(ExecutionContextFactory ecf) { } @Override public void preFacadeInit(ExecutionContextFactory ecf) { this.ecf = ecf; cacheManager = MCacheManager.getMCacheManager(); } @Override public CacheManager getInstance(Object... parameters) { if (cacheManager == null) throw new IllegalStateException("MCacheToolFactory not initialized"); return cacheManager; } @Override public void destroy() { cacheManager.close(); } ExecutionContextFactory getEcf() { return ecf; } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/tools/SubEthaSmtpToolFactory.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.tools import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.ToolFactory import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.service.EmailEcaRule import org.moqui.impl.util.MoquiShiroRealm import org.slf4j.Logger import org.slf4j.LoggerFactory import org.subethamail.smtp.MessageContext import org.subethamail.smtp.MessageHandler import org.subethamail.smtp.MessageHandlerFactory import org.subethamail.smtp.RejectException import org.subethamail.smtp.TooMuchDataException import org.subethamail.smtp.auth.EasyAuthenticationHandlerFactory import org.subethamail.smtp.auth.LoginFailedException import org.subethamail.smtp.auth.UsernamePasswordValidator import org.subethamail.smtp.server.SMTPServer import jakarta.mail.Session import jakarta.mail.internet.MimeMessage /** * ToolFactory to initialize SubEtha SMTP server and provide access to an instance of org.subethamail.smtp.server.SMTPServer * * Includes static class EmecaMessageHandler that will generate Email ECA events for messages received. * * See the MOQUI_LOCAL EmailServer record in seed data for SMTP server parameters. */ @CompileStatic class SubEthaSmtpToolFactory implements ToolFactory { protected final static Logger logger = LoggerFactory.getLogger(SubEthaSmtpToolFactory.class) final static String TOOL_NAME = "SubEthaSmtp" final static String EMAIL_SERVER_ID = "MOQUI_LOCAL" protected ExecutionContextFactoryImpl ecfi = null protected SMTPServer smtpServer = null protected EmecaMessageHandlerFactory messageHandlerFactory = null protected EasyAuthenticationHandlerFactory authHandlerFactory = null protected Session session = Session.getInstance(System.getProperties()) /** Default empty constructor */ SubEthaSmtpToolFactory() { } @Override String getName() { return TOOL_NAME } @Override void init(ExecutionContextFactory ecf) { ecfi = (ExecutionContextFactoryImpl) ecf EntityValue emailServer = ecf.entity.find("moqui.basic.email.EmailServer").condition("emailServerId", EMAIL_SERVER_ID) .useCache(true).disableAuthz().one() if (emailServer == null) { logger.error("Not starting SubEtha SMTP server, could not find ${EMAIL_SERVER_ID} EmailServer record") return } int port = emailServer.smtpPort as int messageHandlerFactory = new EmecaMessageHandlerFactory(this) authHandlerFactory = new EasyAuthenticationHandlerFactory(new MoquiUsernamePasswordValidator(ecfi)) def serverBuilder = SMTPServer .port(port) .messageHandlerFactory(messageHandlerFactory) .authenticationHandlerFactory(authHandlerFactory) if (emailServer.smtpStartTls == "Y") { serverBuilder = serverBuilder.enableTLS() } smtpServer = serverBuilder.build() smtpServer.start() } @Override void preFacadeInit(ExecutionContextFactory ecf) { } @Override SMTPServer getInstance(Object... parameters) { if (smtpServer == null) throw new IllegalStateException("SubEthaSmtpToolFactory not initialized") return smtpServer } @Override void destroy() { if (smtpServer != null) try { smtpServer.stop() logger.info("SubEtha SMTP server stopped") } catch (Throwable t) { logger.error("Error in SubEtha SMTP server stop", t) } } static class EmecaMessageHandlerFactory implements MessageHandlerFactory { final SubEthaSmtpToolFactory toolFactory EmecaMessageHandlerFactory(SubEthaSmtpToolFactory toolFactory) { this.toolFactory = toolFactory } @Override MessageHandler create(MessageContext ctx) { return new EmecaMessageHandler(ctx, toolFactory) } } static class EmecaMessageHandler implements MessageHandler { final MessageContext ctx final SubEthaSmtpToolFactory toolFactory private String from = (String) null private List recipientList = new LinkedList<>() private MimeMessage mimeMessage = (MimeMessage) null EmecaMessageHandler(MessageContext ctx, SubEthaSmtpToolFactory toolFactory) { this.ctx = ctx; this.toolFactory = toolFactory; } @Override void from(String from) throws RejectException { this.from = from } @Override void recipient(String recipient) throws RejectException { recipientList.add(recipient) } @Override String data(InputStream data) throws RejectException, TooMuchDataException, IOException { // TODO: ever reject? perhaps of the from or no recipient addresses match a valid UserAccount.username? mimeMessage = new MimeMessage(toolFactory.session, data) return null } @Override void done() { // run EMECA rules toolFactory.ecfi.serviceFacade.runEmecaRules(mimeMessage, EMAIL_SERVER_ID) // always save EmailMessage record? better to let an EMECA rule do it... // logger.warn("Got email: ${mimeMessage.getSubject()} from ${from} recipients ${recipientList}\n${EmailEcaRule.makeBodyPartList(mimeMessage)}") } } static class MoquiUsernamePasswordValidator implements UsernamePasswordValidator { final ExecutionContextFactoryImpl ecf MoquiUsernamePasswordValidator(ExecutionContextFactoryImpl ecf) { this.ecf = ecf } @Override void login(String username, String password, MessageContext messageContext) throws LoginFailedException { EntityValue emailServer = ecf.entity.find("moqui.basic.email.EmailServer").condition("emailServerId", EMAIL_SERVER_ID) .useCache(true).disableAuthz().one() if (emailServer.mailUsername == username) { if (emailServer.mailPassword != password) throw new LoginFailedException("Password incorrect for email root user") } else { if (!MoquiShiroRealm.checkCredentials(username, password, ecf)) throw new LoginFailedException(ecf.resource.expand('Username ${username} and/or password incorrect','',[username:username])) } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/EdiHandler.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import groovy.transform.CompileStatic import org.moqui.context.ExecutionContext import org.moqui.util.CollectionUtilities import org.moqui.util.ObjectUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.regex.Pattern @CompileStatic class EdiHandler { protected final static Logger logger = LoggerFactory.getLogger(EdiHandler.class) protected ExecutionContext ec Character segmentTerminator = null Character elementSeparator = null Character componentDelimiter = null char escapeCharacter = '?' Character segmentSuffix = '\n' protected List> envelope = null protected List> body = null protected String bodyRootId = null protected Set knownSegmentIds = new HashSet<>() // FUTURE: load Bots record defs to validate input/output messages: Map recordDefs protected List segmentErrors = null EdiHandler(ExecutionContext ec) { this.ec = ec } EdiHandler setChars(Character segmentTerminator, Character elementSeparator, Character componentDelimiter, Character escapeCharacter) { this.segmentTerminator = segmentTerminator ?: ('~' as Character) this.elementSeparator = elementSeparator ?: ('*' as Character) this.componentDelimiter = componentDelimiter ?: (':' as Character) this.escapeCharacter = (escapeCharacter ?: '?') as char return this } // NOTE: common X12 componentDelimiter seems to include ':', '^', '<', '@', etc... EdiHandler setX12DefaultChars() { setChars('~' as char, '*' as char, ':' as char, '?' as char); return this } EdiHandler setITradeDefaultChars() { setChars('~' as char, '*' as char, '@' as char, '?' as char); return this } EdiHandler setEdifactDefaultChars() { setChars('\'' as char, '+' as char, ':' as char, '?' as char); return this } /** Run a Groovy script at location to get the nested List/Map file envelope structure (for X12: ISA, GS, and ST * segments). The QUERIES and SUBTRANSLATION entries can be removed, will be ignored. * * These are based on Bots Grammars (http://sourceforge.net/projects/bots/files/grammars/), converted from Python to * Groovy List/Map syntax (search/replace '{' to '[' and '}' to ']'), only include the structure List (script should * evaluate to or return just the structure List). */ EdiHandler loadEnvelope(String location) { envelope = (List>) ec.resource.script(location, null) extractSegmentIds(envelope) return this } /** Run a Groovy script at location to get the nested List/Map file structure. The segment(s) in the top-level List * should be referenced in the envelope structure, ie this structure will be used under the envelope structure. * * These are based on Bots Grammars (http://sourceforge.net/projects/bots/files/grammars/), converted from Python to * Groovy List/Map syntax (search/replace '{' to '[' and '}' to ']'), only include the structure List (script should * evaluate to or return just the structure List). */ EdiHandler loadBody(String location) { body = (List>) ec.resource.script(location, null) extractSegmentIds(body) bodyRootId = body[0].ID return this } protected void extractSegmentIds(List> defList) { for (Map defMap in defList) { knownSegmentIds.add((String) defMap.ID) if (defMap.LEVEL) extractSegmentIds((List>) defMap.LEVEL) } } /** Parse EDI text and return a Map containing a "elements" entry with a List of element values (each may be a String * or List) and an entry for each child segment where the key is the segment ID (generally 2 or 3 characters) * and the value is a List where each Map has this same Map structure. * * If no definition is found for a segment, all text the segment (in original form) is put in the "originalList" * (of type List) entry. This is used for partial parsing (envelope only) and then completing the parse with * the body structure loaded. */ Map> parseText(String ediText) { if (envelope == null) throw new IllegalArgumentException("Cannot parse EDI text, envelope must be loaded") if (!ediText) throw new IllegalArgumentException("No EDI text passed") segmentErrors = [] determineSeparators(ediText) List allSegmentStringList = Arrays.asList(ediText.split(getSegmentRegex())) if (allSegmentStringList.size() < 2) throw new IllegalArgumentException("No segments found in EDI text, using segment terminator [${segmentTerminator}]") Map> rootMap = [:] parseSegments(allSegmentStringList, 0, rootMap, envelope) return rootMap } List getSegmentErrors() { return segmentErrors } /** Generate EDI text from the same Map/List structure created from the parse. */ String generateText(Map> rootMap) { if (segmentTerminator == null) throw new IllegalArgumentException("No segment terminator specified") if (elementSeparator == null) throw new IllegalArgumentException("No element separator specified") if (componentDelimiter == null) throw new IllegalArgumentException("No component delimiter specified") StringBuilder sb = new StringBuilder() generateSegment(rootMap, sb) return sb.toString() } // X12 ISA segment is fixed width, pad fields to width of each element Map> segmentElementSizes = [ISA:[3, 2, 10, 2, 10, 2, 15, 2, 15, 6, 4, 1, 5, 9, 1, 1, 1]] char paddingChar = '\u00a0' Set noEscapeSegments = new HashSet<>(['ISA', 'UNA']) protected void generateSegment(Map> segmentMap, StringBuilder sb) { if (segmentMap.elements) { List elements = segmentMap.elements String segmentId = elements[0] List elementSizes = segmentElementSizes.get(segmentId) boolean noEscape = noEscapeSegments.contains(segmentId) // all segments should have elements, but root Map will not for (int i = 0; i < elements.size(); i++) { Object element = elements[i] Integer elementSize = elementSizes ? elementSizes[i] : null if (element instanceof List) { // composite element, add each component with component delimiter Iterator compIter = element.iterator() while (compIter.hasNext()) { Object curComp = compIter.next() if (curComp != null) sb.append(escape(ObjectUtilities.toPlainString(curComp))) if (compIter.hasNext()) sb.append(componentDelimiter) } } else { String elementString = ObjectUtilities.toPlainString(element) if (!noEscape) elementString = escape(elementString) sb.append(elementString) if (elementSize != null) { int curSize = elementString.size() while (curSize < elementSize) { sb.append(paddingChar); curSize++ } } } // append the element separator, if there is another element if (i < (elements.size() - 1)) sb.append(elementSeparator) } // append segment terminator sb.append(segmentTerminator) // if there is a segment suffix append that if (segmentSuffix) sb.append(segmentSuffix) } // generate child segments for (Map.Entry> entry in segmentMap.entrySet()) { if (!(entry.value instanceof List)) throw new IllegalArgumentException("Entry value is not a list: ${entry}") if (entry.key == "elements") continue if (entry.key == "originalList") { // also support output of literal child segments from originalList (full segment string except terminator) for (Object original in entry.value) sb.append(original).append(segmentTerminator) } else { // is a child segment for (Object childObj in entry.value) { if (childObj instanceof Map) { generateSegment((Map>) childObj, sb) } else { // should ALWAYS be a Map at this level, if not blow up throw new Exception("Expected Map for segment, got: ${childObj}") } } } } } protected void determineSeparators(String ediText) { // auto-detect segment/element/component chars (only if not set) // useful reference, see: https://mohsinkalam.wordpress.com/delimiters/ if (ediText.startsWith("ISA")) { // X12 message if (segmentTerminator == null) segmentTerminator = ediText.charAt(105) as Character if (elementSeparator == null) elementSeparator = ediText.charAt(3) as Character if (componentDelimiter == null) componentDelimiter = ediText.charAt(104) as Character } else if (ediText.startsWith("UNA")) { // EDIFACT message if (segmentTerminator == null) segmentTerminator = ediText.charAt(8) as Character if (elementSeparator == null) elementSeparator = ediText.charAt(4) as Character if (componentDelimiter == null) componentDelimiter = ediText.charAt(3) as Character } if (segmentTerminator == null) throw new IllegalArgumentException("No segment terminator specified or automatically determined") if (elementSeparator == null) throw new IllegalArgumentException("No element separator specified or automatically determined") if (componentDelimiter == null) throw new IllegalArgumentException("No component delimiter specified or automatically determined") } /** Internal recursive method for parsing segments */ protected int parseSegments(List allSegmentStringList, int segmentIndex, Map> currentSegment, List> levelDefList) { while (segmentIndex < allSegmentStringList.size()) { String segmentString = allSegmentStringList.get(segmentIndex).trim() String segmentId = getSegmentId(segmentString) if (segmentId == null) { // this shouldn't generally happen, but may if there is a terminating character at the end of the message (after the last segment separator) logger.info("No ID found for segment: ${segmentString}") segmentIndex++ continue } Map curDefMap = levelDefList.find({ it.ID == segmentId }) if (curDefMap != null) { // NOTE: incremented in parseSegment, returns next segment to process segmentIndex = parseSegment(allSegmentStringList, segmentIndex, currentSegment, curDefMap) } else if (!knownSegmentIds.contains(segmentId)) { if (body) { // TODO: improve this to handle multiple positions, somehow keep track of last tx set start segment (in X12 is ST; is first segment in body) int positionInTxSet = segmentIndex - 2 segmentErrors.add(new SegmentError(SegmentErrorType.NOT_DEFINED_IN_TX_SET, segmentIndex, positionInTxSet, segmentId, segmentString)) segmentIndex++ } else { // skip the segment; this is necessary to support partial parsing with envelope only segmentIndex++ // save the string in originalList List originalList = currentSegment.originalList if (originalList == null) { originalList = new ArrayList<>() currentSegment.originalList = originalList } originalList.add(segmentString) } } else { // if segmentId is not in the current levelDefList, return to check against parent return segmentIndex } } // this will only happen for the root segment, the final child (trailer segment) return segmentIndex } protected int parseSegment(List allSegmentStringList, int segmentIndex, Map> currentSegment, Map curDefMap) { String segmentString = allSegmentStringList.get(segmentIndex).trim() ArrayList elements = getSegmentElements(segmentString) String segmentId = elements[0] // if segmentId is in the current levelDefList add as child to current segment, increment index, recurse Map> newSegment = [elements:elements] as Map CollectionUtilities.addToListInMap(segmentId, newSegment, currentSegment) int nextSegmentIndex = segmentIndex + 1 // current segment has children (ie LEVEL entry)? then recurse otherwise just return to handle siblings/parents List> curDefLevel = (List>) curDefMap.LEVEL if (!curDefLevel && body && curDefMap.ID == bodyRootId) { // switch from envelope to body curDefLevel = (List>) body[0].LEVEL } if (curDefLevel) { return parseSegments(allSegmentStringList, nextSegmentIndex, newSegment, curDefLevel) } else { return nextSegmentIndex } } protected String getSegmentId(String segmentString) { int separatorIndex = segmentString.indexOf(elementSeparator as String) if (separatorIndex > 0) { return segmentString.substring(0, separatorIndex) } else if (segmentString.size() <= 3) { return segmentString } else { return null } } protected ArrayList getSegmentElements(String segmentString) { List originalElementList = Arrays.asList(segmentString.split(getElementRegex())) // split composite elements to components List, unescape elements ArrayList elements = new ArrayList<>(originalElementList.size()) for (String originalElement in originalElementList) { // change non-breaking white space to regular space before trim originalElement = originalElement.replaceAll("\\u00a0", " ") originalElement = originalElement.trim() if (originalElement.length() >= 3 && originalElement.contains(componentDelimiter as String)) { String[] componentArray = originalElement.split(getComponentRegex()) if (componentArray.length == 1) { elements.add(unescape(componentArray[0])) } else { ArrayList components = new ArrayList<>(componentArray.length) for (String component in componentArray) components.add(unescape(component.trim())) elements.add(components) } } else { elements.add(unescape(originalElement)) } } return elements } // regex strings have a non-capturing lookahead for the escape character (ie only separate if not escaped) protected String getSegmentRegex() { return "(? splitMessage(String rootHeaderId, String rootTrailerId, String ediText) { determineSeparators(ediText) List splitStringList = [] List allSegmentStringList = Arrays.asList(ediText.split(getSegmentRegex())) ArrayList curSplitList = null for (int i = 0; i < allSegmentStringList.size(); i++) { String segmentString = allSegmentStringList.get(i).trim() String segId = getSegmentId(segmentString) if (rootHeaderId && segId == rootHeaderId && curSplitList) { // hit a header without a footer, save what we have so far and start a new split splitStringList.add(combineSegments(curSplitList)) curSplitList = new ArrayList<>() curSplitList.add(segmentString) } else if (rootTrailerId && segId == rootTrailerId) { // hit a trailer, add it to the current split, save the split, clear the current split if (curSplitList == null) curSplitList = new ArrayList<>() curSplitList.add(segmentString) splitStringList.add(combineSegments(curSplitList)) curSplitList = null } else { if (curSplitList == null) curSplitList = new ArrayList<>() curSplitList.add(segmentString) } } return splitStringList } String combineSegments(ArrayList segmentStringList) { StringBuilder sb = new StringBuilder() for (int i = 0; i < segmentStringList.size(); i++) { sb.append(segmentStringList.get(i)).append(segmentTerminator) if (segmentSuffix) sb.append(segmentSuffix) } return sb.toString() } int countSegments(Map> ediMap) { int count = 0 if (ediMap.size() <= 1) return 0 for (Map.Entry> entry in ediMap) { if (entry.key == 'elements') continue for (Object itemObj in entry.value) { if (itemObj instanceof Map) { Map> itemMap = (Map>) itemObj if (itemMap.size() > 0) count++ if (itemMap.size() > 1) count += countSegments(itemMap) } } } return count } protected String escape(String original) { if (!original) return "" StringBuilder builder = new StringBuilder() for (int i = 0; i < original.length(); i++) { char c = original.charAt(i) if (needsEscape(c)) builder.append(escapeCharacter) builder.append(c) } return builder.toString() } protected boolean needsEscape(char c) { return (c == componentDelimiter || c == elementSeparator || c == escapeCharacter || c == segmentTerminator) } protected String unescape(String original) { StringBuilder builder = new StringBuilder() for (int i = 0; i < original.length(); i++) { char c = original.charAt(i) if (c == escapeCharacter) { // skip it and append the next character (next char might be escape character to don't just skip) i++ builder.append(original.charAt(i)) } else { builder.append(c) } } return builder.toString() } static enum SegmentErrorType { UNRECOGNIZED_SEGMENT_ID, UNEXPECTED, MANDATORY_MISSING, LOOP_OVER_MAX, EXCEEDS_MAXIMUM_USE, NOT_DEFINED_IN_TX_SET, NOT_IN_SEQUENCE, ELEMENT_ERRORS } /* X12 AK304 Element Error Codes 1 Unrecognized segment ID 2 Unexpected segment 3 Mandatory segment missing 4 Loop Occurs Over Maximum Times 5 Segment Exceeds Maximum Use 6 Segment Not in Defined Transaction Set 7 Segment Not in Proper Sequence 8 Segment Has Data Element Errors */ static Map segmentErrorX12Codes = [ (SegmentErrorType.UNRECOGNIZED_SEGMENT_ID):'1', (SegmentErrorType.UNEXPECTED):'2', (SegmentErrorType.MANDATORY_MISSING):'3', (SegmentErrorType.LOOP_OVER_MAX):'4', (SegmentErrorType.EXCEEDS_MAXIMUM_USE):'5', (SegmentErrorType.NOT_DEFINED_IN_TX_SET):'6', (SegmentErrorType.NOT_IN_SEQUENCE):'7',(SegmentErrorType.ELEMENT_ERRORS):'8'] static enum ElementErrorType { MANDATORY_MISSING, CONDITIONAL_REQUIRED_MISSING, TOO_MANY, TOO_SHORT, TOO_LONG, INVALID_CHAR, INVALID_CODE, INVALID_DATE, INVALID_TIME, EXCLUSION_VIOLATED } /* X12 AK403 Element Error Codes 1 Mandatory data element missing 2 Conditional required data element missing. 3 Too many data elements. 4 Data element too short. 5 Data element too long. 6 Invalid character in data element. 7 Invalid code value. 8 Invalid Date 9 Invalid Time 10 Exclusion Condition Violated */ static Map elementErrorX12Codes = [ (ElementErrorType.MANDATORY_MISSING):'1', (ElementErrorType.CONDITIONAL_REQUIRED_MISSING):'2', (ElementErrorType.TOO_MANY):'3', (ElementErrorType.TOO_SHORT):'4', (ElementErrorType.TOO_LONG):'5', (ElementErrorType.INVALID_CHAR):'6', (ElementErrorType.INVALID_CODE):'7', (ElementErrorType.INVALID_DATE):'8', (ElementErrorType.INVALID_TIME):'9', (ElementErrorType.EXCLUSION_VIOLATED):'10'] static class SegmentError { SegmentErrorType errorType int segmentIndex int positionInTxSet String segmentId String segmentText List elementErrors = [] SegmentError(SegmentErrorType errorType, int segmentIndex, int positionInTxSet, String segmentId, String segmentText) { this.errorType=errorType; this.segmentIndex = segmentIndex; this.positionInTxSet = positionInTxSet this.segmentId = segmentId; this.segmentText = segmentText } /** NOTE: used in mantle EdiServices.produce#X12FunctionalAck */ Map makeAk3() { Map AK3 = [:] AK3.elements = ['AK3', segmentId, positionInTxSet as String, '', segmentErrorX12Codes.get(errorType)] if (elementErrors) { List ak4List = [] AK3.AK4 = ak4List for (ElementError elementError in elementErrors) ak4List.add(elementError.makeAk4()) } return AK3 } } static class ElementError { ElementErrorType errorType int elementPosition Integer compositePosition String elementText ElementError(ElementErrorType errorType, int elementPosition, Integer compositePosition, String elementText) { this.errorType = errorType; this.elementPosition = elementPosition this.compositePosition = compositePosition; this.elementText = elementText } Map> makeAk4() { Object position = elementPosition as String if (compositePosition) position = [position, compositePosition as String] List elements = [ 'AK4', position, elementErrorX12Codes.get(errorType), elementText ] return [elements: elements] } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/ElFinderConnector.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import org.apache.commons.fileupload2.core.FileItem import org.moqui.context.ExecutionContext import org.moqui.resource.ResourceReference import org.slf4j.Logger import org.slf4j.LoggerFactory /** Used by the org.moqui.impl.ElFinderServices.run#Command service. */ class ElFinderConnector { protected final static Logger logger = LoggerFactory.getLogger(ElFinderConnector.class) ExecutionContext ec String volumeId String resourceRoot ElFinderConnector(ExecutionContext ec, String resourceRoot, String volumeId) { this.ec = ec this.resourceRoot = resourceRoot this.volumeId = volumeId } String hash(String str) { String hashed = str.bytes.encodeBase64().toString() hashed = hashed.replace("=", "") hashed = hashed.replace("+", "-") hashed = hashed.replace("/", "_") hashed = volumeId + hashed return hashed } static String unhash(String hashed) { if (!hashed) return "" // NOTE: assumes a volume ID prefix with 3 characters hashed = hashed.substring(3) hashed = hashed.replace(".", "=") hashed = hashed.replace("-", "+") hashed = hashed.replace("_", "/") return new String(hashed.decodeBase64()) } String getLocation(String hashed) { if (hashed) { String unhashedPath = unhash(hashed) if (unhashedPath == "/" || unhashedPath == "root") return resourceRoot if (unhashedPath.startsWith("/")) unhashedPath = unhashedPath.substring(1) return resourceRoot + (resourceRoot.endsWith("/") ? "" : "/") + unhashedPath } return resourceRoot } String getPathRelativeToRoot(String location) { String path = location.trim() if (!location.startsWith(resourceRoot)) { logger.warn("Location [${location}] does not start resourceRoot [${resourceRoot}]! Returning full location as relative path to root") return location } path = path.substring(resourceRoot.length()) if (path.endsWith("/")) path = path.substring(0, path.length() - 1) if (path.startsWith("/")) path = path.substring(1) if (path == "") return "root" return path } boolean isRoot(String location) { return getPathRelativeToRoot(location) == "root" } Map getLocationInfo(String location) { return getResourceInfo(ec.resource.getLocationReference(location)) } Map getResourceInfo(ResourceReference ref) { Map info = [:] boolean curRoot = isRoot(ref.getLocation()) info.name = curRoot ? (resourceRoot.endsWith("/") ? resourceRoot.substring(0, resourceRoot.length() - 1) : resourceRoot) : ref.getFileName() String location = ref.getLocation() String relativePath = getPathRelativeToRoot(location) info.hash = hash(relativePath) if (curRoot) { info.volumeid = volumeId } else { String parentPath = relativePath.contains("/") ? relativePath.substring(0, relativePath.lastIndexOf("/")) : "root" // logger.warn("======= phash: location=${location}, relativePath=${relativePath}, parentPath=${parentPath}") info.phash = hash(parentPath) } info.mime = curRoot || ref.isDirectory() ? "directory" : ref.getContentType() if (ref.supportsLastModified()) info.ts = ref.getLastModified() if (ref.supportsSize()) info.size = ref.getSize() info.dirs = hasChildDirectories(ref) ? 1 : 0 info.read = 1 info.write = ref.supportsWrite() ? 1 : 0 info.locked = 0 return info } static boolean hasChildDirectories(ResourceReference ref) { if (!ref.isDirectory()) return false List childList = ref.getDirectoryEntries() for (ResourceReference child in childList) if (child.isDirectory()) return true return false } List getFiles(String target, boolean tree) { List files = [] ResourceReference currentRef = ec.resource.getLocationReference(getLocation(target)) if (currentRef.isDirectory()) files.add(getResourceInfo(currentRef)) if (tree) files.addAll(getTree(resourceRoot, 0)) for (ResourceReference childRef in currentRef.getDirectoryEntries()) { Map resourceInfo = getResourceInfo(childRef) if (!files.contains(resourceInfo)) files.add(resourceInfo) } return files } List getTree(String location, int deep) { return getTree(ec.resource.getLocationReference(location), deep) } List getTree(ResourceReference ref, int deep) { List dirs = [] for (ResourceReference child in ref.getDirectoryEntries()) { if (child.isDirectory()) { Map info = getResourceInfo(child) dirs.add(info) if (deep > 0) dirs.addAll(getTree(child, deep - 1)) } } return dirs } List getParents(String location) { return getParents(ec.resource.getLocationReference(location)) } List getParents(ResourceReference ref) { List tree = [] ResourceReference dir = ref while (!isRoot(dir.getLocation())) { ResourceReference parent = dir.getParent() if (parent == null) { logger.warn("Got null parent for [${dir.getLocation()}], starting location [${ref.getLocation()}]") break } dir = parent tree.add(0, getResourceInfo(dir)) getTree(dir, 0).each { if (!tree.contains(it)) tree.add(it) } } return tree ?: [getResourceInfo(ref)] } Map getOptions(String target) { Map options = [seperator:"/", path:getLocation(target)] // if we ever have a direct URL to get a file: options.url = "http://localhost/files/..." options.disabled = [ 'tmb', 'size', 'dim', 'duplicate', 'paste', 'archive', 'extract', 'search', 'resize', 'netmount' ] return options } List delete(String location) { List deleted = [] ResourceReference ref = ec.resource.getLocationReference(location) if (!ref.isDirectory()) if (ref.delete()) deleted.add(hash(getPathRelativeToRoot(location))) else deleted.addAll(deleteDir(ref)) return deleted } List deleteDir(ResourceReference dir) { List deleted = [] for (ResourceReference child in dir.getDirectoryEntries()) { if (child.isDirectory()) { deleted.addAll(deleteDir(child)) } else { if (child.delete()) deleted.add(hash(getPathRelativeToRoot(child.getLocation()))) } } if (dir.delete()) deleted.add(hash(getPathRelativeToRoot(dir.getLocation()))) return deleted } void runCommand() { String cmd = ec.context.cmd String target = ec.context.target Map otherParameters = (Map) ec.context.otherParameters Map responseMap = [:] ec.context.responseMap = responseMap if (cmd == "file") { ec.context.fileLocation = getLocation(target) ec.context.fileInline = otherParameters.download != "1" } else if (cmd == "open") { boolean init = otherParameters.init == "1" boolean tree = otherParameters.tree == "1" if (init) { responseMap.api = "2.0" responseMap.netDrivers = [] if (!target) target = hash("root") } if (!target) { responseMap.clear() responseMap.error = "File not found" return } // TODO: make this a setting somewhere? leave out altogether? responseMap.uplMaxSize = "32M" responseMap.cwd = getLocationInfo(getLocation(target)) responseMap.files = getFiles(target, tree) responseMap.options = getOptions(target) } else if (cmd == "tree") { if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } String location = getLocation(target) List tree = [getLocationInfo(location)] tree.addAll(getTree(location, 0)) responseMap.tree = tree } else if (cmd == "parents") { // if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } responseMap.tree = getParents(getLocation(target)) } else if (cmd == "ls") { if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } List fileList = [] ResourceReference curDir = ec.resource.getLocationReference(getLocation(target)) for (ResourceReference child in curDir.getDirectoryEntries()) fileList.add(child.getFileName()) responseMap.list = fileList } else if (cmd == "mkdir") { String name = otherParameters.name if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } if (!name) { responseMap.clear(); responseMap.error = "No name specified for new directory"; return } String curLocation = getLocation(target) ResourceReference curDir = ec.resource.getLocationReference(curLocation) if (!curDir.supportsWrite()) { responseMap.clear(); responseMap.error = "Resource does not support write"; return } ResourceReference newRef = curDir.makeDirectory(name) responseMap.added = [getResourceInfo(newRef)] } else if (cmd == "mkfile") { String name = otherParameters.name if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } if (!name) { responseMap.clear(); responseMap.error = "No name specified for new file"; return } String curLocation = getLocation(target) ResourceReference curDir = ec.resource.getLocationReference(curLocation) if (!curDir.supportsWrite()) { responseMap.clear(); responseMap.error = "Resource does not support write"; return } ResourceReference newRef = curDir.makeFile(name) responseMap.added = [getResourceInfo(newRef)] } else if (cmd == "rm") { Object targetsObj = otherParameters.targets if (!targetsObj) targetsObj = otherParameters.'targets[]' List targets = targetsObj instanceof List ? targetsObj : [targetsObj as String] List removed = [] for (String curTarget in targets) { String rmLocation = getLocation(curTarget) logger.info("ElFinder rm ${rmLocation}") removed.addAll(delete(rmLocation)) } responseMap.removed = removed } else if (cmd == "rename") { String name = otherParameters.name if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } if (!name) { responseMap.clear(); responseMap.error = "No name specified for new directory"; return } String location = getLocation(target) String newLocation = location.substring(0, location.lastIndexOf("/") + 1) + name ResourceReference curRef = ec.resource.getLocationReference(location) curRef.move(newLocation) responseMap.added = [getLocationInfo(newLocation)] responseMap.removed = [target] } else if (cmd == "upload") { if (!target) { responseMap.clear(); responseMap.error = "errOpen"; return } String location = getLocation(target) // logger.info("ElFinder upload to ${location}, _fileUploadList: ${otherParameters._fileUploadList}") List added = [] for (FileItem item in otherParameters._fileUploadList) { logger.info("ElFinder upload ${item.getName()} to ${location}") ResourceReference newRef = ec.resource.getLocationReference("${location}/${item.getName()}") newRef.putStream(item.getInputStream()) added.add(getResourceInfo(newRef)) } responseMap.added = added } else if (cmd == "get") { String location = getLocation(target) ResourceReference curRef = ec.resource.getLocationReference(location) responseMap.content = curRef.getText() } else if (cmd == "put") { String content = otherParameters.content String location = getLocation(target) ResourceReference curRef = ec.resource.getLocationReference(location) curRef.putText(content) responseMap.changed = [getResourceInfo(curRef)] } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/ElasticSearchLogger.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import groovy.transform.CompileStatic import org.apache.logging.log4j.Level import org.apache.logging.log4j.core.LogEvent import org.apache.logging.log4j.util.ReadOnlyStringMap import org.moqui.BaseArtifactException import org.moqui.context.ArtifactExecutionInfo import org.moqui.impl.context.ElasticFacadeImpl import org.moqui.context.LogEventSubscriber import org.moqui.impl.context.ExecutionContextFactoryImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentLinkedQueue import java.util.concurrent.TimeUnit import java.util.concurrent.atomic.AtomicBoolean /** */ @CompileStatic class ElasticSearchLogger { protected final static Logger logger = LoggerFactory.getLogger(ElasticFacadeImpl.class) // TODO: make INDEX_NAME configurable somehow final static String INDEX_NAME = "moqui_logs" final static String DOC_TYPE = "LogMessage" final static int QUEUE_LIMIT = 16384 private ElasticFacadeImpl.ElasticClientImpl elasticClient = null protected ExecutionContextFactoryImpl ecfi = null protected ElasticSearchSubscriber subscriber = null private boolean initialized = false private boolean disabled = false final ConcurrentLinkedQueue logMessageQueue = new ConcurrentLinkedQueue<>() final AtomicBoolean flushRunning = new AtomicBoolean(false) ElasticSearchLogger(ElasticFacadeImpl.ElasticClientImpl elasticClient, ExecutionContextFactoryImpl ecfi) { this.elasticClient = elasticClient this.ecfi = ecfi if (ecfi.getToolFactory("ElasticSearchLogger") != null) { // used to check: elasticClient.esVersionUnder7 // logger.warn("ElasticClient ${elasticClient.clusterName} has version under 7.0, not starting ElasticSearchLogger") logger.warn("Found 'ElasticSearchLogger' ToolFactory from moqui-elasticsearch, not starting embedded ElasticSearchLogger") } else { init() } } void init() { // check for index exists, create with mapping for log doc if not try { boolean hasIndex = elasticClient.indexExists(INDEX_NAME) if (!hasIndex) elasticClient.createIndex(INDEX_NAME, DOC_TYPE, docMapping, (String) null) } catch (Exception e) { logger.error("Error checking and creating ${INDEX_NAME} ES index, not starting ElasticSearchLogger", e) return } LogMessageQueueFlush lmqf = new LogMessageQueueFlush(this) // running every 3 seconds (was originally 1), might be good to have configurable as a higher value better for less busy servers, lower for busier ecfi.scheduleAtFixedRate(lmqf, 10, 3) subscriber = new ElasticSearchSubscriber(this) ecfi.registerLogEventSubscriber(subscriber) initialized = true } void destroy() { disabled = true } boolean isInitialized() { return initialized } static class ElasticSearchSubscriber implements LogEventSubscriber { private final ElasticSearchLogger esLogger private final InetAddress localAddr = InetAddress.getLocalHost() ElasticSearchSubscriber(ElasticSearchLogger esLogger) { this.esLogger = esLogger } @Override void process(LogEvent event) { if (esLogger.disabled) return // NOTE: levels configurable in log4j2.xml but always exclude these if (Level.DEBUG.is(event.level) || Level.TRACE.is(event.level)) return // if too many messages in queue start ignoring, likely means ElasticSearch not responding or not fast enough if (esLogger.logMessageQueue.size() >= QUEUE_LIMIT) return Map msgMap = ['@timestamp':event.timeMillis, level:event.level.toString(), thread_name:event.threadName, thread_id:event.threadId, thread_priority:event.threadPriority, logger_name:event.loggerName, message:event.message?.formattedMessage, source_host:localAddr.hostName] as Map ReadOnlyStringMap contextData = event.contextData if (contextData != null && contextData.size() > 0) { Map mdcMap = new HashMap<>(contextData.toMap()) String userId = mdcMap.get("moqui_userId") if (userId != null) { msgMap.put("user_id", userId); mdcMap.remove("moqui_userId") } String visitorId = mdcMap.get("moqui_visitorId") if (visitorId != null) { msgMap.put("visitor_id", visitorId); mdcMap.remove("moqui_visitorId") } if (mdcMap.size() > 0) msgMap.put("mdc", mdcMap) // System.out.println("Cur user ${userId} ${visitorId}") } Throwable thrown = event.thrown if (thrown != null) msgMap.put("thrown", makeThrowableMap(thrown)) esLogger.logMessageQueue.add(msgMap) } static Map makeThrowableMap(Throwable thrown) { StackTraceElement[] stArray = thrown.stackTrace List stList = [] for (int i = 0; i < stArray.length; i++) { StackTraceElement ste = (StackTraceElement) stArray[i] stList.add("${ste.className}.${ste.methodName}(${ste.fileName}:${ste.lineNumber})".toString()) } Map thrownMap = [name:thrown.class.name, message:thrown.message, localizedMessage:thrown.localizedMessage, stackTrace:stList] as Map if (thrown instanceof BaseArtifactException) { BaseArtifactException bae = (BaseArtifactException) thrown Deque aeiList = bae.getArtifactStack() if (aeiList != null && aeiList.size() > 0) thrownMap.put("artifactStack", aeiList.collect({ it.toBasicString() })) } Throwable cause = thrown.cause if (cause != null) thrownMap.put("cause", makeThrowableMap(cause)) Throwable[] supArray = thrown.suppressed if (supArray != null && supArray.length > 0) { List supList = [] for (int i = 0; i < supArray.length; i++) { Throwable sup = supArray[i] supList.add(makeThrowableMap(sup)) } thrownMap.put("suppressed", supList) } return thrownMap } } static class LogMessageQueueFlush implements Runnable { final static int maxCreates = 50 final static int sameTsMaxCreates = 100 final ElasticSearchLogger esLogger LogMessageQueueFlush(ElasticSearchLogger esLogger) { this.esLogger = esLogger } @Override void run() { // if flag not false (expect param) return now, wait for next scheduled run if (!esLogger.flushRunning.compareAndSet(false, true)) return try { while (esLogger.logMessageQueue.size() > 0) { flushQueue() } } finally { esLogger.flushRunning.set(false) } } void flushQueue() { final ConcurrentLinkedQueue queue = esLogger.logMessageQueue ArrayList createList = new ArrayList<>(maxCreates) int createCount = 0 long lastTimestamp = 0 int sameTsCount = 0 while (createCount < sameTsMaxCreates) { Map message = queue.poll() if (message == null) break // add 1ms to timestamp if same as last so in search messages are in a better order; on busy servers this will require filtering by thread_id boolean sameTs = false try { long timestamp = message.get("@timestamp") as long if (timestamp == lastTimestamp) { sameTsCount++ timestamp += sameTsCount message.put("@timestamp", timestamp) sameTs = true } else { lastTimestamp = timestamp sameTsCount = 0 } } catch (Throwable t) { System.out.println("Error checking subsequent timestamp in ES log message: " + t.toString()) } // increment the count and add the message createCount++ createList.add(message) if (!sameTs && createCount >= maxCreates) break } int retryCount = 5 while (retryCount > 0) { int createListSize = createList.size() if (createListSize == 0) break try { // long startTime = System.currentTimeMillis() try { esLogger.elasticClient.bulkIndex(INDEX_NAME, DOC_TYPE, null, createList, false) } catch (Exception e) { System.out.println("Error logging to ElasticSearch: ${e.toString()}") } // System.out.println("Indexed ${createListSize} ElasticSearch log messages in ${System.currentTimeMillis() - startTime}ms") break } catch (Throwable t) { System.out.println("Error indexing ElasticSearch log messages, retrying (${retryCount}): ${t.toString()}") retryCount-- } } } } final static Map docMapping = [properties: ['@timestamp':[type:'date', format:'epoch_millis'], level:[type:'keyword'], thread_name:[type:'keyword'], thread_id:[type:'long'], thread_priority:[type:'long'], user_id:[type:'keyword'], visitor_id:[type:'keyword'], logger_name:[type:'text'], name:[type:'text'], message:[type:'text'], mdc:[type:'object'], thrown:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'], stackTrace:[type:'text'], artifactStack:[type:'text'], suppressed:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'], commonElementCount:[type:'long'], stackTrace:[type:'text']]], cause:[type:'object', properties:[name:[type:'text'], message:[type:'text'], localizedMessage:[type:'text'], commonElementCount:[type:'long'], stackTrace:[type:'text'], artifactStack:[type:'text']]] ]] ]] } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/JdbcExtractor.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util; import org.moqui.BaseException; import org.moqui.etl.SimpleEtl; import org.moqui.impl.context.ExecutionContextImpl; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.sql.XAConnection; import java.sql.Connection; import java.sql.ResultSet; import java.sql.ResultSetMetaData; import java.sql.Statement; import java.util.HashMap; import java.util.Map; public class JdbcExtractor implements SimpleEtl.Extractor { protected final static Logger logger = LoggerFactory.getLogger(JdbcExtractor.class); SimpleEtl etl = null; private ExecutionContextImpl eci; private String recordType, selectSql; private Map confMap; public JdbcExtractor(ExecutionContextImpl eci) { this.eci = eci; } public JdbcExtractor setSqlInfo(String recordType, String selectSql) { this.recordType = recordType; this.selectSql = selectSql; return this; } public JdbcExtractor setDbInfo(String dbType, String host, String port, String database, String user, String password) { confMap = new HashMap<>(); confMap.put("entity_ds_db_conf", dbType); confMap.put("entity_ds_host", host); confMap.put("entity_ds_port", port); confMap.put("entity_ds_database", database); confMap.put("entity_ds_user", user); confMap.put("entity_ds_password", password); return this; } public String getRecordType() { return recordType; } @Override public void extract(SimpleEtl etl) throws Exception { this.etl = etl; XAConnection xacon = null; Connection con = null; Statement stmt = null; ResultSet rs = null; try { xacon = eci.getEntityFacade().getConfConnection(confMap); con = xacon.getConnection(); stmt = con.createStatement(); rs = stmt.executeQuery(selectSql); ResultSetMetaData rsmd = rs.getMetaData(); int columnCount = rsmd.getColumnCount(); String[] columnNames = new String[columnCount]; for (int i = 1; i <= columnCount; i++) columnNames[i-1] = rsmd.getColumnName(i); while (rs.next()) { SimpleEtl.SimpleEntry curEntry = new SimpleEtl.SimpleEntry(recordType, new HashMap<>()); for (int i = 1; i <= columnCount; i++) curEntry.values.put(columnNames[i-1], rs.getObject(i)); try { etl.processEntry(curEntry); } catch (SimpleEtl.StopException e) { logger.warn("Got StopException", e); break; } } } catch (Exception e) { throw new BaseException("Error in SQL query " + selectSql, e); } finally { if (rs != null) rs.close(); if (stmt != null) stmt.close(); if (con != null) con.close(); if (xacon != null) xacon.close(); } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/MoquiShiroRealm.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import groovy.transform.CompileStatic import org.apache.shiro.authc.* import org.apache.shiro.authc.credential.CredentialsMatcher import org.apache.shiro.authz.Authorizer import org.apache.shiro.authz.Permission import org.apache.shiro.authz.UnauthorizedException import org.apache.shiro.realm.Realm import org.apache.shiro.subject.PrincipalCollection import org.apache.shiro.lang.util.SimpleByteSource import org.moqui.BaseArtifactException import org.moqui.Moqui import org.moqui.context.PasswordChangeRequiredException import org.moqui.context.SecondFactorRequiredException import org.moqui.entity.EntityCondition import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ArtifactExecutionFacadeImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.UserFacadeImpl import org.moqui.util.MNode import org.moqui.util.WebUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp @CompileStatic class MoquiShiroRealm implements Realm, Authorizer { protected final static Logger logger = LoggerFactory.getLogger(MoquiShiroRealm.class) protected ExecutionContextFactoryImpl ecfi protected String realmName = "moquiRealm" protected Class authenticationTokenClass = UsernamePasswordToken.class MoquiShiroRealm() { // with this sort of init we may only be able to get ecfi through static reference this.ecfi = (ExecutionContextFactoryImpl) Moqui.executionContextFactory } MoquiShiroRealm(ExecutionContextFactoryImpl ecfi) { this.ecfi = ecfi } void setName(String n) { realmName = n } @Override String getName() { return realmName } //Class getAuthenticationTokenClass() { return authenticationTokenClass } //void setAuthenticationTokenClass(Class atc) { authenticationTokenClass = atc } @Override boolean supports(AuthenticationToken token) { return token != null && authenticationTokenClass.isAssignableFrom(token.getClass()) } static EntityValue loginPrePassword(ExecutionContextImpl eci, String username) { EntityValue newUserAccount = eci.entity.find("moqui.security.UserAccount").condition("username", username) .useCache(true).disableAuthz().one() if (newUserAccount == null) { // case-insensitive lookup by username EntityCondition usernameCond = eci.entityFacade.getConditionFactory() .makeCondition("username", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase() newUserAccount = eci.entity.find("moqui.security.UserAccount").condition(usernameCond).disableAuthz().one() } if (newUserAccount == null) { // look at emailAddress if used instead, with case-insensitive lookup EntityCondition emailAddressCond = eci.entityFacade.getConditionFactory() .makeCondition("emailAddress", EntityCondition.ComparisonOperator.EQUALS, username).ignoreCase() newUserAccount = eci.entity.find("moqui.security.UserAccount").condition(emailAddressCond).disableAuthz().one() } // no account found? if (newUserAccount == null) throw new UnknownAccountException(eci.resource.expand('No account found for username ${username}','',[username:username])) // check for disabled account before checking password (otherwise even after disable could determine if // password is correct or not if ("Y".equals(newUserAccount.getNoCheckSimple("disabled"))) { if (newUserAccount.getNoCheckSimple("disabledDateTime") != null) { // account temporarily disabled (probably due to excessive attempts Integer disabledMinutes = eci.ecfi.confXmlRoot.first("user-facade").first("login").attribute("disable-minutes") as Integer ?: 30I Timestamp reEnableTime = new Timestamp(newUserAccount.getTimestamp("disabledDateTime").getTime() + (disabledMinutes.intValue()*60I*1000I)) if (reEnableTime > eci.user.nowTimestamp) { // only blow up if the re-enable time is not passed eci.service.sync().name("org.moqui.impl.UserServices.increment#UserAccountFailedLogins") .parameter("userId", newUserAccount.userId).requireNewTransaction(true).call() throw new ExcessiveAttemptsException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because account is disabled and will not be re-enabled until ${reEnableTime} [DISTMP].', '', [newUserAccount:newUserAccount, reEnableTime:reEnableTime])) } } else { // account permanently disabled eci.service.sync().name("org.moqui.impl.UserServices.increment#UserAccountFailedLogins") .parameters((Map) [userId:newUserAccount.userId]).requireNewTransaction(true).call() throw new DisabledAccountException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because account is disabled and is not schedule to be automatically re-enabled [DISPRM].', '', [newUserAccount:newUserAccount])) } } Timestamp terminateDate = (Timestamp) newUserAccount.getNoCheckSimple("terminateDate") if (terminateDate != (Timestamp) null && System.currentTimeMillis() > terminateDate.getTime()) { throw new DisabledAccountException(eci.resource.expand('User account ${newUserAccount.username} was terminated at ${ec.l10n.format(newUserAccount.terminateDate, null)} [TERM].', '', [newUserAccount:newUserAccount])) } return newUserAccount } static void loginPostPassword(ExecutionContextImpl eci, EntityValue newUserAccount, AuthenticationToken token) { // the password did match, but check a few additional things String userId = newUserAccount.getNoCheckSimple("userId") // check for require password change if ("Y".equals(newUserAccount.getNoCheckSimple("requirePasswordChange"))) { // NOTE: don't call incrementUserAccountFailedLogins here (don't need compounding reasons to stop access) throw new PasswordChangeRequiredException(eci.resource.expand('Authenticate failed for user [${newUserAccount.username}] because account requires password change [PWDCHG].','',[newUserAccount:newUserAccount])) } // check time since password was last changed, if it has been too long (user-facade.password.@change-weeks default 12) then fail if (newUserAccount.getNoCheckSimple("passwordSetDate") != null) { int changeWeeks = (eci.ecfi.confXmlRoot.first("user-facade").first("password").attribute("change-weeks") ?: 12) as int if (changeWeeks > 0) { int wksSinceChange = ((eci.user.nowTimestamp.time - newUserAccount.getTimestamp("passwordSetDate").time) / (7*24*60*60*1000)).intValue() if (wksSinceChange > changeWeeks) { // NOTE: don't call incrementUserAccountFailedLogins here (don't need compounding reasons to stop access) throw new ExpiredCredentialsException(eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because password was changed ${wksSinceChange} weeks ago and must be changed every ${changeWeeks} weeks [PWDTIM].', '', [newUserAccount:newUserAccount, wksSinceChange:wksSinceChange, changeWeeks:changeWeeks])) } } } // check if the user requires an additional authentication factor step // do this after checking for require password change and expired password for better user experience if (!(token instanceof ForceLoginToken)) { boolean secondReqd = eci.ecfi.serviceFacade.sync().name("org.moqui.impl.UserServices.get#UserAuthcFactorRequired") .parameter("userId", userId).disableAuthz().call()?.secondFactorRequired ?: false // if the user requires authentication, throw a SecondFactorRequiredException so that UserFacadeImpl.groovy can catch the error and perform the appropriate action. if (secondReqd) { throw new SecondFactorRequiredException(eci.ecfi.resource.expand('Authentication code required for user ${username}', '',[username:newUserAccount.getNoCheckSimple("username")])) } } // check ipAllowed if on UserAccount or any UserGroup a member of String clientIp = eci.userFacade.getClientIp() if (clientIp == null || clientIp.isEmpty()) { if (eci.web != null) logger.warn("Web login with no client IP for userId ${newUserAccount.userId}, not checking ipAllowed") } else { if (clientIp.contains(":")) { logger.warn("Web login with IPv6 client IP ${clientIp} for userId ${newUserAccount.userId}, not checking ipAllowed") } else { ArrayList ipAllowedList = new ArrayList<>() String uaIpAllowed = newUserAccount.getNoCheckSimple("ipAllowed") if (uaIpAllowed != null && !uaIpAllowed.isEmpty()) ipAllowedList.add(uaIpAllowed) EntityList ugmList = eci.entityFacade.find("moqui.security.UserGroupMember") .condition("userId", newUserAccount.getNoCheckSimple("userId")) .disableAuthz().useCache(true).list() .filterByDate(null, null, eci.userFacade.nowTimestamp) ArrayList userGroupIdList = new ArrayList<>() for (EntityValue ugm in ugmList) userGroupIdList.add((String) ugm.get("userGroupId")) userGroupIdList.add("ALL_USERS") EntityList ugList = eci.entityFacade.find("moqui.security.UserGroup") .condition("ipAllowed", EntityCondition.IS_NOT_NULL, null) .condition("userGroupId", EntityCondition.IN, userGroupIdList).disableAuthz().useCache(false).list() for (EntityValue ug in ugList) ipAllowedList.add((String) ug.getNoCheckSimple("ipAllowed")) int ipAllowedListSize = ipAllowedList.size() if (ipAllowedListSize > 0) { boolean anyMatches = false for (int i = 0; i < ipAllowedListSize; i++) { String pattern = (String) ipAllowedList.get(i) if (WebUtilities.ip4Matches(pattern, clientIp)) { anyMatches = true break } } if (!anyMatches) throw new AccountException( eci.resource.expand('Authenticate failed for user ${newUserAccount.username} because client IP ${clientIp} is not in allowed list for user or group.', '', [newUserAccount:newUserAccount, clientIp:clientIp])) } } } // no more auth failures? record the various account state updates, hasLoggedOut=N if (newUserAccount.getNoCheckSimple("successiveFailedLogins") || "Y".equals(newUserAccount.getNoCheckSimple("disabled")) || newUserAccount.getNoCheckSimple("disabledDateTime") != null || "Y".equals(newUserAccount.getNoCheckSimple("hasLoggedOut"))) { try { eci.service.sync().name("update", "moqui.security.UserAccount") .parameters([userId:newUserAccount.userId, successiveFailedLogins:0, disabled:"N", disabledDateTime:null, hasLoggedOut:"N"]) .disableAuthz().call() } catch (Exception e) { logger.warn("Error resetting UserAccount login status", e) } } // update visit if no user in visit yet String visitId = eci.userFacade.getVisitId() EntityValue visit = eci.entityFacade.find("moqui.server.Visit").condition("visitId", visitId).disableAuthz().one() if (visit != null) { if (!visit.getNoCheckSimple("userId")) { eci.service.sync().name("update", "moqui.server.Visit").parameter("visitId", visit.visitId) .parameter("userId", newUserAccount.userId).disableAuthz().call() } if (!visit.getNoCheckSimple("clientIpCountryGeoId") && !visit.getNoCheckSimple("clientIpTimeZone")) { MNode ssNode = eci.ecfi.confXmlRoot.first("server-stats") if (ssNode.attribute("visit-ip-info-on-login") != "false") { eci.service.async().name("org.moqui.impl.ServerServices.get#VisitClientIpData") .parameter("visitId", visit.visitId).call() } } } } static void loginSaveHistory(ExecutionContextImpl eci, String userId, String passwordUsed, boolean successful) { // track the UserLoginHistory, whether the above succeeded or failed (ie even if an exception was thrown) if (!eci.getSkipStats()) { MNode loginNode = eci.ecfi.confXmlRoot.first("user-facade").first("login") if (userId != null && loginNode.attribute("history-store") != "false") { Timestamp fromDate = eci.getUser().getNowTimestamp() // look for login history in the last minute, if any found don't create UserLoginHistory Timestamp recentDate = new Timestamp(fromDate.getTime() - 60000) Map ulhContext = [userId:userId, fromDate:fromDate, visitId:eci.user.visitId, successfulLogin:(successful?"Y":"N")] as Map if (!successful && loginNode.attribute("history-incorrect-password") != "false") ulhContext.passwordUsed = passwordUsed eci.runInWorkerThread({ try { long recentUlh = eci.entity.find("moqui.security.UserLoginHistory").condition("userId", userId) .condition("fromDate", EntityCondition.GREATER_THAN, recentDate).disableAuthz().count() if (recentUlh == 0) { eci.ecfi.serviceFacade.sync().name("create", "moqui.security.UserLoginHistory") .parameters(ulhContext).disableAuthz().call() } else { if (logger.isDebugEnabled()) logger.debug("Not creating UserLoginHistory, found existing record for userId ${userId} and more recent than ${recentDate}") } } catch (Exception ee) { // this blows up sometimes on MySQL, may in other cases, and is only so important so log a warning but don't rethrow logger.warn("UserLoginHistory create failed: ${ee.toString()}") } }) } } } @Override AuthenticationInfo getAuthenticationInfo(AuthenticationToken token) throws AuthenticationException { ExecutionContextImpl eci = ecfi.getEci() String username = token.principal as String String userId = null boolean successful = false boolean isForceLogin = token instanceof ForceLoginToken SaltedAuthenticationInfo info = null try { EntityValue newUserAccount = loginPrePassword(eci, username) userId = newUserAccount.getString("userId") // create the salted SimpleAuthenticationInfo object String salt = (newUserAccount.passwordSalt ?: '') as String SimpleByteSource saltBs = new SimpleByteSource(salt) info = new SimpleAuthenticationInfo(username, newUserAccount.currentPassword, saltBs, realmName) if (!isForceLogin) { // check the password (credentials for this case) CredentialsMatcher cm = ecfi.getCredentialsMatcher((String) newUserAccount.passwordHashType, "Y".equals(newUserAccount.passwordBase64)) if (!cm.doCredentialsMatch(token, info)) { // if failed on password, increment in new transaction to make sure it sticks ecfi.serviceFacade.sync().name("org.moqui.impl.UserServices.increment#UserAccountFailedLogins") .parameters((Map) [userId:newUserAccount.userId]).requireNewTransaction(true).call() throw new IncorrectCredentialsException(ecfi.resource.expand('Password incorrect for username ${username}','',[username:username])) } } // credentials matched loginPostPassword(eci, newUserAccount, token) // at this point the user is successfully authenticated successful = true } finally { boolean saveHistory = true if (isForceLogin) { ForceLoginToken flt = (ForceLoginToken) token saveHistory = flt.saveHistory } if (saveHistory) loginSaveHistory(eci, userId, token.credentials as String, successful) } return info } static boolean checkCredentials(String username, String password, ExecutionContextFactoryImpl ecfi) { EntityValue newUserAccount = ecfi.entity.find("moqui.security.UserAccount").condition("username", username) .useCache(true).disableAuthz().one() String salt = (newUserAccount.passwordSalt ?: '') as String SimpleByteSource saltBs = new SimpleByteSource(salt) SimpleAuthenticationInfo info = new SimpleAuthenticationInfo(username, newUserAccount.currentPassword, saltBs, "moquiRealm") CredentialsMatcher cm = ecfi.getCredentialsMatcher((String) newUserAccount.passwordHashType, "Y".equals(newUserAccount.passwordBase64)) UsernamePasswordToken token = new UsernamePasswordToken(username, password) return cm.doCredentialsMatch(token, info) } static class ForceLoginToken extends UsernamePasswordToken { boolean saveHistory = true ForceLoginToken(final String username, final boolean rememberMe) { super (username, 'force', rememberMe) } ForceLoginToken(final String username, final boolean rememberMe, final boolean saveHistory) { super (username, 'force', rememberMe) this.saveHistory = saveHistory } } // ========== Authorization Methods ========== /** * @param principalCollection The principal (user) * @param resourceAccess Formatted as: "${typeEnumId}:${actionEnumId}:${name}" * @return boolean true if principal is permitted to access the resource, false otherwise. */ boolean isPermitted(PrincipalCollection principalCollection, String resourceAccess) { // String username = (String) principalCollection.primaryPrincipal // TODO: if we want to support other users than the current need to look them up here return ArtifactExecutionFacadeImpl.isPermitted(resourceAccess, ecfi.getEci()) } boolean[] isPermitted(PrincipalCollection principalCollection, String... resourceAccesses) { boolean[] resultArray = new boolean[resourceAccesses.size()] int i = 0 for (String resourceAccess in resourceAccesses) { resultArray[i] = this.isPermitted(principalCollection, resourceAccess) i++ } return resultArray } boolean isPermittedAll(PrincipalCollection principalCollection, String... resourceAccesses) { for (String resourceAccess in resourceAccesses) if (!this.isPermitted(principalCollection, resourceAccess)) return false return true } boolean isPermitted(PrincipalCollection principalCollection, Permission permission) { throw new BaseArtifactException("Authorization of Permission through Shiro not yet supported") } boolean[] isPermitted(PrincipalCollection principalCollection, List permissions) { throw new BaseArtifactException("Authorization of Permission through Shiro not yet supported") } boolean isPermittedAll(PrincipalCollection principalCollection, Collection permissions) { throw new BaseArtifactException("Authorization of Permission through Shiro not yet supported") } void checkPermission(PrincipalCollection principalCollection, Permission permission) { // TODO how to handle the permission interface? // see: http://www.jarvana.com/jarvana/view/org/apache/shiro/shiro-core/1.1.0/shiro-core-1.1.0-javadoc.jar!/org/apache/shiro/authz/Permission.html // also look at DomainPermission, can extend for Moqui artifacts // this.checkPermission(principalCollection, permission.?) throw new BaseArtifactException("Authorization of Permission through Shiro not yet supported") } void checkPermission(PrincipalCollection principalCollection, String permission) { String username = (String) principalCollection.primaryPrincipal if (UserFacadeImpl.hasPermission(username, permission, null, ecfi.getEci())) { throw new UnauthorizedException(ecfi.resource.expand('User ${username} does not have permission ${permission}','',[username:username,permission:permission])) } } void checkPermissions(PrincipalCollection principalCollection, String... strings) { for (String permission in strings) checkPermission(principalCollection, permission) } void checkPermissions(PrincipalCollection principalCollection, Collection permissions) { for (Permission permission in permissions) checkPermission(principalCollection, permission) } boolean hasRole(PrincipalCollection principalCollection, String roleName) { String username = (String) principalCollection.primaryPrincipal return UserFacadeImpl.isInGroup(username, roleName, null, ecfi.getEci()) } boolean[] hasRoles(PrincipalCollection principalCollection, List roleNames) { boolean[] resultArray = new boolean[roleNames.size()] int i = 0 for (String roleName in roleNames) { resultArray[i] = this.hasRole(principalCollection, roleName); i++ } return resultArray } boolean hasAllRoles(PrincipalCollection principalCollection, Collection roleNames) { for (String roleName in roleNames) { if (!this.hasRole(principalCollection, roleName)) return false } return true } void checkRole(PrincipalCollection principalCollection, String roleName) { if (!this.hasRole(principalCollection, roleName)) throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName])) } void checkRoles(PrincipalCollection principalCollection, Collection roleNames) { for (String roleName in roleNames) { if (!this.hasRole(principalCollection, roleName)) throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName])) } } void checkRoles(PrincipalCollection principalCollection, String... roleNames) { for (String roleName in roleNames) { if (!this.hasRole(principalCollection, roleName)) throw new UnauthorizedException(ecfi.resource.expand('User ${principalCollection.primaryPrincipal} is not in role ${roleName}','',[principalCollection:principalCollection,roleName:roleName])) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/RestSchemaUtil.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import groovy.json.JsonBuilder import groovy.transform.CompileStatic import org.moqui.entity.EntityList import org.moqui.entity.EntityNotFoundException import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.WebFacadeImpl import org.moqui.impl.entity.EntityDefinition import org.moqui.impl.entity.EntityDefinition.MasterDefinition import org.moqui.impl.entity.EntityDefinition.MasterDetail import org.moqui.impl.entity.EntityFacadeImpl import org.moqui.impl.entity.EntityJavaUtil.RelationshipInfo import org.moqui.impl.entity.FieldInfo import org.moqui.impl.service.RestApi import org.moqui.impl.service.ServiceDefinition import org.moqui.service.ServiceException import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import org.yaml.snakeyaml.DumperOptions import org.yaml.snakeyaml.Yaml import jakarta.servlet.http.HttpServletResponse @CompileStatic class RestSchemaUtil { protected final static Logger logger = LoggerFactory.getLogger(RestSchemaUtil.class) static final Map fieldTypeJsonMap = [ "id":"string", "id-long":"string", "text-indicator":"string", "text-short":"string", "text-medium":"string", "text-intermediate":"string", "text-long":"string", "text-very-long":"string", "date-time":"string", "time":"string", "date":"string", "number-integer":"number", "number-float":"number", "number-decimal":"number", "currency-amount":"number", "currency-precise":"number", "binary-very-long":"string" ] // NOTE: binary-very-long may need hyper-schema stuff static final Map fieldTypeJsonFormatMap = [ "date-time":"date-time", "date":"date", "number-integer":"int64", "number-float":"double", "number-decimal":"", "currency-amount":"", "currency-precise":"", "binary-very-long":"" ] static final Map jsonPaginationProperties = [pageIndex:[type:'number', format:'int32', description:'Page number to return, starting with zero'], pageSize:[type:'number', format:'int32', description:'Number of records per page (default 100)'], orderByField:[type:'string', description:'Field name to order by (or comma separated names)'], pageNoLimit:[type:'string', description:'If true don\'t limit page size (no pagination)'], dependentLevels:[type:'number', format:'int32', description:'Levels of dependent child records to include'] ] static final Map jsonPaginationParameters = [type:'object', properties: jsonPaginationProperties] static final Map jsonCountParameters = [type:'object', properties: [count:[type:'number', format:'int64', description:'Count of results']]] static final List swaggerPaginationParameters = [[name:'pageIndex', in:'query', required:false, type:'number', format:'int32', description:'Page number to return, starting with zero'], [name:'pageSize', in:'query', required:false, type:'number', format:'int32', description:'Number of records per page (default 100)'], [name:'orderByField', in:'query', required:false, type:'string', description:'Field name to order by (or comma separated names)'], [name:'pageNoLimit', in:'query', required:false, type:'string', description:'If true don\'t limit page size (no pagination)'], [name:'dependentLevels', in:'query', required:false, type:'number', format:'int32', description:'Levels of dependent child records to include'] ] as List static final Map ramlPaginationParameters = [ pageIndex:[type:'number', description:'Page number to return, starting with zero'], pageSize:[type:'number', default:100, description:'Number of records per page (default 100)'], orderByField:[type:'string', description:'Field name to order by (or comma separated names)'], pageNoLimit:[type:'string', description:'If true don\'t limit page size (no pagination)'], dependentLevels:[type:'number', description:'Levels of dependent child records to include'] ] static final Map fieldTypeRamlMap = [ "id":"string", "id-long":"string", "text-indicator":"string", "text-short":"string", "text-medium":"string", "text-intermediate":"string", "text-long":"string", "text-very-long":"string", "date-time":"date", "time":"string", "date":"string", "number-integer":"integer", "number-float":"number", "number-decimal":"number", "currency-amount":"number", "currency-precise":"number", "binary-very-long":"string" ] // NOTE: binary-very-long may need hyper-schema stuff // =============================================== // ========== Entity Definition Methods ========== // =============================================== static List getFieldEnums(EntityDefinition ed, FieldInfo fi) { // populate enum values for Enumeration and StatusItem // find first relationship that has this field as the only key map and is not a many relationship RelationshipInfo oneRelInfo = null List allRelInfoList = ed.getRelationshipsInfo(false) for (RelationshipInfo relInfo in allRelInfoList) { Map km = relInfo.keyMap if (km.size() == 1 && km.containsKey(fi.name) && relInfo.type == "one" && relInfo.relNode.attribute("is-auto-reverse") != "true") { oneRelInfo = relInfo break; } } if (oneRelInfo != null && oneRelInfo.title != null) { if (oneRelInfo.relatedEd.getFullEntityName() == 'moqui.basic.Enumeration') { EntityList enumList = ed.efi.find("moqui.basic.Enumeration").condition("enumTypeId", oneRelInfo.title) .orderBy("sequenceNum,enumId").disableAuthz().list() if (enumList) { List enumIdList = [] for (EntityValue ev in enumList) enumIdList.add((String) ev.enumId) return enumIdList } } else if (oneRelInfo.relatedEd.getFullEntityName() == 'moqui.basic.StatusItem') { EntityList statusList = ed.efi.find("moqui.basic.StatusItem").condition("statusTypeId", oneRelInfo.title) .orderBy("sequenceNum,statusId").disableAuthz().list() if (statusList) { List statusIdList = [] for (EntityValue ev in statusList) statusIdList.add((String) ev.statusId) return statusIdList } } } return null } static Map getJsonSchema(EntityDefinition ed, boolean pkOnly, boolean standalone, Map definitionsMap, String schemaUri, String linkPrefix, String schemaLinkPrefix, boolean nestRelationships, String masterName, MasterDetail masterDetail) { String name = ed.getShortOrFullEntityName() String prettyName = ed.getPrettyName(null, null) String refName = name if (masterName) { refName = "${name}.${masterName}".toString() prettyName = "${prettyName} (Master: ${masterName})".toString() } if (pkOnly) { name = name + ".PK" refName = refName + ".PK" } Map properties = [:] properties.put('_entity', [type:'string', default:name]) // NOTE: Swagger validation doesn't like the id field, was: id:refName Map schema = [title:prettyName, type:'object', properties:properties] as Map // add all fields ArrayList allFields = pkOnly ? ed.getPkFieldNames() : ed.getAllFieldNames() for (int i = 0; i < allFields.size(); i++) { FieldInfo fi = ed.getFieldInfo(allFields.get(i)) Map propMap = [:] propMap.put('type', fieldTypeJsonMap.get(fi.type)) String format = fieldTypeJsonFormatMap.get(fi.type) if (format) propMap.put('format', format) properties.put(fi.name, propMap) List enumList = getFieldEnums(ed, fi) if (enumList) propMap.put('enum', enumList) } // put current schema in Map before nesting for relationships, avoid infinite recursion with entity rel loops if (standalone && definitionsMap == null) { definitionsMap = [:] definitionsMap.put('paginationParameters', jsonPaginationParameters) } if (definitionsMap != null && !definitionsMap.containsKey(refName)) definitionsMap.put(refName, schema) if (!pkOnly && (masterName || masterDetail != null)) { // add only relationships from master definition or detail List detailList if (masterName) { MasterDefinition masterDef = ed.getMasterDefinition(masterName) if (masterDef == null) throw new IllegalArgumentException("Master name ${masterName} not valid for entity ${ed.getFullEntityName()}") detailList = masterDef.detailList } else { detailList = masterDetail.getDetailList() } for (MasterDetail childMasterDetail in detailList) { RelationshipInfo relInfo = childMasterDetail.relInfo String relationshipName = relInfo.relationshipName String entryName = relInfo.shortAlias ?: relationshipName String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName() if (pkOnly) relatedRefName = relatedRefName + ".PK" // recurse, let it put itself in the definitionsMap // linkPrefix and schemaLinkPrefix are null so that no links are added for master dependents if (definitionsMap != null && !definitionsMap.containsKey(relatedRefName)) getJsonSchema(relInfo.relatedEd, pkOnly, false, definitionsMap, schemaUri, null, null, false, null, childMasterDetail) if (relInfo.type == "many") { properties.put(entryName, [type:'array', items:['$ref':('#/definitions/' + relatedRefName)]]) } else { properties.put(entryName, ['$ref':('#/definitions/' + relatedRefName)]) } } } else if (!pkOnly && nestRelationships) { // add all relationships, nest List relInfoList = ed.getRelationshipsInfo(true) for (RelationshipInfo relInfo in relInfoList) { String relationshipName = relInfo.relationshipName String entryName = relInfo.shortAlias ?: relationshipName String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName() if (pkOnly) relatedRefName = relatedRefName + ".PK" // recurse, let it put itself in the definitionsMap if (definitionsMap != null && !definitionsMap.containsKey(relatedRefName)) getJsonSchema(relInfo.relatedEd, pkOnly, false, definitionsMap, schemaUri, linkPrefix, schemaLinkPrefix, nestRelationships, null, null) if (relInfo.type == "many") { properties.put(entryName, [type:'array', items:['$ref':('#/definitions/' + relatedRefName)]]) } else { properties.put(entryName, ['$ref':('#/definitions/' + relatedRefName)]) } } } // add links (for Entity REST API) if (linkPrefix || schemaLinkPrefix) { List pkNameList = ed.getPkFieldNames() StringBuilder idSb = new StringBuilder() for (String pkName in pkNameList) idSb.append('/{').append(pkName).append('}') String idString = idSb.toString() List linkList if (linkPrefix) { linkList = [ [rel:'self', method:'GET', href:"${linkPrefix}/${refName}${idString}", title:"Get single ${prettyName}", targetSchema:['$ref':"#/definitions/${name}"]], [rel:'instances', method:'GET', href:"${linkPrefix}/${refName}", title:"Get list of ${prettyName}", schema:[allOf:[['$ref':'#/definitions/paginationParameters'], ['$ref':"#/definitions/${name}"]]], targetSchema:[type:'array', items:['$ref':"#/definitions/${name}"]]], [rel:'create', method:'POST', href:"${linkPrefix}/${refName}", title:"Create ${prettyName}", schema:['$ref':"#/definitions/${name}"]], [rel:'update', method:'PATCH', href:"${linkPrefix}/${refName}${idString}", title:"Update ${prettyName}", schema:['$ref':"#/definitions/${name}"]], [rel:'store', method:'PUT', href:"${linkPrefix}/${refName}${idString}", title:"Create or Update ${prettyName}", schema:['$ref':"#/definitions/${name}"]], [rel:'destroy', method:'DELETE', href:"${linkPrefix}/${refName}${idString}", title:"Delete ${prettyName}", schema:['$ref':"#/definitions/${name}"]] ] as List } else { linkList = [] } if (schemaLinkPrefix) linkList.add([rel:'describedBy', method:'GET', href:"${schemaLinkPrefix}/${refName}", title:"Get schema for ${prettyName}"]) schema.put('links', linkList) } if (standalone) { return ['$schema':'http://json-schema.org/draft-04/hyper-schema#', id:"${schemaUri}/${refName}", '$ref':"#/definitions/${name}", definitions:definitionsMap] } else { return schema } } static Map getRamlFieldMap(EntityDefinition ed, FieldInfo fi) { Map propMap = [:] String description = fi.fieldNode.first("description")?.text if (description) propMap.put("description", description) propMap.put('type', fieldTypeRamlMap.get(fi.type)) List enumList = getFieldEnums(ed, fi) if (enumList) propMap.put('enum', enumList) return propMap } static Map getRamlTypeMap(EntityDefinition ed, boolean pkOnly, Map typesMap, String masterName, MasterDetail masterDetail) { String name = ed.getShortOrFullEntityName() String prettyName = ed.getPrettyName(null, null) String refName = name if (masterName) { refName = "${name}.${masterName}" prettyName = prettyName + " (Master: ${masterName})" } Map properties = [:] Map typeMap = [displayName:prettyName, type:'object', properties:properties] as Map if (typesMap != null && !typesMap.containsKey(name)) typesMap.put(refName, typeMap) // add field properties ArrayList allFields = pkOnly ? ed.getPkFieldNames() : ed.getAllFieldNames() for (int i = 0; i < allFields.size(); i++) { FieldInfo fi = ed.getFieldInfo(allFields.get(i)) properties.put(fi.name, getRamlFieldMap(ed, fi)) } // for master add related properties if (!pkOnly && (masterName || masterDetail != null)) { // add only relationships from master definition or detail List detailList if (masterName) { MasterDefinition masterDef = ed.getMasterDefinition(masterName) if (masterDef == null) throw new IllegalArgumentException("Master name ${masterName} not valid for entity ${ed.getFullEntityName()}") detailList = masterDef.detailList } else { detailList = masterDetail.getDetailList() } for (MasterDetail childMasterDetail in detailList) { RelationshipInfo relInfo = childMasterDetail.relInfo String relationshipName = relInfo.relationshipName String entryName = relInfo.shortAlias ?: relationshipName String relatedRefName = relInfo.relatedEd.getShortOrFullEntityName() // recurse, let it put itself in the definitionsMap if (typesMap != null && !typesMap.containsKey(relatedRefName)) getRamlTypeMap(relInfo.relatedEd, pkOnly, typesMap, null, childMasterDetail) if (relInfo.type == "many") { // properties.put(entryName, [type:'array', items:relatedRefName]) properties.put(entryName, [type:(relatedRefName + '[]')]) } else { properties.put(entryName, [type:relatedRefName]) } } } return typeMap } static Map getRamlApi(EntityDefinition ed, String masterName) { String name = ed.getShortOrFullEntityName() if (masterName) name = "${name}/${masterName}" String prettyName = ed.getPrettyName(null, null) Map ramlMap = [:] // setup field info Map qpMap = [:] ArrayList allFields = ed.getAllFieldNames() for (int i = 0; i < allFields.size(); i++) { FieldInfo fi = ed.getFieldInfo(allFields.get(i)) qpMap.put(fi.name, getRamlFieldMap(ed, fi)) } // get list // TODO: make body array of schema ramlMap.put('get', [is:['paged'], description:"Get list of ${prettyName}".toString(), queryParameters:qpMap, responses:[200:[body:['application/json': [schema:name]]]]]) // create ramlMap.put('post', [description:"Create ${prettyName}".toString(), body:['application/json': [schema:name]]]) // under IDs for single record operations List pkNameList = ed.getPkFieldNames() Map recordMap = ramlMap for (String pkName in pkNameList) { Map childMap = [:] recordMap.put('/{' + pkName + '}', childMap) recordMap = childMap } // get single recordMap.put('get', [description:"Get single ${prettyName}".toString(), responses:[200:[body:['application/json': [schema:name]]]]]) // update recordMap.put('patch', [description:"Update ${prettyName}".toString(), body:['application/json': [schema:name]]]) // store recordMap.put('put', [description:"Create or Update ${prettyName}".toString(), body:['application/json': [schema:name]]]) // delete recordMap.put('delete', [description:"Delete ${prettyName}".toString()]) return ramlMap } static void addToSwaggerMap(EntityDefinition ed, Map swaggerMap, String masterName) { Map definitionsMap = ((Map) swaggerMap.definitions) String refDefName = ed.getShortOrFullEntityName() if (masterName) refDefName = refDefName + "." + masterName String refDefNamePk = refDefName + ".PK" String entityDescription = ed.getEntityNode().first("description")?.text // add responses Map responses = ["401":[description:"Authentication required"], "403":[description:"Access Forbidden (no authz)"], "404":[description:"Value Not Found"], "429":[description:"Too Many Requests (tarpit)"], "500":[description:"General Error"]] // entity path (no ID) String entityPath = "/" + (ed.getShortOrFullEntityName()) if (masterName) entityPath = entityPath + "/" + masterName Map> entityResourceMap = [:] ((Map) swaggerMap.paths).put(entityPath, entityResourceMap) // get - list List listParameters = [] listParameters.addAll(swaggerPaginationParameters) for (String fieldName in ed.getAllFieldNames()) { FieldInfo fi = ed.getFieldInfo(fieldName) listParameters.add([name:fieldName, in:'query', required:false, type:(fieldTypeJsonMap.get(fi.type) ?: "string"), format:(fieldTypeJsonFormatMap.get(fi.type) ?: ""), description:fi.fieldNode.first("description")?.text]) } Map listResponses = ["200":[description:'Success', schema:[type:"array", items:['$ref':"#/definitions/${refDefName}".toString()]]]] as Map listResponses.putAll(responses) entityResourceMap.put("get", [summary:("Get ${ed.getFullEntityName()}".toString()), description:entityDescription, parameters:listParameters, security:[[basicAuth:[]]], responses:listResponses]) // post - create Map createResponses = ["200":[description:'Success', schema:['$ref':"#/definitions/${refDefNamePk}".toString()]]] as Map createResponses.putAll(responses) entityResourceMap.put("post", [summary:("Create ${ed.getFullEntityName()}".toString()), description:entityDescription, parameters:[name:'body', in:'body', required:true, schema:['$ref':"#/definitions/${refDefName}".toString()]], security:[[basicAuth:[]]], responses:createResponses]) // entity plus ID path StringBuilder entityIdPathSb = new StringBuilder(entityPath) List parameters = [] for (String pkName in ed.getPkFieldNames()) { entityIdPathSb.append("/{").append(pkName).append("}") FieldInfo fi = ed.getFieldInfo(pkName) parameters.add([name:pkName, in:'path', required:true, type:(fieldTypeJsonMap.get(fi.type) ?: "string"), description:fi.fieldNode.first("description")?.text]) } String entityIdPath = entityIdPathSb.toString() Map> entityIdResourceMap = [:] ((Map) swaggerMap.paths).put(entityIdPath, entityIdResourceMap) // under id: get - one Map oneResponses = ["200":[name:'body', in:'body', required:false, schema:['$ref':"#/definitions/${refDefName}".toString()]]] as Map oneResponses.putAll(responses) entityIdResourceMap.put("get", [summary:("Create ${ed.getFullEntityName()}".toString()), description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:oneResponses]) // under id: patch - update List updateParameters = new LinkedList(parameters) updateParameters.add([name:'body', in:'body', required:false, schema:['$ref':"#/definitions/${refDefName}".toString()]]) entityIdResourceMap.put("patch", [summary:("Update ${ed.getFullEntityName()}".toString()), description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:updateParameters, responses:responses]) // under id: put - store entityIdResourceMap.put("put", [summary:("Create or Update ${ed.getFullEntityName()}".toString()), description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:updateParameters, responses:responses]) // under id: delete - delete entityIdResourceMap.put("delete", [summary:("Delete ${ed.getFullEntityName()}".toString()), description:entityDescription, security:[[basicAuth:[]], [api_key:[]]], parameters:parameters, responses:responses]) // add a definition for entity fields definitionsMap.put(refDefName, getJsonSchema(ed, false, false, definitionsMap, null, null, null, false, masterName, null)) definitionsMap.put(refDefNamePk, getJsonSchema(ed, true, false, null, null, null, null, false, masterName, null)) } // ================================================ // ========== Service Definition Methods ========== // ================================================ static Map getJsonSchemaMapIn(ServiceDefinition sd) { // add a definition for service in parameters List requiredParms = [] Map properties = [:] Map defMap = [type:'object', properties:properties] as Map for (String parmName in sd.getInParameterNames()) { MNode parmNode = sd.getInParameter(parmName) if (parmNode.attribute("required") == "true") requiredParms.add(parmName) properties.put(parmName, getJsonSchemaPropMap(sd, parmNode)) } if (requiredParms) defMap.put("required", requiredParms) return defMap } static Map getJsonSchemaMapOut(ServiceDefinition sd) { List requiredParms = [] Map properties = [:] Map defMap = [type:'object', properties:properties] as Map for (String parmName in sd.getOutParameterNames()) { MNode parmNode = sd.getOutParameter(parmName) if (parmNode.attribute("required") == "true") requiredParms.add(parmName) properties.put(parmName, getJsonSchemaPropMap(sd, parmNode)) } if (requiredParms) defMap.put("required", requiredParms) return defMap } static protected Map getJsonSchemaPropMap(ServiceDefinition sd, MNode parmNode) { String objectType = (String) parmNode?.attribute('type') String jsonType = RestApi.getJsonType(objectType) Map propMap = [type:jsonType] as Map String format = RestApi.getJsonFormat(objectType) if (format) propMap.put("format", format) String description = parmNode.first("description")?.text if (description) propMap.put("description", description) if (parmNode.attribute("default-value")) propMap.put("default", (String) parmNode.attribute("default-value")) if (parmNode.attribute("default")) propMap.put("default", "{${parmNode.attribute("default")}}".toString()) List childList = parmNode.children("parameter") if (jsonType == 'array') { if (childList) { propMap.put("items", getJsonSchemaPropMap(sd, childList[0])) } else { logger.warn("Parameter ${parmNode.attribute('name')} of service ${sd.serviceName} is an array type but has no child parameter (should have one, name ignored), may cause error in Swagger, etc") } } else if (jsonType == 'object') { if (childList) { Map properties = [:] propMap.put("properties", properties) for (MNode childNode in childList) { properties.put(childNode.attribute("name"), getJsonSchemaPropMap(sd, childNode)) } } else { // Swagger UI is okay with empty maps (works, just less detail), so don't warn about this // logger.warn("Parameter ${parmNode.attribute('name')} of service ${getServiceName()} is an object type but has no child parameters, may cause error in Swagger, etc") } } else { addParameterEnums(sd, parmNode, propMap) } return propMap } static void addParameterEnums(ServiceDefinition sd, MNode parmNode, Map propMap) { String entityName = parmNode.attribute("entity-name") String fieldName = parmNode.attribute("field-name") if (entityName && fieldName) { EntityDefinition ed = sd.sfi.ecfi.entityFacade.getEntityDefinition(entityName) if (ed == null) throw new ServiceException("Entity ${entityName} not found, from parameter ${parmNode.attribute('name')} of service ${sd.serviceName}") FieldInfo fi = ed.getFieldInfo(fieldName) if (fi == null) throw new ServiceException("Field ${fieldName} not found for entity ${entityName}, from parameter ${parmNode.attribute('name')} of service ${sd.serviceName}") List enumList = getFieldEnums(ed, fi) if (enumList) propMap.put('enum', enumList) } } static Map getRamlMapIn(ServiceDefinition sd) { Map properties = [:] Map defMap = [type:'object', properties:properties] as Map for (String parmName in sd.getInParameterNames()) { MNode parmNode = sd.getInParameter(parmName) properties.put(parmName, getRamlPropMap(parmNode)) } return defMap } static Map getRamlMapOut(ServiceDefinition sd) { Map properties = [:] Map defMap = [type:'object', properties:properties] as Map for (String parmName in sd.getOutParameterNames()) { MNode parmNode = sd.getOutParameter(parmName) properties.put(parmName, getRamlPropMap(parmNode)) } return defMap } protected static Map getRamlPropMap(MNode parmNode) { String objectType = parmNode?.attribute('type') String ramlType = RestApi.getRamlType(objectType) Map propMap = [type:ramlType] as Map String description = parmNode.first("description")?.text if (description) propMap.put("description", description) if (parmNode.attribute("required") == "true") propMap.put("required", true) if (parmNode.attribute("default-value")) propMap.put("default", (String) parmNode.attribute("default-value")) if (parmNode.attribute("default")) propMap.put("default", "{${parmNode.attribute("default")}}".toString()) List childList = parmNode.children("parameter") if (childList) { if (ramlType == 'array') { propMap.put("items", getRamlPropMap(childList[0])) } else if (ramlType == 'object') { Map properties = [:] propMap.put("properties", properties) for (MNode childNode in childList) { properties.put(childNode.attribute("name"), getRamlPropMap(childNode)) } } } return propMap } // ================================================ // ========== Web Request Schema Methods ========== // ================================================ static void handleEntityRestSchema(ExecutionContextImpl eci, List extraPathNameList, String schemaUri, String linkPrefix, String schemaLinkPrefix, boolean getMaster) { // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth if (!eci.getUser().getUsername()) { // if there was a login error there will be a MessageFacade error message String errorMessage = eci.message.errorsString if (!errorMessage) errorMessage = "Authentication required for entity REST schema" eci.webImpl.sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null) return } EntityFacadeImpl efi = eci.entityFacade if (extraPathNameList.size() == 0) { List allRefList = [] Map definitionsMap = [:] definitionsMap.put('paginationParameters', jsonPaginationParameters) Map rootMap = ['$schema':'http://json-schema.org/draft-04/hyper-schema#', title:'Moqui Entity REST API', anyOf:allRefList, definitions:definitionsMap] if (schemaUri) rootMap.put('id', schemaUri) Set entityNameSet if (getMaster) { // if getMaster and no entity name in path, just get entities with master definitions entityNameSet = efi.getAllEntityNamesWithMaster() } else { entityNameSet = efi.getAllNonViewEntityNames() } for (String entityName in entityNameSet) { EntityDefinition ed = efi.getEntityDefinition(entityName) String refName = ed.getShortOrFullEntityName() if (getMaster) { Map masterDefMap = ed.getMasterDefinitionMap() Map entityPathMap = [:] for (String masterName in masterDefMap.keySet()) { allRefList.add(['$ref':"#/definitions/${refName}/${masterName}"]) Map schema = getJsonSchema(ed, false, false, definitionsMap, schemaUri, linkPrefix, schemaLinkPrefix, false, masterName, null) entityPathMap.put(masterName, schema) } definitionsMap.put(refName, entityPathMap) } else { allRefList.add(['$ref':"#/definitions/${refName}"]) Map schema = getJsonSchema(ed, false, false, null, schemaUri, linkPrefix, schemaLinkPrefix, true, null, null) definitionsMap.put(refName, schema) } } JsonBuilder jb = new JsonBuilder() jb.call(rootMap) String jsonStr = jb.toPrettyString() eci.webImpl.sendTextResponse(jsonStr, "application/schema+json", "MoquiEntities.schema.json") } else { String entityName = extraPathNameList.get(0) if (entityName.endsWith(".json")) entityName = entityName.substring(0, entityName.length() - 5) String masterName = null if (extraPathNameList.size() > 1) { masterName = extraPathNameList.get(1) if (masterName.endsWith(".json")) masterName = masterName.substring(0, masterName.length() - 5) } if (getMaster && !masterName) masterName = "default" try { EntityDefinition ed = efi.getEntityDefinition(entityName) if (ed == null) { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "No entity found with name or alias [${entityName}]", null) return } Map schema = getJsonSchema(ed, false, true, null, schemaUri, linkPrefix, schemaLinkPrefix, !getMaster, masterName, null) // TODO: support array wrapper (different URL? suffix?) with [type:'array', items:schema] // sendJsonResponse(schema) JsonBuilder jb = new JsonBuilder() jb.call(schema) String jsonStr = jb.toPrettyString() eci.webImpl.sendTextResponse(jsonStr, "application/schema+json", "${entityName}.schema.json") } catch (EntityNotFoundException e) { if (logger.isTraceEnabled()) logger.trace("In entity REST schema entity not found: " + e.toString()) eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "No entity found with name or alias [${entityName}]", null) } } } static void handleEntityRestRaml(ExecutionContextImpl eci, List extraPathNameList, String linkPrefix, String schemaLinkPrefix, boolean getMaster) { // make sure a user is logged in, screen/etc that calls will generally be configured to not require auth if (!eci.getUser().getUsername()) { // if there was a login error there will be a MessageFacade error message String errorMessage = eci.message.errorsString if (!errorMessage) errorMessage = "Authentication required for entity REST schema" eci.webImpl.sendJsonError(HttpServletResponse.SC_UNAUTHORIZED, errorMessage, null) return } EntityFacadeImpl efi = eci.entityFacade List schemasList = [] Map rootMap = [title:'Moqui Entity REST API', version:eci.factory.moquiVersion, baseUri:linkPrefix, mediaType:'application/json', schemas:schemasList] as Map rootMap.put('traits', [[paged:[queryParameters:ramlPaginationParameters]]]) Set entityNameSet String masterName = null if (extraPathNameList.size() > 0) { String entityName = extraPathNameList.get(0) if (entityName.endsWith(".raml")) entityName = entityName.substring(0, entityName.length() - 5) if (extraPathNameList.size() > 1) { masterName = extraPathNameList.get(1) if (masterName.endsWith(".raml")) masterName = masterName.substring(0, masterName.length() - 5) } entityNameSet = new TreeSet() entityNameSet.add(entityName) } else if (getMaster) { // if getMaster and no entity name in path, just get entities with master definitions entityNameSet = efi.getAllEntityNamesWithMaster() } else { entityNameSet = efi.getAllNonViewEntityNames() } for (String entityName in entityNameSet) { EntityDefinition ed = efi.getEntityDefinition(entityName) String refName = ed.getShortOrFullEntityName() if (getMaster) { Set masterNameSet = new LinkedHashSet() if (masterName) { masterNameSet.add(masterName) } else { Map masterDefMap = ed.getMasterDefinitionMap() masterNameSet.addAll(masterDefMap.keySet()) } Map entityPathMap = [:] for (String curMasterName in masterNameSet) { schemasList.add([("${refName}/${curMasterName}".toString()):"!include ${schemaLinkPrefix}/${refName}/${curMasterName}.json".toString()]) Map ramlApi = getRamlApi(ed, masterName) entityPathMap.put("/" + curMasterName, ramlApi) } rootMap.put("/" + refName, entityPathMap) } else { schemasList.add([(refName):"!include ${schemaLinkPrefix}/${refName}.json".toString()]) Map ramlApi = getRamlApi(ed, null) rootMap.put('/' + refName, ramlApi) } } DumperOptions options = new DumperOptions() options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN) options.setPrettyFlow(true) Yaml yaml = new Yaml(options) String yamlString = yaml.dump(rootMap) // add beginning line "#%RAML 0.8", more efficient way to do this? yamlString = "#%RAML 0.8\n" + yamlString eci.webImpl.sendTextResponse(yamlString, "application/raml+yaml", "MoquiEntities.raml") } static void handleEntityRestSwagger(ExecutionContextImpl eci, List extraPathNameList, String basePath, boolean getMaster) { if (extraPathNameList.size() == 0) { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "No entity name specified in path (for all entities use 'all')", null) return } EntityFacadeImpl efi = eci.entityFacade String entityName = extraPathNameList.get(0) String outputType = "application/json" if (entityName.endsWith(".yaml")) outputType = "application/yaml" if (entityName.endsWith(".json") || entityName.endsWith(".yaml")) entityName = entityName.substring(0, entityName.length() - 5) if (entityName == 'all') entityName = null String masterName = null if (extraPathNameList.size() > 1) { masterName = extraPathNameList.get(1) if (masterName.endsWith(".json") || masterName.endsWith(".yaml")) masterName = masterName.substring(0, masterName.length() - 5) } String filename = entityName ?: "Entities" if (masterName) filename = filename + "." + masterName eci.webImpl.response.setHeader("Access-Control-Allow-Origin", "*") eci.webImpl.response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, PATCH, OPTIONS") eci.webImpl.response.setHeader("Access-Control-Allow-Headers", "Content-Type, api_key, Authorization") String fullHost = WebFacadeImpl.makeWebappHost(eci.webImpl.webappMoquiName, eci, eci.webImpl, true) String scheme = fullHost.substring(0, fullHost.indexOf("://")) String hostName = fullHost.substring(fullHost.indexOf("://") + 3) Map definitionsMap = new TreeMap() Map swaggerMap = [swagger:'2.0', info:[title:("${filename} REST API"), version:eci.factory.moquiVersion], host:hostName, basePath:basePath, schemes:[scheme], consumes:['application/json', 'multipart/form-data'], produces:['application/json'], securityDefinitions:[basicAuth:[type:'basic', description:'HTTP Basic Authentication'], api_key:[type:"apiKey", name:"api_key", in:"header", description:'HTTP Header api_key']], paths:[:], definitions:definitionsMap ] Set entityNameSet if (entityName) { entityNameSet = new TreeSet() entityNameSet.add(entityName) } else if (getMaster) { // if getMaster and no entity name in path, just get entities with master definitions entityNameSet = efi.getAllEntityNamesWithMaster() } else { entityNameSet = efi.getAllNonViewEntityNames() } for (String curEntityName in entityNameSet) { EntityDefinition ed = efi.getEntityDefinition(curEntityName) if (getMaster) { Set masterNameSet = new LinkedHashSet() if (masterName) { masterNameSet.add(masterName) } else { Map masterDefMap = ed.getMasterDefinitionMap() masterNameSet.addAll(masterDefMap.keySet()) } for (String curMasterName in masterNameSet) { addToSwaggerMap(ed, swaggerMap, curMasterName) } } else { addToSwaggerMap(ed, swaggerMap, null) } } if (outputType == "application/json") { JsonBuilder jb = new JsonBuilder() jb.call(swaggerMap) String jsonStr = jb.toPrettyString() eci.webImpl.sendTextResponse(jsonStr, "application/json", "${filename}.swagger.json") } else if (outputType == "application/yaml") { DumperOptions options = new DumperOptions() options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN) options.setPrettyFlow(true) Yaml yaml = new Yaml(options) String yamlString = yaml.dump(swaggerMap) eci.webImpl.sendTextResponse(yamlString, "application/yaml", "${filename}.swagger.yaml") } else { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "Output type ${outputType} not supported", null) } } static void handleServiceRestSwagger(ExecutionContextImpl eci, List extraPathNameList, String basePath) { if (extraPathNameList.size() == 0) { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "No root resource name specified in path", null) return } String outputType = "application/json" List rootPathList = [] StringBuilder filenameBase = new StringBuilder() for (String pathName in extraPathNameList) { if (pathName.endsWith(".yaml")) outputType = "application/yaml" if (pathName.endsWith(".json") || pathName.endsWith(".yaml")) pathName = pathName.substring(0, pathName.length() - 5) rootPathList.add(pathName) filenameBase.append(pathName).append('.') } eci.webImpl.response.setHeader("Access-Control-Allow-Origin", "*") eci.webImpl.response.setHeader("Access-Control-Allow-Methods", "GET, POST, DELETE, PUT, PATCH, OPTIONS") eci.webImpl.response.setHeader("Access-Control-Allow-Headers", "Content-Type, api_key, Authorization") String fullHost = WebFacadeImpl.makeWebappHost(eci.webImpl.webappMoquiName, eci, eci.webImpl, true) String scheme = fullHost.substring(0, fullHost.indexOf("://")) String hostName = fullHost.substring(fullHost.indexOf("://") + 3) Map swaggerMap = eci.serviceFacade.restApi.getSwaggerMap(rootPathList, [scheme], hostName, basePath) if (outputType == "application/json") { JsonBuilder jb = new JsonBuilder() jb.call(swaggerMap) String jsonStr = jb.toPrettyString() eci.webImpl.sendTextResponse(jsonStr, "application/json", "${filenameBase}swagger.json") } else if (outputType == "application/yaml") { DumperOptions options = new DumperOptions() options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN) options.setPrettyFlow(true) Yaml yaml = new Yaml(options) String yamlString = yaml.dump(swaggerMap) eci.webImpl.sendTextResponse(yamlString, "application/yaml", "${filenameBase}swagger.yaml") } else { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "Output type ${outputType} not supported", null) } } static void handleServiceRestRaml(ExecutionContextImpl eci, List extraPathNameList, String linkPrefix) { if (extraPathNameList.size() == 0) { eci.webImpl.sendJsonError(HttpServletResponse.SC_BAD_REQUEST, "No root resource name specified in path", null) return } String rootResourceName = extraPathNameList.get(0) if (rootResourceName.endsWith(".raml")) rootResourceName = rootResourceName.substring(0, rootResourceName.length() - 5) Map swaggerMap = eci.serviceFacade.restApi.getRamlMap(rootResourceName, linkPrefix) DumperOptions options = new DumperOptions() options.setDefaultFlowStyle(DumperOptions.FlowStyle.BLOCK) // default: options.setDefaultScalarStyle(DumperOptions.ScalarStyle.PLAIN) options.setPrettyFlow(true) Yaml yaml = new Yaml(options) String yamlString = yaml.dump(swaggerMap) // add beginning line "#%RAML 1.0", more efficient way to do this? yamlString = "#%RAML 1.0\n" + yamlString eci.webImpl.sendTextResponse(yamlString, "application/raml+yaml", "${rootResourceName}.raml") } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/SimpleSgmlReader.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util import groovy.transform.CompileStatic import org.apache.commons.validator.routines.CalendarValidator import org.moqui.util.StringUtilities import org.slf4j.Logger import org.slf4j.LoggerFactory import java.sql.Timestamp /** A Simple SGML parser. Doesn't validate, doesn't support attributes, and has some hacks for easier OFX V1 parsing with * the colon separated header. */ @CompileStatic class SimpleSgmlReader { protected final static Logger logger = LoggerFactory.getLogger(SimpleSgmlReader.class) final static CalendarValidator calendarValidator = new CalendarValidator() protected Map headerMap = null protected Node rootNode = null private String sgml private int length = 0 private int pos = 0 SimpleSgmlReader(String sgml) { this.sgml = sgml if (!sgml) return this.length = sgml.length() // parse the header key/value pairs (one per line, colon separated) int firstLt = sgml.indexOf(lt) if (firstLt > 0) { String headerStr = sgml.substring(0, firstLt).trim() headerMap = [:] if (headerStr) { for (String line in headerStr.split('\\s')) { if (!line) continue int colonIndex = line.indexOf(':') if (colonIndex > 0 && line.length() > (colonIndex + 1)) { String key = line.substring(0, colonIndex).trim() String value = line.substring(colonIndex + 1).trim() headerMap.put(key, value) } } } } pos = firstLt rootNode = startRoot() } Map getHeader() { return headerMap } Node getRoot() { return rootNode } static final int slash = ('/' as char) as int static final int lt = ('<' as char) as int static final int gt = ('>' as char) as int protected Node startRoot() { // move pos to first char of element name pos++ // find close of tag (>) int gtIndex = sgml.indexOf(gt, pos) if (gtIndex == -1) return null String nodeName = sgml.substring(pos, gtIndex).trim() Node rootNode = new Node(null, nodeName) pos = gtIndex + 1 // assume root Node has child nodes (not value) int nextLtIndex = sgml.indexOf(lt, pos) if (nextLtIndex == -1) return null pos = nextLtIndex handleChildren(rootNode) return rootNode } protected void handleChildren(Node parent) { // child elements while (pos < length && sgml.charAt(pos) == lt && sgml.charAt(pos + 1) != slash) { startNode(parent) } // close tag if (pos < length && sgml.charAt(pos) == lt && sgml.charAt(pos + 1) == slash) { int closeGtIndex = sgml.indexOf(gt, pos + 2) if (closeGtIndex > pos) pos = closeGtIndex + 1 // at this point pos is after the close of the tag, see if there is a next element, will be a sibling int nextLtIndex = sgml.indexOf(lt, pos) if (nextLtIndex == -1) pos = length else pos = nextLtIndex } } protected void startNode(Node parent) { // move pos to first char of element name pos++ // find close of tag (>) // TODO: handle self-closing element int gtIndex = sgml.indexOf(gt, pos) if (gtIndex == -1) return String nodeName = sgml.substring(pos, gtIndex).trim() Node curNode = new Node(parent, nodeName) pos = gtIndex + 1 skipWhitespace() if (pos == length) return // find next element or close element, look for element value text int ltIndex = sgml.indexOf(lt, pos) if (ltIndex == -1) ltIndex = length String value = null if (ltIndex > pos) { value = sgml.substring(pos, ltIndex).trim() if (value) { value = StringUtilities.decodeFromXml(value) curNode.setValue(value) } pos = ltIndex } if (pos == length) return // if no value element has children, otherwise we've got the value so just return if (!value) handleChildren(curNode) } protected void skipWhitespace() { while (pos < length && Character.isWhitespace(sgml.charAt(pos))) pos++ } /** possible formats include: yyyyMMdd (GMT), yyyyMMddHHmmss (GMT), yyyyMMddHHmmss.SSS (GMT), yyyyMMddHHmmss.SSS[-5:EST] */ static Timestamp parseOfxDateTime(String ofxStr) { if (ofxStr.length() <= 18) { while (ofxStr.length() < 14) { ofxStr = ofxStr + "0" } if (ofxStr.length() < 15) { ofxStr = ofxStr + "." } while (ofxStr.length() < 18) { ofxStr = ofxStr + "0" } ofxStr = ofxStr + " +0000" } else { // has a time zone, strip it and create an RFC 822 time zone (like -0500) int openBraceIndex = ofxStr.indexOf('[') String tzStr = ofxStr.substring(openBraceIndex + 1, ofxStr.indexOf(':', openBraceIndex)) if (Character.isDigit(tzStr.charAt(0))) tzStr = '+' + tzStr if (tzStr.length() == 2) tzStr = tzStr[0] + '0' + tzStr[1] String dtStr = ofxStr.substring(0, openBraceIndex) while (dtStr.length() < 14) { dtStr = dtStr + "0" } if (dtStr.length() < 15) { dtStr = dtStr + "." } while (dtStr.length() < 18) { dtStr = dtStr + "0" } ofxStr = "${dtStr} ${tzStr}00" } Calendar cal = calendarValidator.validate(ofxStr, 'yyyyMMddHHmmss.SSS Z', null, null) return cal ? new Timestamp(cal.getTimeInMillis()) : null } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/util/SimpleSigner.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.util; import org.moqui.BaseException; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.IOException; import java.io.InputStream; import java.security.KeyFactory; import java.security.PrivateKey; import java.security.Signature; import java.security.spec.PKCS8EncodedKeySpec; import java.util.Base64; public class SimpleSigner { protected final static Logger logger = LoggerFactory.getLogger(SimpleSigner.class); private String keyResource, keyType = "RSA", signatureType = "SHA1withRSA"; private PrivateKey key = null; public SimpleSigner(String keyResource) { this.keyResource = keyResource; initKey(); } public SimpleSigner(String keyResource, String keyType, String signatureType) { this.keyResource = keyResource; if (keyType != null) this.keyType = keyType; if (signatureType != null) this.signatureType = signatureType; initKey(); } public String sign(String data) throws Exception { if (key == null) throw new BaseException("Cannot sign message, key could not be loaded from resource " + keyResource); Signature signature = Signature.getInstance(signatureType); signature.initSign(key); signature.update(data.getBytes()); return Base64.getEncoder().encodeToString(signature.sign()); } private void initKey() { try { byte[] keyData = readKey(keyResource); PKCS8EncodedKeySpec keySpec = new PKCS8EncodedKeySpec(keyData); KeyFactory kf = KeyFactory.getInstance(keyType); key = kf.generatePrivate(keySpec); } catch (Exception e) { logger.warn("Could not initialize signing key " + keyResource + ": " + e.toString()); } } public static byte[] readKey(String resourcePath) throws IOException { InputStream is = Thread.currentThread().getContextClassLoader().getResourceAsStream(resourcePath); if (is == null) throw new BaseException("Could not find signing key resource " + resourcePath + " on classpath"); String keyData = ObjectUtilities.getStreamText(is); StringBuilder sb = new StringBuilder(); String[] lines = keyData.split("\n"); String[] skips = new String[]{"-----BEGIN", "-----END", ": "}; for (String line : lines) { boolean skipLine = false; for (String skip : skips) if (line.contains(skip)) { skipLine = true; } if (!skipLine) sb.append(line.trim()); } return Base64.getDecoder().decode(sb.toString()); } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/ElasticRequestLogFilter.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.Moqui import org.moqui.impl.context.ElasticFacadeImpl.ElasticClientImpl import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.UserFacadeImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.* import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletResponseWrapper import jakarta.servlet.http.HttpSession import java.util.concurrent.ConcurrentLinkedQueue /** Save data about HTTP requests to ElasticSearch using a Servlet Filter */ @CompileStatic class ElasticRequestLogFilter implements Filter { protected final static Logger logger = LoggerFactory.getLogger(ElasticRequestLogFilter.class) final static String INDEX_NAME = "moqui_http_log" // final static String DOC_TYPE = "MoquiHttpRequest" protected FilterConfig filterConfig = null protected ExecutionContextFactoryImpl ecfi = null private ElasticClientImpl elasticClient = null private boolean disabled = false final ConcurrentLinkedQueue requestLogQueue = new ConcurrentLinkedQueue<>() ElasticRequestLogFilter() { super() } @Override void init(FilterConfig filterConfig) throws ServletException { this.filterConfig = filterConfig ecfi = (ExecutionContextFactoryImpl) filterConfig.servletContext.getAttribute("executionContextFactory") if (ecfi == null) ecfi = (ExecutionContextFactoryImpl) Moqui.executionContextFactory elasticClient = (ElasticClientImpl) (ecfi.elasticFacade.getClient("logger") ?: ecfi.elasticFacade.getDefault()) if (elasticClient == null) { logger.error("In ElasticRequestLogFilter init could not find ElasticClient with name logger or default, not starting") return } if (elasticClient.esVersionUnder7) { logger.warn("ElasticClient ${elasticClient.clusterName} has version under 7.0, not starting ElasticRequestLogFilter") return } // check for index exists, create with mapping for log doc if not try { boolean hasIndex = elasticClient.indexExists(INDEX_NAME) if (!hasIndex) elasticClient.createIndex(INDEX_NAME, docMapping, null) } catch (Exception e) { logger.error("Error checking and creating ${INDEX_NAME} ES index, not starting ElasticRequestLogFilter", e) return } RequestLogQueueFlush rlqf = new RequestLogQueueFlush(this) ecfi.scheduleAtFixedRate(rlqf, 15, 5) } // TODO: add geoip (see https://www.elastic.co/guide/en/logstash/current/plugins-filters-geoip.html) // TODO: add user_agent (see https://www.elastic.co/guide/en/logstash/current/plugins-filters-useragent.html) final static Map docMapping = [properties:[ '@timestamp':[type:'date', format:'epoch_millis'], remote_ip:[type:'ip'], remote_user:[type:'keyword'], server_ip:[type:'keyword'], content_type:[type:'text'], request_method:[type:'keyword'], request_scheme:[type:'keyword'], request_host:[type:'keyword'], request_path:[type:'text'], request_query:[type:'text'], http_version:[type:'half_float'], response:[type:'short'], time_initial_ms:[type:'integer'], time_final_ms:[type:'integer'], bytes:[type:'long'], referrer:[type:'text'], agent:[type:'text'], session:[type:'keyword'], visitor_id:[type:'keyword'] ] ] @Override void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { long startTime = System.currentTimeMillis() if (elasticClient == null || disabled || !DispatcherType.REQUEST.is(req.getDispatcherType()) || !(req instanceof HttpServletRequest) || !(resp instanceof HttpServletResponse)) { chain.doFilter(req, resp) return } HttpServletRequest request = (HttpServletRequest) req HttpServletResponse response = (HttpServletResponse) resp CountingHttpServletResponseWrapper responseWrapper = (CountingHttpServletResponseWrapper) null try { responseWrapper = new CountingHttpServletResponseWrapper(response) } catch (Exception e) { logger.warn("Error initializing CountingHttpServletResponseWrapper", e) } // chain first so response is run if (responseWrapper != null) { chain.doFilter(req, responseWrapper) } else { chain.doFilter(req, resp) } if (request.isAsyncStarted()) { request.getAsyncContext().addListener(new RequestLogAsyncListener(this, startTime), req, responseWrapper != null ? responseWrapper : response) } else { logRequest(request, responseWrapper != null ? responseWrapper : response, startTime) } } void logRequest(HttpServletRequest request, HttpServletResponse response, long startTime) { long initialTime = System.currentTimeMillis() - startTime // always flush the buffer so we can get the final time; this is for some reason NECESSARY for the wrapper otherwise content doesn't make it through response.flushBuffer() String clientIp = UserFacadeImpl.getClientIp(request, null, ecfi) String serverIp = request.getLocalAddr() // IPv6 addresses sometimes have square braces around them, ElasticSearch doesn't like that so strip them if found // NOTE: clientIp already has square braces removed by getClientIp() if (serverIp != null && !serverIp.isEmpty()) { if (serverIp.charAt(0) == (char) '[') serverIp = serverIp.substring(1) if (serverIp.charAt(serverIp.length() - 1) == (char) ']') serverIp = serverIp.substring(0, serverIp.length() - 1) } // IPv6 addresses have square braces but ElasticSearch doesn't like them, so if there are any get rid of them float httpVersion = 0.0 String protocol = request.getProtocol().trim() int psIdx = protocol.indexOf("/") if (psIdx > 0) try { httpVersion = Float.parseFloat(protocol.substring(psIdx + 1)) } catch (Exception e) { } // get response size, only way to wrap the response with wrappers for Writer and OutputStream to count size? messy, slow... long written = 0L if (response instanceof CountingHttpServletResponseWrapper) written = ((CountingHttpServletResponseWrapper) response).getWritten() HttpSession session = request.getSession(false) // final time after streaming response (ie flush response) long finalTime = System.currentTimeMillis() - startTime Map reqMap = ['@timestamp':startTime, remote_ip:clientIp, remote_user:request.getRemoteUser(), server_ip:serverIp, content_type:response.getContentType(), request_method:request.getMethod(), request_scheme:request.getScheme(), request_host:request.getServerName(), request_path:request.getRequestURI(), request_query:request.getQueryString(), http_version:httpVersion, response:response.getStatus(), time_initial_ms:initialTime, time_final_ms:finalTime, bytes:written, referrer:request.getHeader("Referer"), agent:request.getHeader("User-Agent"), session:session?.getId(), visitor_id:session?.getAttribute("moqui.visitorId")] requestLogQueue.add(reqMap) // logger.info("${request.getMethod()} ${request.getRequestURI()} - ${response.getStatus()} ${finalTime}ms ${written}b asyncs ${request.isAsyncStarted()}\n${reqMap}") } @Override void destroy() { } static class RequestLogQueueFlush implements Runnable { final static int maxCreates = 50 final ElasticRequestLogFilter filter RequestLogQueueFlush(ElasticRequestLogFilter filter) { this.filter = filter } @Override synchronized void run() { while (filter.requestLogQueue.size() > 0) { flushQueue() } } void flushQueue() { final ConcurrentLinkedQueue queue = filter.requestLogQueue ArrayList createList = new ArrayList<>(maxCreates) int createCount = 0 while (createCount < maxCreates) { Map message = queue.poll() if (message == null) break // increment the count and add the message createCount++ createList.add(message) } int retryCount = 5 while (retryCount > 0) { int createListSize = createList.size() if (createListSize == 0) break try { // long startTime = System.currentTimeMillis() try { filter.elasticClient.bulkIndex(INDEX_NAME, null, createList) } catch (Exception e) { logger.error("Error logging to ElasticSearch: ${e.toString()}") } // logger.warn("Indexed ${createListSize} ElasticSearch log messages in ${System.currentTimeMillis() - startTime}ms") break } catch (Throwable t) { logger.error("Error indexing ElasticSearch log messages, retrying (${retryCount}): ${t.toString()}") retryCount-- } } } } static class RequestLogAsyncListener implements AsyncListener { ElasticRequestLogFilter filter private long startTime RequestLogAsyncListener(ElasticRequestLogFilter filter, long startTime) { this.filter = filter; this.startTime = startTime } @Override void onComplete(AsyncEvent event) throws IOException { logEvent(event) } @Override void onTimeout(AsyncEvent event) throws IOException { logEvent(event) } @Override void onError(AsyncEvent event) throws IOException { logEvent(event) } @Override void onStartAsync(AsyncEvent event) throws IOException { } void logEvent(AsyncEvent event) { if (event.getSuppliedRequest() instanceof HttpServletRequest && event.getSuppliedResponse() instanceof HttpServletResponse) { filter.logRequest((HttpServletRequest) event.getSuppliedRequest(), (HttpServletResponse) event.getSuppliedResponse(), startTime) } } } class CountingHttpServletResponseWrapper extends HttpServletResponseWrapper { private OutputStreamCounter outputStream = null; private PrintWriter writer = null; CountingHttpServletResponseWrapper(HttpServletResponse response) throws IOException { super(response); } long getWritten() { return outputStream != null ? outputStream.getWritten() : 0; } @Override synchronized ServletOutputStream getOutputStream() throws IOException { if (writer != null) throw new IllegalStateException("getWriter() already called"); if (outputStream == null) outputStream = new OutputStreamCounter(super.getOutputStream()); return outputStream; } @Override synchronized PrintWriter getWriter() throws IOException { if (writer == null && outputStream != null) throw new IllegalStateException("getOutputStream() already called"); if (writer == null) { outputStream = new OutputStreamCounter(super.getOutputStream()); writer = new PrintWriter(new OutputStreamWriter(outputStream, getCharacterEncoding())); } return this.writer; } @Override void flushBuffer() throws IOException { if (writer != null) writer.flush(); else if (outputStream != null) outputStream.flush(); super.flushBuffer(); } static class OutputStreamCounter extends ServletOutputStream { private long written = 0; private ServletOutputStream inner; OutputStreamCounter(ServletOutputStream inner) { this.inner = inner; } long getWritten() { return written; } @Override void close() throws IOException { inner.close(); } @Override void flush() throws IOException { inner.flush(); } @Override void write(byte[] b) throws IOException { write(b, 0, b.length); } @Override void write(byte[] b, int off, int len) throws IOException { inner.write(b, off, len); written += len; } @Override void write(int b) throws IOException { inner.write(b); written++; } @Override boolean isReady() { return inner.isReady(); } @Override void setWriteListener(WriteListener writeListener) { inner.setWriteListener(writeListener); } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/GroovyShellEndpoint.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import java.io.PrintWriter import java.io.Writer import java.util.concurrent.ExecutorService import java.util.concurrent.Executors import java.util.concurrent.ScheduledExecutorService import java.util.concurrent.ScheduledFuture import java.util.concurrent.TimeUnit import jakarta.websocket.CloseReason import jakarta.websocket.EndpointConfig import jakarta.websocket.Session import groovy.lang.GroovyShell import groovy.transform.CompileStatic import org.slf4j.Logger import org.slf4j.LoggerFactory import org.moqui.impl.context.ExecutionContextImpl @CompileStatic class GroovyShellEndpoint extends MoquiAbstractEndpoint { private static final Logger logger = LoggerFactory.getLogger(GroovyShellEndpoint.class) private static final int idleSeconds = 300 private static final int maxEvalSeconds = 900 // kill infinite loops ExecutionContextImpl eci GroovyShell groovyShell ExecutorService exec ScheduledExecutorService scheduler ScheduledFuture idleTask ScheduledFuture evalTimeoutTask volatile boolean closing = false GroovyShellEndpoint() { super() } @Override void onOpen(Session session, EndpointConfig config) { this.destroyInitialEci = false super.onOpen(session, config) logger.info("Opening GroovyShellEndpoint session ${session.getId()} for user ${userId}:${username}") eci = ecf.getEci() if (!eci.userFacade.hasPermission("GROOVY_SHELL_WEB")) { throw new IllegalAccessException("User ${username} does not have permission to use Groovy Shell") } exec = Executors.newSingleThreadExecutor(Thread.ofVirtual().name("GroovyShell-" + session.getId(), 0).factory()) scheduler = Executors.newSingleThreadScheduledExecutor() Writer wsWriter = new WsWriter(session) def binding = eci.getContextBinding() binding.setVariable("out", new PrintWriter(wsWriter, true)) binding.setVariable("err", new PrintWriter(wsWriter, true)) groovyShell = new GroovyShell(ecf.classLoader, binding) resetIdleTimer() } @Override void onMessage(String message) { if (closing || groovyShell == null) return String trimmed = message?.trim() if (trimmed == ':exit') { try { checkSend("Ending session.${System.lineSeparator()}") session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Client requested exit")) } catch (Throwable t) { logger.trace("Error in closing session", t) } return } suspendIdleTimer() try { exec.submit { scheduleEvalTimeout() registerEci() eci.artifactExecutionFacade.disableAuthz() try { Object result = groovyShell.evaluate(message) checkSend(result.toString() + System.lineSeparator()) } catch (Throwable t) { logger.error("GroovyShell evaluation error", t) checkSend("ERROR: ${t.class.simpleName}: ${t.message}${System.lineSeparator()}") } finally { try { if (!closing) eci.artifactExecutionFacade.enableAuthz() } finally { if (!closing) deregisterEci() cancelEvalTimeout() resumeIdleTimer() } } } } catch (Throwable t) { resumeIdleTimer() } } @Override void onClose(Session session, CloseReason closeReason) { if (closing) { super.onClose(session, closeReason) return } closing = true logger.info("Closing GroovyShellEndpoint session ${session.getId()} for user ${userId}:${username}") try { idleTask?.cancel(false) evalTimeoutTask?.cancel(false) scheduler?.shutdownNow() exec?.shutdownNow() groovyShell = null if (eci != null) { try { eci.destroy() } catch (Throwable t) { logger.error("Error destroying ExecutionContext in GroovyShellEndpoint", t) } finally { eci = null } } } finally { super.onClose(session, closeReason) } } /** * Bind this session's ExecutionContext to the current executor thread * for the duration of a single Groovy evaluation. */ void registerEci() { ExecutionContextImpl activeEc = ecfi.activeContext.get() if (activeEc != null && activeEc != eci) { logger.warn("Foreign ExecutionContext found on thread; clearing ThreadLocal only") ecfi.activeContext.remove() ecfi.activeContextMap.remove(Thread.currentThread().threadId()) } eci.forThreadId = Thread.currentThread().threadId() eci.forThreadName = Thread.currentThread().getName() ecfi.activeContext.set(eci) ecfi.activeContextMap.put(Thread.currentThread().threadId(), eci) } /** * Remove the ExecutionContext from the executor thread after evaluation * to avoid leaking it to the next task on the same thread. */ void deregisterEci() { ecfi.activeContext.remove() ecfi.activeContextMap.remove(Thread.currentThread().threadId()) } void resetIdleTimer() { if (closing || scheduler == null || scheduler.isShutdown()) return idleTask?.cancel(false) try { idleTask = scheduler.schedule({ try { if (session?.isOpen()) { session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Idle timeout")) } } catch (Throwable t) { logger.trace('Error in closing session', t) } }, idleSeconds, TimeUnit.SECONDS) } catch (Throwable ignored) { } } void suspendIdleTimer() { idleTask?.cancel(false) idleTask = null } void resumeIdleTimer() { resetIdleTimer() } void scheduleEvalTimeout() { if (closing || scheduler == null || scheduler.isShutdown()) return evalTimeoutTask?.cancel(false) try { evalTimeoutTask = scheduler.schedule({ try { if (session?.isOpen()) { checkSend("ERROR: Evaluation exceeded ${maxEvalSeconds} seconds, closing session.${System.lineSeparator()}") session.close(new CloseReason(CloseReason.CloseCodes.NORMAL_CLOSURE, "Evaluation timeout")) } } catch (Throwable ignored) { } }, maxEvalSeconds, TimeUnit.SECONDS) } catch (Throwable ignored) { } } void cancelEvalTimeout() { evalTimeoutTask?.cancel(false) evalTimeoutTask = null } void checkSend(String text) { try { if (session?.isOpen()) { session.asyncRemote.sendText(text) } } catch (Throwable ignored) { } } static class WsWriter extends Writer { final Session session WsWriter(Session session) { this.session = session } @Override void write(char[] cbuf, int off, int len) { try { if (session?.isOpen()) { session.asyncRemote.sendText(new String(cbuf, off, len)) } } catch (Throwable ignored) { } } @Override void flush() throws IOException { } @Override void close() throws IOException { } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiAbstractEndpoint.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.http.HttpSession import jakarta.websocket.* import jakarta.websocket.server.HandshakeRequest /** * An abstract class for WebSocket Endpoint that does basic setup, including creating an ExecutionContext with the user * logged if they were logged in for the corresponding HttpSession (based on the WebSocket HandshakeRequest, ie the * HTTP upgrade request, tied to an existing HttpSession). * * The main method to implement is the onMessage(String) method. * * If you override the onOpen() method call the super method first. * If you override the onClose() method call the super method last (will clear out all internal fields). */ @CompileStatic abstract class MoquiAbstractEndpoint extends Endpoint implements MessageHandler.Whole { private final static Logger logger = LoggerFactory.getLogger(MoquiAbstractEndpoint.class) protected ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) null protected Session session = (Session) null protected HttpSession httpSession = (HttpSession) null protected HandshakeRequest handshakeRequest = (HandshakeRequest) null protected String userId = (String) null protected String username = (String) null protected boolean destroyInitialEci = true MoquiAbstractEndpoint() { super() } ExecutionContextFactoryImpl getEcf() { return ecfi } HttpSession getHttpSession() { return httpSession } Session getSession() { return session } String getUserId() { return userId } String getUsername() { return username } @Override void onOpen(Session session, EndpointConfig config) { this.session = session ecfi = (ExecutionContextFactoryImpl) config.userProperties.get("executionContextFactory") handshakeRequest = (HandshakeRequest) config.userProperties.get("handshakeRequest") // Jetty 12 EE 11 bug https://github.com/jetty/jetty.project/issues/11809 // httpSession = handshakeRequest != null ? (HttpSession) handshakeRequest.getHttpSession() : (HttpSession) config.userProperties.get("httpSession") httpSession = (HttpSession) config.userProperties.get("httpSession") ExecutionContextImpl eci = ecfi.getEci() try { if (httpSession != null) { eci.userFacade.initFromHttpSession(httpSession) } else if (handshakeRequest != null) { eci.userFacade.initFromHandshakeRequest(handshakeRequest) } else { logger.warn("No HandshakeRequest or HttpSession found opening WebSocket Session ${session.id}, not logging in user") } userId = eci.user.userId username = eci.user.username Long timeout = (Long) config.userProperties.get("maxIdleTimeout") if (timeout != null && session.getMaxIdleTimeout() > 0 && session.getMaxIdleTimeout() < timeout) session.setMaxIdleTimeout(timeout) session.addMessageHandler(this) if (logger.isTraceEnabled()) logger.trace("Opened WebSocket Session ${session.getId()}, userId: ${userId} (${username}), timeout: ${session.getMaxIdleTimeout()}ms") } finally { if (eci != null && destroyInitialEci) { eci.destroy() } } /* logger.info("Opened WebSocket Session ${session.getId()}, parameters: ${session.getRequestParameterMap()}, username: ${session.getUserPrincipal()?.getName()}, config props: ${config.userProperties}") for (String attrName in httpSession.getAttributeNames()) logger.info("WebSocket Session ${session.getId()}, session attribute: ${attrName}=${httpSession.getAttribute(attrName)}") */ } @Override abstract void onMessage(String message) @Override void onClose(Session session, CloseReason closeReason) { this.session = null this.httpSession = null this.handshakeRequest = null this.ecfi = null if (logger.isTraceEnabled()) logger.trace("Closed WebSocket Session ${session.getId()}: ${closeReason.reasonPhrase}") } @Override void onError(Session session, Throwable thr) { if (thr instanceof SocketTimeoutException || (thr.getMessage() != null && thr.getMessage().toLowerCase().contains("timeout"))) { logger.info("Timeout in WebSocket Session ${session.getId()}, User ${userId} (${username}): ${thr.getMessage()}") } else { logger.warn("Error in WebSocket Session ${session.getId()}, User ${userId} (${username})", thr) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiAuthFilter.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.UserFacadeImpl import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.servlet.Filter import jakarta.servlet.FilterChain import jakarta.servlet.FilterConfig import jakarta.servlet.ServletContext import jakarta.servlet.ServletException import jakarta.servlet.ServletRequest import jakarta.servlet.ServletResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse /** Check authentication and permission for servlets other than MoquiServlet, MoquiFopServlet. * Specify permission to check in 'permission' init-param. */ @CompileStatic class MoquiAuthFilter implements Filter { protected final static Logger logger = LoggerFactory.getLogger(MoquiAuthFilter.class) protected FilterConfig filterConfig = null protected String permission = null MoquiAuthFilter() { super() } @Override void init(FilterConfig filterConfig) throws ServletException { this.filterConfig = filterConfig permission = filterConfig.getInitParameter("permission") } @Override void doFilter(ServletRequest req, ServletResponse resp, FilterChain chain) throws IOException, ServletException { if (!(req instanceof HttpServletRequest) || !(resp instanceof HttpServletResponse)) { chain.doFilter(req, resp); return } HttpServletRequest request = (HttpServletRequest) req HttpServletResponse response = (HttpServletResponse) resp // HttpSession session = request.getSession() ServletContext servletContext = req.getServletContext() ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) servletContext.getAttribute("executionContextFactory") // check for and cleanly handle when executionContextFactory is not in place in ServletContext attr if (ecfi == null) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System is initializing, try again soon.") return } ExecutionContextImpl activeEc = ecfi.activeContext.get() if (activeEc != null) { logger.warn("In MoquiAuthFilter.doFilter there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying") activeEc.destroy() } ExecutionContextImpl ec = ecfi.getEci() try { UserFacadeImpl ufi = ec.userFacade ufi.initFromHttpRequest(request, response) if (!ufi.username) { String message = ec.messageFacade.getErrorsString() if (!message) message = "Authentication required" response.sendError(HttpServletResponse.SC_UNAUTHORIZED, message) return } if (permission && !ufi.hasPermission(permission)) { response.sendError(HttpServletResponse.SC_FORBIDDEN, "User ${ufi.username} does not have permission ${permission}") return } chain.doFilter(req, resp) } finally { ec.destroy() } } @Override void destroy() { } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiContextListener.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import jakarta.servlet.DispatcherType import jakarta.servlet.Filter import jakarta.servlet.FilterRegistration import jakarta.servlet.Servlet import jakarta.servlet.ServletContext import jakarta.servlet.ServletContextEvent import jakarta.servlet.ServletContextListener import jakarta.servlet.ServletRegistration import jakarta.websocket.HandshakeResponse import jakarta.websocket.server.HandshakeRequest import jakarta.websocket.server.ServerContainer import jakarta.websocket.server.ServerEndpointConfig import org.moqui.Moqui import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextFactoryImpl.WebappInfo import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class MoquiContextListener implements ServletContextListener { protected final static Logger logger = LoggerFactory.getLogger(MoquiContextListener.class) protected static String getId(ServletContext sc) { String contextPath = sc.getContextPath() return contextPath.length() > 1 ? contextPath.substring(1) : "ROOT" } protected ExecutionContextFactoryImpl ecfi = null @Override void contextInitialized(ServletContextEvent servletContextEvent) { long initStartTime = System.currentTimeMillis() try { ServletContext sc = servletContextEvent.servletContext String webappId = getId(sc) String moquiWebappName = sc.getInitParameter("moqui-name") // before we init the ECF, see if there is a runtime directory in the webappRealPath, and if so set that as the moqui.runtime System property String webappRealPath = sc.getRealPath("/") String embeddedRuntimePath = webappRealPath + "/runtime" if (new File(embeddedRuntimePath).exists()) System.setProperty("moqui.runtime", embeddedRuntimePath) logger.info("Loading Webapp '${moquiWebappName}' (${sc.getServletContextName()}) on ${webappId}, located at: ${webappRealPath}") ecfi = Moqui.dynamicInit(ExecutionContextFactoryImpl.class, sc) // logger.warn("ServletContext (" + (sc != null ? sc.getClass().getName() : "") + ":" + (sc != null && sc.getClass().getClassLoader() != null ? sc.getClass().getClassLoader().getClass().getName() : "") + ")" + " value: " + sc) WebappInfo wi = ecfi.getWebappInfo(moquiWebappName) // add webapp filters List filterNodeList = wi.webappNode.children("filter") filterNodeList = (List) filterNodeList.sort(false, { Integer.parseInt(it.attribute("priority") ?: '5') }) for (MNode filterNode in filterNodeList) { if (filterNode.attribute("enabled") == "false") continue String filterName = filterNode.attribute("name") try { Filter filter = (Filter) Thread.currentThread().getContextClassLoader().loadClass(filterNode.attribute("class")).newInstance() FilterRegistration.Dynamic filterReg = sc.addFilter(filterName, filter) for (MNode initParamNode in filterNode.children("init-param")) { initParamNode.setSystemExpandAttributes(true) filterReg.setInitParameter(initParamNode.attribute("name"), initParamNode.attribute("value") ?: "") } if ("true".equals(filterNode.attribute("async-supported"))) filterReg.setAsyncSupported(true) EnumSet dispatcherTypes = EnumSet.noneOf(DispatcherType.class) for (MNode dispatcherNode in filterNode.children("dispatcher")) { DispatcherType dt = DispatcherType.valueOf(dispatcherNode.getText()) if (dt == null) { logger.warn("Got invalid DispatcherType ${dispatcherNode.getText()} for filter ${filterName}") } dispatcherTypes.add(dt) } Set urlPatternSet = new LinkedHashSet<>() for (MNode urlPatternNode in filterNode.children("url-pattern")) urlPatternSet.add(urlPatternNode.getText()) String[] urlPatterns = urlPatternSet.toArray(new String[urlPatternSet.size()]) filterReg.addMappingForUrlPatterns(dispatcherTypes.size() > 0 ? dispatcherTypes : null, false, urlPatterns) logger.info("Added webapp filter ${filterName} on: ${urlPatterns}, ${dispatcherTypes}") } catch (Exception e) { logger.error("Error adding filter ${filterName}", e) } } // add webapp listeners for (MNode listenerNode in wi.webappNode.children("listener")) { if (listenerNode.attribute("enabled") == "false") continue String className = listenerNode.attribute("class") try { EventListener listener = (EventListener) Thread.currentThread().getContextClassLoader().loadClass(className).newInstance() sc.addListener(listener) logger.info("Added webapp listener ${className}") } catch (Exception e) { logger.error("Error adding listener ${className}", e) } } // add webapp servlets for (MNode servletNode in wi.webappNode.children("servlet")) { if (servletNode.attribute("enabled") == "false") continue String servletName = servletNode.attribute("name") try { Servlet servlet = (Servlet) Thread.currentThread().getContextClassLoader().loadClass(servletNode.attribute("class")).newInstance() ServletRegistration.Dynamic servletReg = sc.addServlet(servletName, servlet) for (MNode initParamNode in servletNode.children("init-param")) { initParamNode.setSystemExpandAttributes(true) servletReg.setInitParameter(initParamNode.attribute("name"), initParamNode.attribute("value") ?: "") } String loadOnStartupStr = servletNode.attribute("load-on-startup") ?: "1" servletReg.setLoadOnStartup(loadOnStartupStr as int) if ("true".equals(servletNode.attribute("async-supported"))) servletReg.setAsyncSupported(true) Set urlPatternSet = new LinkedHashSet<>() for (MNode urlPatternNode in servletNode.children("url-pattern")) urlPatternSet.add(urlPatternNode.getText()) String[] urlPatterns = urlPatternSet.toArray(new String[urlPatternSet.size()]) Set alreadyMapped = servletReg.addMapping(urlPatterns) if (alreadyMapped) logger.warn("For servlet ${servletName} to following URL patterns were already mapped: ${alreadyMapped}") logger.info("Added servlet ${servletName} on: ${urlPatterns}") } catch (Exception e) { logger.error("Error adding servlet ${servletName}", e) } } // NOTE: webapp.session-config.@timeout handled in MoquiSessionListener // WebSocket Endpoint Setup ServerContainer wsServer = ecfi.getServerContainer() if (wsServer != null) { logger.info("Found WebSocket ServerContainer ${wsServer.class.name}") if (wi.webappNode.attribute("websocket-timeout")) wsServer.setDefaultMaxSessionIdleTimeout(Long.valueOf(wi.webappNode.attribute("websocket-timeout"))) for (MNode endpointNode in wi.webappNode.children("endpoint")) { if (endpointNode.attribute("enabled") == "false") continue try { Class endpointClass = Thread.currentThread().getContextClassLoader().loadClass(endpointNode.attribute("class")) String endpointPath = endpointNode.attribute("path") if (!endpointPath.startsWith("/")) endpointPath = "/" + endpointPath MoquiServerEndpointConfigurator configurator = new MoquiServerEndpointConfigurator(ecfi, endpointNode.attribute("timeout")) ServerEndpointConfig sec = ServerEndpointConfig.Builder.create(endpointClass, endpointPath) .configurator(configurator).build() wsServer.addEndpoint(sec) logger.info("Added WebSocket endpoint ${endpointPath} for class ${endpointClass.name}") } catch (Exception e) { logger.error("Error WebSocket endpoint on ${endpointNode.attribute("path")}", e) } } } else { logger.info("No WebSocket ServerContainer found, web sockets disabled") } // run after-startup actions if (wi.afterStartupActions) { ExecutionContextImpl eci = ecfi.getEci() wi.afterStartupActions.run(eci) eci.destroy() } logger.info("Moqui Framework initialized in ${(System.currentTimeMillis() - initStartTime)/1000} seconds") } catch (Throwable t) { logger.error("Error initializing webapp context: ${t.toString()}", t) throw t } } @Override void contextDestroyed(ServletContextEvent servletContextEvent) { ServletContext sc = servletContextEvent.servletContext String webappId = getId(sc) String moquiWebappName = sc.getInitParameter("moqui-name") logger.info("Context Destroyed for Moqui webapp [${webappId}]") if (ecfi != null) { // run before-shutdown actions WebappInfo wi = ecfi.getWebappInfo(moquiWebappName) if (wi.beforeShutdownActions) { ExecutionContextImpl eci = ecfi.getEci() wi.beforeShutdownActions.run(eci) eci.destroy() } ecfi.destroy() ecfi = null } else { logger.warn("No ExecutionContextFactoryImpl referenced, not destroying") } logger.info("Destroyed Moqui Execution Context Factory for webapp [${webappId}]") } static class MoquiServerEndpointConfigurator extends ServerEndpointConfig.Configurator { // for a good explanation of javax.websocket details related to this see: // http://stackoverflow.com/questions/17936440/accessing-httpsession-from-httpservletrequest-in-a-web-socket-serverendpoint ExecutionContextFactoryImpl ecfi Long maxIdleTimeout = null MoquiServerEndpointConfigurator(ExecutionContextFactoryImpl ecfi, String timeoutStr) { this.ecfi = ecfi if (timeoutStr) maxIdleTimeout = Long.valueOf(timeoutStr) } @Override boolean checkOrigin(String originHeaderValue) { // logger.info("New ServerEndpoint Origin: ${originHeaderValue}") // TODO: check this against what? will be something like 'http://localhost:8080' return super.checkOrigin(originHeaderValue) } @Override void modifyHandshake(ServerEndpointConfig config, HandshakeRequest request, HandshakeResponse response) { config.getUserProperties().put("handshakeRequest", request) config.getUserProperties().put("httpSession", request.getHttpSession()) config.getUserProperties().put("executionContextFactory", ecfi) if (maxIdleTimeout != null) config.getUserProperties().put("maxIdleTimeout", maxIdleTimeout) } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiFopServlet.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.context.ArtifactTarpitException import org.moqui.impl.context.ExecutionContextImpl import org.moqui.util.StringUtilities import jakarta.servlet.ServletConfig import jakarta.servlet.ServletException import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.http.HttpServletResponse import org.moqui.screen.ScreenRender import org.moqui.context.ArtifactAuthorizationException import org.moqui.impl.context.ExecutionContextFactoryImpl import javax.xml.transform.stream.StreamSource import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class MoquiFopServlet extends HttpServlet { protected final static Logger logger = LoggerFactory.getLogger(MoquiFopServlet.class) MoquiFopServlet() { super() } @Override void init(ServletConfig config) throws ServletException { super.init(config) String webappName = config.getInitParameter("moqui-name") ?: config.getServletContext().getInitParameter("moqui-name") logger.info("${config.getServletName()} initialized for webapp ${webappName}") } @Override void service(HttpServletRequest request, HttpServletResponse response) throws ServletException, IOException { ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory") String moquiWebappName = getServletContext().getInitParameter("moqui-name") if (ecfi == null || moquiWebappName == null) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System is initializing, try again soon.") return } // handle CORS actual and preflight request headers if (MoquiServlet.handleCors(request, response, moquiWebappName, ecfi)) return long startTime = System.currentTimeMillis() if (logger.traceEnabled) logger.trace("Start request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]") ExecutionContextImpl activeEc = ecfi.activeContext.get() if (activeEc != null) { logger.warn("In MoquiServlet.service there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying") activeEc.destroy() } ExecutionContextImpl ec = ecfi.getEci() String xslFoText = null try { ec.initWebFacade(moquiWebappName, request, response) ec.web.requestAttributes.put("moquiRequestStartTime", startTime) ArrayList pathInfoList = ec.web.getPathInfoList() ScreenRender sr = ec.screen.makeRender().webappName(moquiWebappName).renderMode("xsl-fo") .rootScreenFromHost(request.getServerName()).screenPath(pathInfoList) xslFoText = sr.render() if (ec.message.hasError()) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, ec.message.errorsString) return } // logger.warn("======== XSL-FO content:\n${xslFoText}") if (logger.traceEnabled) logger.trace("XSL-FO content:\n${xslFoText}") String contentType = (String) ec.web.requestParameters."contentType" ?: "application/pdf" response.setContentType(contentType) String filename = (ec.web.parameters.get("filename") as String) ?: (ec.web.parameters.get("saveFilename") as String) if (filename) { String utfFilename = StringUtilities.encodeAsciiFilename(filename) response.setHeader("Content-Disposition", "attachment; filename=\"${filename}\"; filename*=utf-8''${utfFilename}") } else { response.setHeader("Content-Disposition", "inline") } // special case disable authz for resource access boolean enableAuthz = !ecfi.getExecutionContext().getArtifactExecution().disableAuthz() try { /* FUTURE: pre-render to get page count, then pass in final rendered streamed to client Integer pageCount = ec.resource.xslFoTransform(new StreamSource(new StringReader(xslFoText)), null, org.apache.commons.io.output.NullOutputStream.NULL_OUTPUT_STREAM, contentType) logger.info("Rendered ${pathInfo} as ${contentType} has ${pageCount} pages") */ ec.resource.xslFoTransform(new StreamSource(new StringReader(xslFoText)), null, response.getOutputStream(), contentType) } finally { if (enableAuthz) ecfi.getExecutionContext().getArtifactExecution().enableAuthz() } if (logger.infoEnabled) logger.info("Finished XSL-FO request to ${pathInfoList}, content type ${response.getContentType()} in ${System.currentTimeMillis()-startTime}ms; session ${request.session.id} thread ${Thread.currentThread().id}:${Thread.currentThread().name}") } catch (ArtifactAuthorizationException e) { // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures // See ScreenRenderImpl.checkWebappSettings for authc and SC_UNAUTHORIZED handling logger.warn((String) "Web Access Forbidden (no authz): " + e.message) response.sendError(HttpServletResponse.SC_FORBIDDEN, e.message) } catch (ArtifactTarpitException e) { logger.warn((String) "Web Too Many Requests (tarpit): " + e.message) if (e.getRetryAfterSeconds()) response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details response.sendError(429, e.message) } catch (ScreenResourceNotFoundException e) { logger.warn((String) "Web Resource Not Found: " + e.message) response.sendError(HttpServletResponse.SC_NOT_FOUND, e.message) } catch (Throwable t) { logger.error("Error transforming XSL-FO content:\n${xslFoText}", t) if (ec.message.hasError()) { String errorsString = ec.message.errorsString logger.error(errorsString, t) response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, errorsString) } else { throw t } } finally { // make sure everything is cleaned up ec.destroy() } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiServlet.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.context.ArtifactTarpitException import org.moqui.context.AuthenticationRequiredException import org.moqui.context.ArtifactAuthorizationException import org.moqui.context.NotificationMessage import org.moqui.context.WebMediaTypeException import org.moqui.impl.context.ExecutionContextFactoryImpl import org.moqui.impl.context.ExecutionContextImpl import org.moqui.impl.context.WebFacadeImpl import org.moqui.impl.screen.ScreenRenderImpl import org.moqui.util.MNode import org.slf4j.Logger import org.slf4j.LoggerFactory import org.slf4j.MDC import jakarta.servlet.ServletConfig import jakarta.servlet.http.HttpServlet import jakarta.servlet.http.HttpServletResponse import jakarta.servlet.http.HttpServletRequest import jakarta.servlet.ServletException @CompileStatic class MoquiServlet extends HttpServlet { protected final static Logger logger = LoggerFactory.getLogger(MoquiServlet.class) MoquiServlet() { super() } @Override void init(ServletConfig config) throws ServletException { super.init(config) String webappName = config.getInitParameter("moqui-name") ?: config.getServletContext().getInitParameter("moqui-name") logger.info("${config.getServletName()} initialized for webapp ${webappName}") } @Override void service(HttpServletRequest request, HttpServletResponse response) { ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) getServletContext().getAttribute("executionContextFactory") String webappName = getInitParameter("moqui-name") ?: getServletContext().getInitParameter("moqui-name") // check for and cleanly handle when executionContextFactory is not in place in ServletContext attr if (ecfi == null || webappName == null) { response.sendError(HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "System is initializing, try again soon.") return } // "Connection:Upgrade or " "Upgrade".equals(request.getHeader("Connection")) || if ("websocket".equals(request.getHeader("Upgrade"))) { logger.warn("Got request for Upgrade:websocket which should have been handled by servlet container, returning error") response.sendError(HttpServletResponse.SC_NOT_IMPLEMENTED) return } // handle CORS actual and preflight request headers if (handleCors(request, response, webappName, ecfi)) return if (!request.characterEncoding) request.setCharacterEncoding("UTF-8") long startTime = System.currentTimeMillis() if (logger.traceEnabled) logger.trace("Start request to [${request.getPathInfo()}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]") // logger.warn("Start request to [${pathInfo}] at time [${startTime}] in session [${request.session.id}] thread [${Thread.currentThread().id}:${Thread.currentThread().name}]", new Exception("Start request")) if (MDC.get("moqui_userId") != null) logger.warn("In MoquiServlet.service there is already a userId in thread (${Thread.currentThread().id}:${Thread.currentThread().name}), removing") MDC.remove("moqui_userId") MDC.remove("moqui_visitorId") // make sure no transaction is active in thread if (ecfi.transactionFacade.isTransactionInPlace()) { logger.warn("In MoquiServlet.service there is already a transaction for thread [${Thread.currentThread().id}:${Thread.currentThread().name}], closing") try { ecfi.transactionFacade.destroyAllInThread() } catch (Throwable t) { logger.error("Error destroying transaction already in place in MoquiServlet.service", t) } } // check for active ExecutionContext ExecutionContextImpl activeEc = ecfi.activeContext.get() if (activeEc != null) { logger.warn("In MoquiServlet.service there is already an ExecutionContext for user ${activeEc.user.username} (from ${activeEc.forThreadId}:${activeEc.forThreadName}) in this thread (${Thread.currentThread().id}:${Thread.currentThread().name}), destroying") try { activeEc.destroy() } catch (Throwable t) { logger.error("Error destroying ExecutionContext already in place in MoquiServlet.service", t) } } // get a new ExecutionContext ExecutionContextImpl ec = ecfi.getEci() /** NOTE to set render settings manually do something like this, but it is not necessary to set these things * for a web page render because if we call render(request, response) it can figure all of this out as defaults * * ScreenRender render = ec.screen.makeRender().webappName(moquiWebappName).renderMode("html") * .rootScreenFromHost(request.getServerName()).screenPath(pathInfo.split("/") as List) */ ScreenRenderImpl sri = null try { ec.initWebFacade(webappName, request, response) ec.web.requestAttributes.put("moquiRequestStartTime", startTime) sri = (ScreenRenderImpl) ec.screenFacade.makeRender().saveHistory(true) sri.render(request, response) } catch (AuthenticationRequiredException e) { logger.warn("Web Unauthorized (no authc): " + e.message) sendErrorResponse(request, response, HttpServletResponse.SC_UNAUTHORIZED, "unauthorized", null, e, ecfi, webappName, sri) } catch (ArtifactAuthorizationException e) { // SC_UNAUTHORIZED 401 used when authc/login fails, use SC_FORBIDDEN 403 for authz failures // See ScreenRenderImpl.checkWebappSettings for authc and SC_UNAUTHORIZED handling logger.warn("Web Access Forbidden (no authz): " + e.message) sendErrorResponse(request, response, HttpServletResponse.SC_FORBIDDEN, "forbidden", null, e, ecfi, webappName, sri) } catch (ScreenResourceNotFoundException e) { logger.warn("Web Resource Not Found: " + e.message) sendErrorResponse(request, response, HttpServletResponse.SC_NOT_FOUND, "not-found", null, e, ecfi, webappName, sri) } catch (ArtifactTarpitException e) { logger.warn("Web Too Many Requests (tarpit): " + e.message) if (e.getRetryAfterSeconds()) response.addIntHeader("Retry-After", e.getRetryAfterSeconds()) // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details sendErrorResponse(request, response, 429, "too-many", null, e, ecfi, webappName, sri) } catch (WebMediaTypeException e) { logger.warn("Web Unsupported Media Type: " + e.message) sendErrorResponse(request, response, HttpServletResponse.SC_UNSUPPORTED_MEDIA_TYPE, "media-type", e.message, e, ecfi, webappName, sri) } catch (Throwable t) { if (ec.message.hasError()) { String errorsString = ec.message.errorsString logger.error(errorsString, t) if ("true".equals(request.getAttribute("moqui.login.error"))) { sendErrorResponse(request, response, HttpServletResponse.SC_UNAUTHORIZED, "unauthorized", errorsString, t, ecfi, webappName, sri) } else { sendErrorResponse(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "internal-error", errorsString, t, ecfi, webappName, sri) } } else { String tString = t.toString() if (isBrokenPipe(t)) { logger.error("Internal error processing request: " + tString) } else { logger.error("Internal error processing request: " + tString, t) } sendErrorResponse(request, response, HttpServletResponse.SC_INTERNAL_SERVER_ERROR, "internal-error", null, t, ecfi, webappName, sri) } } finally { /* this is here just for kicks, uncomment to log a list of all artifacts hit/used in the screen render StringBuilder hits = new StringBuilder() hits.append("Artifacts hit in this request: ") for (def aei in ec.artifactExecution.history) hits.append("\n").append(aei) logger.info(hits.toString()) */ // make sure everything is cleaned up ec.destroy() } /* definitely don't want this normally, but uncomment to help debug session attribute issues: logger.warn("Thread ClassLoader ${Thread.currentThread().getContextClassLoader()?.getClass()?.getName()}") for (String name in ec.web.session.getAttributeNames()) { Object value = ec.web.session.getAttribute(name) logger.warn("Session attr " + name + "(" + (value != null ? value.getClass().getName() : "") + ":" + (value != null && value.getClass().getClassLoader() != null ? value.getClass().getClassLoader().getClass().getName() : "") + ")" + " value: " + value) } */ } /** Handles CORS headers and if this a CORS preflight request or the origin is not allowed sends the proper response and returns true (caller should then not respond, ie just quit via return) */ static boolean handleCors(HttpServletRequest request, HttpServletResponse response, String webappName, ExecutionContextFactoryImpl ecfi) { ExecutionContextFactoryImpl.WebappInfo webappInfo = ecfi.getWebappInfo(webappName) String originHeader = request.getHeader("Origin") if (originHeader != null && !originHeader.isEmpty() && webappInfo != null && !"false".equals(webappInfo.webappNode.attribute("handle-cors"))) { originHeader = originHeader.toLowerCase() // generate Access-Control-Allow-Origin based on Origin, if allowed Set allowOriginSet = webappInfo.allowOriginSet int originSepIdx = originHeader.indexOf("://") String originDomain = originSepIdx > 0 ? originHeader.substring(originSepIdx + 3) : originHeader int originDomColonIdx = originDomain.indexOf(":") if (originDomColonIdx > 0) originDomain = originDomain.substring(0, originDomColonIdx) // if * allowed or Origin domain matches request domain always allow (same origin) String serverName = request.getServerName() URL requestUrl = new URL(request.getRequestURL().toString()) String hostName = requestUrl.getHost() if (allowOriginSet.contains("*") || originDomain == serverName || originDomain == hostName) { response.setHeader("Access-Control-Allow-Origin", originHeader) } else { if (allowOriginSet.contains(originHeader) || allowOriginSet.contains(originDomain)) { response.setHeader("Access-Control-Allow-Origin", originHeader) } else { // no luck with simpler match, see if any configured domain matches by dot-separated segment // for example: moqui.org ==> 'org','moqui' so www.moqui.org ('org','moqui','www') will match but foo-moqui.org ('org','foo-moqui') will not boolean foundMatch = false for (String allowOrigin in allowOriginSet) { String[] originArray = originDomain.split("\\.").reverse() String[] allowArray = allowOrigin.split("\\.").reverse() // logger.warn("allowArray: ${allowArray} originArray: ${originArray}") boolean allMatched = true for (int i = 0; i < allowArray.length; i++) { if (allowArray[i] != originArray[i]) { allMatched = false break } } if (allMatched) { foundMatch = true break } } if (foundMatch) { response.setHeader("Access-Control-Allow-Origin", originHeader) } else { logger.warn("Returning 401, Origin ${originHeader} not allowed for configuration ${allowOriginSet} or server name ${serverName} or request host ${hostName}") // Origin not allowed, send 401 response // response.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Origin not allowed") WebFacadeImpl.sendError(HttpServletResponse.SC_UNAUTHORIZED, "Origin not allowed", null, request, response) return true } } } String acRequestMethod = request.getHeader("Access-Control-Request-Method") if ("OPTIONS".equals(request.getMethod()) && acRequestMethod != null && !acRequestMethod.isEmpty()) { // String acRequestHeaders = request.getHeader("Access-Control-Request-Headers") webappInfo.addHeaders("cors-preflight", response) response.setStatus(HttpServletResponse.SC_OK) return true } else { webappInfo.addHeaders("cors-actual", response) return false } } return false } static void sendErrorResponse(HttpServletRequest request, HttpServletResponse response, int errorCode, String errorType, String message, Throwable origThrowable, ExecutionContextFactoryImpl ecfi, String moquiWebappName, ScreenRenderImpl sri) { if (message == null && origThrowable != null) { List msgList = new ArrayList<>(10) Throwable curt = origThrowable while (curt != null) { msgList.add(curt.message) curt = curt.getCause() } int msgListSize = msgList.size() if (msgListSize > 4) msgList = (List) msgList.subList(msgListSize - 4, msgListSize) message = msgList.join(" ") } if (ecfi != null && errorCode == HttpServletResponse.SC_INTERNAL_SERVER_ERROR && !isBrokenPipe(origThrowable)) { ExecutionContextImpl ec = ecfi.getEci() ec.makeNotificationMessage().topic("WebServletError").type(NotificationMessage.danger) .title('''Web Error ${errorCode?:''} (${username?:'no user'}) ${path?:''} ${message?:'N/A'}''') .message([errorCode:errorCode, errorType:errorType, message:message, exception:origThrowable?.toString(), path:ec.web?.getPathInfo(), parameters:ec.web?.getRequestParameters(), username:ec.user.username] as Map) .send() } if (ecfi == null) { response.sendError(errorCode, message) return } ExecutionContextImpl ec = ecfi.getEci() String acceptHeader = request.getHeader("Accept") boolean acceptHtml = acceptHeader != null && acceptHeader.contains("text/html") MNode errorScreenNode = acceptHtml ? ecfi.getWebappInfo(moquiWebappName)?.getErrorScreenNode(errorType) : null if (errorScreenNode != null) { try { ec.context.put("errorCode", errorCode) ec.context.put("errorType", errorType) ec.context.put("errorMessage", message) ec.context.put("errorThrowable", origThrowable) String screenPathAttr = errorScreenNode.attribute("screen-path") // NOTE 20180228: this seems to be working fine now and Jetty (at least) is returning the 404/etc responses with the custom HTML body unlike before response.setStatus(errorCode) ec.screen.makeRender().webappName(moquiWebappName).renderMode("html") .rootScreenFromHost(request.getServerName()).screenPath(Arrays.asList(screenPathAttr.split("/"))) .render(request, response) } catch (Throwable t) { logger.error("Error rendering ${errorType} error screen, sending code ${errorCode} with message: ${message}", t) response.sendError(errorCode, message) } } else { WebFacadeImpl.sendError(errorCode, message, origThrowable, request, response) } } static boolean isBrokenPipe(Throwable throwable) { Throwable curt = throwable while (curt != null) { // could constrain more looking for "Broken pipe" message // works for Jetty, may have different exception patterns on other servlet containers if (curt instanceof IOException) return true curt = curt.getCause() } return false } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/MoquiSessionListener.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import jakarta.servlet.http.HttpSession import jakarta.servlet.http.HttpSessionAttributeListener import jakarta.servlet.http.HttpSessionBindingEvent import jakarta.servlet.http.HttpSessionEvent import jakarta.servlet.http.HttpSessionListener import java.sql.Timestamp import org.moqui.Moqui import org.moqui.impl.context.ExecutionContextFactoryImpl import org.slf4j.Logger import org.slf4j.LoggerFactory @CompileStatic class MoquiSessionListener implements HttpSessionListener, HttpSessionAttributeListener { protected final static Logger logger = LoggerFactory.getLogger(MoquiSessionListener.class) private HashMap visitIdBySession = new HashMap<>() @Override void sessionCreated(HttpSessionEvent event) { HttpSession session = event.session ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() String moquiWebappName = session.servletContext.getInitParameter("moqui-name") ExecutionContextFactoryImpl.WebappInfo wi = ecfi?.getWebappInfo(moquiWebappName) if (wi?.sessionTimeoutSeconds != null) session.setMaxInactiveInterval(wi.sessionTimeoutSeconds) } @Override void sessionDestroyed(HttpSessionEvent event) { String sessionId = event.session.id String visitId = visitIdBySession.remove(sessionId) if (!visitId) { try { visitId = event.session.getAttribute("moqui.visitId") } catch (Throwable t) { logger.warn("No saved visitId for session ${sessionId} and error getting moqui.visitId session attribute: " + t.toString()) } } if (!visitId) { if (logger.traceEnabled) logger.trace("Not closing visit for session ${sessionId}, no value for visitId session attribute") return } closeVisit(visitId, sessionId) } @Override void attributeAdded(HttpSessionBindingEvent event) { if ("moqui.visitId".equals(event.name)) visitIdBySession.put(event.session.id, event.value.toString()) } @Override void attributeReplaced(HttpSessionBindingEvent event) { if ("moqui.visitId".equals(event.name)) { String sessionId = event.session.id String oldValue = event.value.toString() if (!oldValue) oldValue = visitIdBySession.get(sessionId) String newValue = event.session.getAttribute("moqui.visitId") if (newValue) visitIdBySession.put(sessionId, newValue) if (oldValue) closeVisit(oldValue, sessionId) } } @Override void attributeRemoved(HttpSessionBindingEvent event) { if ("moqui.visitId".equals(event.name)) { String sessionId = event.session.id String visitId = event.value if (!visitId) { if (logger.traceEnabled) logger.trace("Not closing visit for session ${sessionId}, no value for removed moqui.visitId session attribute") return } closeVisit(visitId, sessionId) } } static void closeVisit(String visitId, String sessionId) { ExecutionContextFactoryImpl ecfi = (ExecutionContextFactoryImpl) Moqui.getExecutionContextFactory() if (ecfi.confXmlRoot.first("server-stats").attribute("visit-enabled") == "false") return // set thruDate on Visit Timestamp thruDate = new Timestamp(System.currentTimeMillis()) ecfi.serviceFacade.sync().name("update", "moqui.server.Visit").parameter("visitId", visitId).parameter("thruDate", thruDate) .disableAuthz().call() if (logger.traceEnabled) logger.trace("Closed visit ${visitId} at ${thruDate} for session ${sessionId}") } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/NotificationEndpoint.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.slf4j.Logger import org.slf4j.LoggerFactory import jakarta.websocket.CloseReason import jakarta.websocket.EndpointConfig import jakarta.websocket.Session @CompileStatic class NotificationEndpoint extends MoquiAbstractEndpoint { private final static Logger logger = LoggerFactory.getLogger(NotificationEndpoint.class) final static String subscribePrefix = "subscribe:" final static String unsubscribePrefix = "unsubscribe:" private Set subscribedTopics = new HashSet<>() NotificationEndpoint() { super() } Set getSubscribedTopics() { subscribedTopics } @Override void onOpen(Session session, EndpointConfig config) { super.onOpen(session, config) getEcf().getNotificationWebSocketListener().registerEndpoint(this) } @Override void onMessage(String message) { if (message.startsWith(subscribePrefix)) { String topics = message.substring(subscribePrefix.length(), message.length()) for (String topic in topics.split(",")) { String trimmedTopic = topic.trim() if (trimmedTopic) subscribedTopics.add(trimmedTopic) } logger.debug("Notification subscribe user ${getUserId()} topics ${subscribedTopics} session ${session?.id}") } else if (message.startsWith(unsubscribePrefix)) { String topics = message.substring(unsubscribePrefix.length(), message.length()) for (String topic in topics.split(",")) { String trimmedTopic = topic.trim() if (trimmedTopic) subscribedTopics.remove(trimmedTopic) } logger.info("Notification unsubscribe for user ${getUserId()} in session ${session?.id}, current topics: ${subscribedTopics}") } else { logger.info("Unknown command prefix for message to NotificationEndpoint in session ${session?.id}: ${message}") } } @Override void onClose(Session session, CloseReason closeReason) { getEcf().getNotificationWebSocketListener().deregisterEndpoint(this) super.onClose(session, closeReason) } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/NotificationWebSocketListener.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.context.ExecutionContextFactory import org.moqui.context.NotificationMessage import org.moqui.context.NotificationMessageListener import org.slf4j.Logger import org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap @CompileStatic class NotificationWebSocketListener implements NotificationMessageListener { private final static Logger logger = LoggerFactory.getLogger(NotificationWebSocketListener.class) private ExecutionContextFactory ecf = null private ConcurrentHashMap> endpointsByUser = new ConcurrentHashMap<>() void registerEndpoint(NotificationEndpoint endpoint) { String userId = endpoint.userId if (userId == null) return String sessionId = endpoint.session.id ConcurrentHashMap registeredEndPoints = endpointsByUser.get(userId) if (registeredEndPoints == null) { registeredEndPoints = new ConcurrentHashMap<>() ConcurrentHashMap existing = endpointsByUser.putIfAbsent(userId, registeredEndPoints) if (existing != null) registeredEndPoints = existing } NotificationEndpoint existing = registeredEndPoints.putIfAbsent(sessionId, endpoint) if (existing != null) logger.warn("Found existing NotificationEndpoint for user ${endpoint.userId} (${existing.username}) session ${sessionId}; not registering additional endpoint") } void deregisterEndpoint(NotificationEndpoint endpoint) { String userId = endpoint.userId if (userId == null) return String sessionId = endpoint.session.id ConcurrentHashMap registeredEndPoints = endpointsByUser.get(userId) if (registeredEndPoints == null) { logger.warn("Tried to deregister endpoing for user ${endpoint.userId} but no endpoints found") return } registeredEndPoints.remove(sessionId) if (registeredEndPoints.size() == 0) endpointsByUser.remove(userId, registeredEndPoints) } @Override void init(ExecutionContextFactory ecf) { this.ecf = ecf } @Override void destroy() { endpointsByUser.clear() this.ecf = null } @Override void onMessage(NotificationMessage nm) { String messageWrapperJson = nm.getWrappedMessageJson() for (String userId in nm.getNotifyUserIds()) { ConcurrentHashMap registeredEndPoints = endpointsByUser.get(userId) if (registeredEndPoints == null) continue for (NotificationEndpoint endpoint in registeredEndPoints.values()) { if (endpoint.session != null && endpoint.session.isOpen() && (endpoint.subscribedTopics.contains("ALL") || endpoint.subscribedTopics.contains(nm.topic))) { endpoint.session.asyncRemote.sendText(messageWrapperJson) nm.markSent(userId) } } } } } ================================================ FILE: framework/src/main/groovy/org/moqui/impl/webapp/ScreenResourceNotFoundException.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.impl.webapp import groovy.transform.CompileStatic import org.moqui.impl.screen.ScreenDefinition @CompileStatic class ScreenResourceNotFoundException extends RuntimeException { ScreenDefinition rootSd List fullPathNameList ScreenDefinition lastSd String pathFromLastScreen String resourceLocation ScreenResourceNotFoundException(ScreenDefinition rootSd, List fullPathNameList, ScreenDefinition lastSd, String pathFromLastScreen, String resourceLocation, Exception cause) { super("Could not find subscreen or transition or file/content [" + pathFromLastScreen + (resourceLocation ? ":" + resourceLocation : "") + "] under screen [" + lastSd?.getLocation() + "] while finding url for path " + fullPathNameList + " under from screen [" + rootSd?.getLocation() + "]", cause) this.rootSd = rootSd this.fullPathNameList = fullPathNameList this.lastSd = lastSd this.pathFromLastScreen = pathFromLastScreen this.resourceLocation = resourceLocation } } ================================================ FILE: framework/src/main/java/org/moqui/BaseArtifactException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.context.ExecutionContextFactory; import java.io.PrintStream; import java.io.PrintWriter; import java.util.Deque; /** BaseArtifactException - extends BaseException to add artifact stack info. */ public class BaseArtifactException extends BaseException { transient private Deque artifactStack = null; public BaseArtifactException(String message) { super(message); populateArtifactStack(); } public BaseArtifactException(String message, Deque curStack) { super(message); artifactStack = curStack; } public BaseArtifactException(String message, Throwable nested) { super(message, nested); populateArtifactStack(); } public BaseArtifactException(String message, Throwable nested, Deque curStack) { super(message, nested); artifactStack = curStack; } public BaseArtifactException(Throwable nested) { super(nested); populateArtifactStack(); } private void populateArtifactStack() { ExecutionContextFactory ecf = Moqui.getExecutionContextFactory(); if (ecf != null) artifactStack = ecf.getExecutionContext().getArtifactExecution().getStack(); } public Deque getArtifactStack() { return artifactStack; } @Override public void printStackTrace() { printStackTrace(System.err); } @Override public void printStackTrace(PrintStream printStream) { if (artifactStack != null && artifactStack.size() > 0) for (ArtifactExecutionInfo aei : artifactStack) printStream.println(aei.toBasicString()); filterStackTrace(this); super.printStackTrace(printStream); } @Override public void printStackTrace(PrintWriter printWriter) { if (artifactStack != null && artifactStack.size() > 0) for (ArtifactExecutionInfo aei : artifactStack) printWriter.println(aei.toBasicString()); filterStackTrace(this); super.printStackTrace(printWriter); } @Override public StackTraceElement[] getStackTrace() { StackTraceElement[] filteredTrace = filterStackTrace(super.getStackTrace()); setStackTrace(filteredTrace); return filteredTrace; } } ================================================ FILE: framework/src/main/java/org/moqui/BaseException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui; import java.io.PrintStream; import java.io.PrintWriter; import java.util.ArrayList; import java.util.List; /** BaseException - the base/root exception for all exception classes in Moqui Framework. */ public class BaseException extends RuntimeException { public BaseException(String message) { super(message); } public BaseException(String message, Throwable nested) { super(message, nested); } public BaseException(Throwable nested) { super(nested); } @Override public void printStackTrace() { filterStackTrace(this); super.printStackTrace(); } @Override public void printStackTrace(PrintStream printStream) { filterStackTrace(this); super.printStackTrace(printStream); } @Override public void printStackTrace(PrintWriter printWriter) { filterStackTrace(this); super.printStackTrace(printWriter); } @Override public StackTraceElement[] getStackTrace() { StackTraceElement[] filteredTrace = filterStackTrace(super.getStackTrace()); setStackTrace(filteredTrace); return filteredTrace; } public static Throwable filterStackTrace(Throwable t) { t.setStackTrace(filterStackTrace(t.getStackTrace())); if (t.getCause() != null) filterStackTrace(t.getCause()); return t; } public static StackTraceElement[] filterStackTrace(StackTraceElement[] orig) { List newList = new ArrayList<>(orig.length); for (StackTraceElement ste: orig) { String cn = ste.getClassName(); if (cn.startsWith("freemarker.core.") || cn.startsWith("freemarker.ext.beans.") || cn.startsWith("org.eclipse.jetty.") || cn.startsWith("java.lang.reflect.") || cn.startsWith("sun.reflect.") || cn.startsWith("org.codehaus.groovy.") || cn.startsWith("groovy.lang.")) { continue; } // if ("renderSingle".equals(ste.getMethodName()) && cn.startsWith("org.moqui.impl.screen.ScreenSection")) continue; // if (("internalRender".equals(ste.getMethodName()) || "doActualRender".equals(ste.getMethodName())) && cn.startsWith("org.moqui.impl.screen.ScreenRenderImpl")) continue; if (("call".equals(ste.getMethodName()) || "callCurrent".equals(ste.getMethodName())) && ste.getLineNumber() == -1) continue; //System.out.println("Adding className: " + cn + ", line: " + ste.getLineNumber()); newList.add(ste); } //System.out.println("Called getFilteredStackTrace, orig.length=" + orig.length + ", newList.size()=" + newList.size()); return newList.toArray(new StackTraceElement[newList.size()]); } } ================================================ FILE: framework/src/main/java/org/moqui/Moqui.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui; import org.moqui.context.ArtifactExecutionInfo; import org.moqui.context.ExecutionContext; import org.moqui.context.ExecutionContextFactory; import org.moqui.entity.EntityDataLoader; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import jakarta.servlet.ServletContext; import java.lang.reflect.InvocationTargetException; import java.util.*; /** * This is a base class that implements a simple way to access the Moqui framework for use in simple deployments where * there is nothing available like a webapp or an OSGi component. * * In deployments where a static reference to the ExecutionContextFactory is not helpful, or not possible, this does * not need to be used and the ExecutionContextFactory instance should be referenced and used from somewhere else. */ @SuppressWarnings("unused") public class Moqui { protected final static Logger logger = LoggerFactory.getLogger(Moqui.class); private static ExecutionContextFactory activeExecutionContextFactory = null; private static final ServiceLoader executionContextFactoryLoader = ServiceLoader.load(ExecutionContextFactory.class); static { // only do this if the moqui.init.static System property is true if ("true".equals(System.getProperty("moqui.init.static"))) { // initialize the activeExecutionContextFactory from configuration using java.util.ServiceLoader // the implementation class name should be in: "META-INF/services/org.moqui.context.ExecutionContextFactory" activeExecutionContextFactory = executionContextFactoryLoader.iterator().next(); } } public static void dynamicInit(ExecutionContextFactory executionContextFactory) { if (activeExecutionContextFactory != null && !activeExecutionContextFactory.isDestroyed()) throw new IllegalStateException("Active ExecutionContextFactory already in place, cannot set one dynamically."); activeExecutionContextFactory = executionContextFactory; } public static K dynamicInit(Class ecfClass, ServletContext sc) throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { if (activeExecutionContextFactory != null && !activeExecutionContextFactory.isDestroyed()) throw new IllegalStateException("Active ExecutionContextFactory already in place, cannot set one dynamically."); K newEcf = ecfClass.getDeclaredConstructor().newInstance(); activeExecutionContextFactory = newEcf; // check for an empty DB if (newEcf.checkEmptyDb()) { logger.warn("Data loaded into empty DB, re-initializing ExecutionContextFactory"); // destroy old ECFI newEcf.destroy(); // create new ECFI to get framework init data from DB newEcf = ecfClass.getDeclaredConstructor().newInstance(); activeExecutionContextFactory = newEcf; } if (sc != null) { // tell ECF about the ServletContext newEcf.initServletContext(sc); // set SC attribute and Moqui class static reference sc.setAttribute("executionContextFactory", newEcf); } return newEcf; } public static K dynamicReInit(Class ecfClass, ServletContext sc) throws InstantiationException, IllegalAccessException, NoSuchMethodException, InvocationTargetException { // handle Servlet pause then resume taking requests after by removing executionContextFactory attribute if (sc.getAttribute("executionContextFactory") != null) sc.removeAttribute("executionContextFactory"); if (activeExecutionContextFactory != null) { if (!activeExecutionContextFactory.isDestroyed()) { activeExecutionContextFactory.destroyActiveExecutionContext(); activeExecutionContextFactory.destroy(); } activeExecutionContextFactory = null; System.gc(); } return dynamicInit(ecfClass, sc); } public static ExecutionContextFactory getExecutionContextFactory() { return activeExecutionContextFactory; } public static ExecutionContext getExecutionContext() { return activeExecutionContextFactory.getExecutionContext(); } /** This should be called when it is known a context won't be used any more, such as at the end of a web request or service execution. */ public static void destroyActiveExecutionContext() { activeExecutionContextFactory.destroyActiveExecutionContext(); } /** This should be called when the process is terminating to clean up framework and tool operations and resources. */ public static void destroyActiveExecutionContextFactory() { activeExecutionContextFactory.destroy(); } /** This method is meant to be run from a command-line interface and handle data loading in a generic way. * @param argMap Arguments, generally from command line, to configure this data load. */ public static void loadData(Map argMap) { if (argMap.containsKey("raw") || argMap.containsKey("no-fk-create")) System.setProperty("entity_disable_fk_create", "true"); // make sure we have a factory, even if moqui.init.static != true if (activeExecutionContextFactory == null) activeExecutionContextFactory = executionContextFactoryLoader.iterator().next(); ExecutionContext ec = activeExecutionContextFactory.getExecutionContext(); // disable authz and add an artifact set to anonymous authorized all ec.getArtifactExecution().disableAuthz(); ec.getArtifactExecution().push("loadData", ArtifactExecutionInfo.AT_OTHER, ArtifactExecutionInfo.AUTHZA_ALL, false); ec.getArtifactExecution().setAnonymousAuthorizedAll(); // login anonymous user ec.getUser().loginAnonymousIfNoUser(); // set the data load parameters EntityDataLoader edl = ec.getEntity().makeDataLoader(); if (argMap.containsKey("types")) { String types = argMap.get("types"); if (!"all".equals(types)) edl.dataTypes(new HashSet<>(Arrays.asList(types.split(",")))); } if (argMap.containsKey("components")) edl.componentNameList(Arrays.asList(argMap.get("components").split(","))); if (argMap.containsKey("location")) edl.location(argMap.get("location")); if (argMap.containsKey("timeout")) edl.transactionTimeout(Integer.valueOf(argMap.get("timeout"))); if (argMap.containsKey("dummy-fks")) edl.dummyFks(true); if (argMap.containsKey("raw") || argMap.containsKey("no-fk-create")) edl.disableFkCreate(true); if (argMap.containsKey("raw") || argMap.containsKey("use-try-insert")) edl.useTryInsert(true); if (argMap.containsKey("raw") || argMap.containsKey("disable-eeca")) edl.disableEntityEca(true); if (argMap.containsKey("raw") || argMap.containsKey("disable-audit-log")) edl.disableAuditLog(true); if (argMap.containsKey("raw") || argMap.containsKey("disable-data-feed")) edl.disableDataFeed(true); // do the data load try { long startTime = System.currentTimeMillis(); long records = edl.load(); long totalSeconds = (System.currentTimeMillis() - startTime)/1000; logger.info("Loaded [" + records + "] records in " + totalSeconds + " seconds."); } catch (Throwable t) { System.out.println("Error loading data: " + t.toString()); t.printStackTrace(); } // cleanup and quit activeExecutionContextFactory.destroyActiveExecutionContext(); activeExecutionContextFactory.destroy(); } } ================================================ FILE: framework/src/main/java/org/moqui/context/ArtifactAuthorizationException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; import java.util.Deque; /** Thrown when artifact authz fails. */ public class ArtifactAuthorizationException extends BaseArtifactException { transient private ArtifactExecutionInfo artifactInfo = null; public ArtifactAuthorizationException(String str) { super(str); } public ArtifactAuthorizationException(String str, Throwable nested) { super(str, nested); } public ArtifactAuthorizationException(String str, ArtifactExecutionInfo curInfo, Deque curStack) { super(str, curStack); artifactInfo = curInfo; } public ArtifactExecutionInfo getArtifactInfo() { return artifactInfo; } } ================================================ FILE: framework/src/main/java/org/moqui/context/ArtifactExecutionFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.util.ArrayList; import java.util.Deque; import java.util.List; /** For information about artifacts as they are being executed. */ public interface ArtifactExecutionFacade { /** Gets information about the current artifact being executed, and about authentication and authorization for * that artifact. * * @return Current (most recent) ArtifactExecutionInfo */ ArtifactExecutionInfo peek(); /** Push onto the artifact stack. This is generally called internally by the framework and does not need to be used * in application code. */ void push(ArtifactExecutionInfo aei, boolean requiresAuthz); ArtifactExecutionInfo push(String name, ArtifactExecutionInfo.ArtifactType typeEnum, ArtifactExecutionInfo.AuthzAction actionEnum, boolean requiresAuthz); /** Pop from the artifact stack and verify it is the same artifact name and type. This is generally called internally * by the framework and does not need to be used in application code. */ ArtifactExecutionInfo pop(ArtifactExecutionInfo aei); /** Gets a stack/deque/list of objects representing artifacts that have been executed to get to the current artifact. * The bottom artifact in the stack will generally be a screen or a service. If a service is run locally * this will trace back to the screen or service that called it, and if a service was called remotely it will be * the bottom of the stack. * * @return Actual ArtifactExecutionInfo stack/deque object */ Deque getStack(); /** Like getStack() but as an ArrayList for more frequent use, less memory overhead, and faster to iterate by index; * NOTE that this is cached and updated on push() and pop(), do not modify or other references to it will have incorrect data */ ArrayList getStackArray(); List getHistory(); String printHistory(); /** Disable authorization checks for the current ExecutionContext only. * This should be used when the system automatically does something (possible based on a user action) that the user * would not generally have permission to do themselves. * * @return boolean representing previous state of disable authorization (true if was disabled, false if not). If * this is true, you should not enableAuthz when you are done and instead allow whichever code first did the * disable to enable it. */ boolean disableAuthz(); /** Enable authorization after a disableAuthz() call. Not that this should be done in a finally block with the code * following the disableAuthz() in the corresponding try block. If this is not in a finally block an exception may * result in authorizations being disabled for the rest of the scope of the ExecutionContext (a potential security * whole). */ void enableAuthz(); boolean disableTarpit(); void enableTarpit(); void setAnonymousAuthorizedAll(); void setAnonymousAuthorizedView(); /** Disable Entity Facade ECA rules (for this thread/ExecutionContext only, does not affect other things happening * in the system). * @return boolean following same pattern as disableAuthz(), and should be handled the same way. */ boolean disableEntityEca(); /** Disable Entity Facade ECA rules (for this thread/ExecutionContext only, does not affect other things happening * in the system). */ void enableEntityEca(); } ================================================ FILE: framework/src/main/java/org/moqui/context/ArtifactExecutionInfo.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.io.Writer; import java.math.BigDecimal; import java.util.Collections; import java.util.AbstractMap.SimpleEntry; import java.util.List; import java.util.Map; import java.util.stream.Collectors; import java.util.stream.Stream; /** Information about execution of an artifact as the system is running */ @SuppressWarnings("unused") public interface ArtifactExecutionInfo { enum ArtifactType { AT_XML_SCREEN, AT_XML_SCREEN_TRANS, AT_XML_SCREEN_CONTENT, AT_SERVICE, AT_ENTITY, AT_REST_PATH, AT_OTHER } enum AuthzAction { AUTHZA_VIEW, AUTHZA_CREATE, AUTHZA_UPDATE, AUTHZA_DELETE, AUTHZA_ALL } enum AuthzType { AUTHZT_ALLOW, AUTHZT_DENY, AUTHZT_ALWAYS } ArtifactType AT_XML_SCREEN = ArtifactType.AT_XML_SCREEN; ArtifactType AT_XML_SCREEN_TRANS = ArtifactType.AT_XML_SCREEN_TRANS; ArtifactType AT_XML_SCREEN_CONTENT = ArtifactType.AT_XML_SCREEN_CONTENT; ArtifactType AT_SERVICE = ArtifactType.AT_SERVICE; ArtifactType AT_ENTITY = ArtifactType.AT_ENTITY; ArtifactType AT_REST_PATH = ArtifactType.AT_REST_PATH; ArtifactType AT_OTHER = ArtifactType.AT_OTHER; AuthzAction AUTHZA_VIEW = AuthzAction.AUTHZA_VIEW; AuthzAction AUTHZA_CREATE = AuthzAction.AUTHZA_CREATE; AuthzAction AUTHZA_UPDATE = AuthzAction.AUTHZA_UPDATE; AuthzAction AUTHZA_DELETE = AuthzAction.AUTHZA_DELETE; AuthzAction AUTHZA_ALL = AuthzAction.AUTHZA_ALL; Map authzActionByName = Collections.unmodifiableMap(Stream.of( new SimpleEntry<>("view", AUTHZA_VIEW), new SimpleEntry<>("create", AUTHZA_CREATE), new SimpleEntry<>("update", AUTHZA_UPDATE), new SimpleEntry<>("delete", AUTHZA_DELETE), new SimpleEntry<>("all", AUTHZA_ALL)).collect(Collectors.toMap(SimpleEntry::getKey, SimpleEntry::getValue))); AuthzType AUTHZT_ALLOW = AuthzType.AUTHZT_ALLOW; AuthzType AUTHZT_DENY = AuthzType.AUTHZT_DENY; AuthzType AUTHZT_ALWAYS = AuthzType.AUTHZT_ALWAYS; String getName(); ArtifactType getTypeEnum(); String getTypeDescription(); AuthzAction getActionEnum(); String getActionDescription(); String getAuthorizedUserId(); AuthzType getAuthorizedAuthzType(); AuthzAction getAuthorizedActionEnum(); boolean isAuthorizationInheritable(); boolean getAuthorizationWasRequired(); boolean getAuthorizationWasGranted(); long getRunningTime(); BigDecimal getRunningTimeMillis(); long getThisRunningTime(); BigDecimal getThisRunningTimeMillis(); long getChildrenRunningTime(); BigDecimal getChildrenRunningTimeMillis(); List getChildList(); ArtifactExecutionInfo getParent(); BigDecimal getPercentOfParentTime(); void print(Writer writer, int level, boolean children); String toBasicString(); } ================================================ FILE: framework/src/main/java/org/moqui/context/ArtifactTarpitException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; /** Thrown when artifact tarpit is hit, too many uses of artifact. */ public class ArtifactTarpitException extends BaseArtifactException { private Integer retryAfterSeconds = null; public ArtifactTarpitException(String str) { super(str); } public ArtifactTarpitException(String str, Throwable nested) { super(str, nested); } public ArtifactTarpitException(String str, Integer retryAfterSeconds) { super(str); this.retryAfterSeconds = retryAfterSeconds; } public Integer getRetryAfterSeconds() { return retryAfterSeconds; } } ================================================ FILE: framework/src/main/java/org/moqui/context/AuthenticationRequiredException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; /** Thrown when an artifact or operation requires authentication and no user is logged in. */ public class AuthenticationRequiredException extends BaseArtifactException { public AuthenticationRequiredException(String str) { super(str); } public AuthenticationRequiredException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/context/CacheFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.jcache.MCache; import javax.cache.Cache; import java.util.Set; /** A facade used for managing and accessing Cache instances. */ public interface CacheFacade { void clearAllCaches(); void clearCachesByPrefix(String prefix); /** Get the named Cache, creating one based on configuration and defaults if none exists. * Defaults to local cache if no configuration found. */ Cache getCache(String cacheName); /** A type-safe variation on getCache for configured caches. */ Cache getCache(String cacheName, Class keyType, Class valueType); /** Get the named local Cache (MCache instance), creating one based on defaults if none exists. * If the cache is configured with type != 'local' this will return an error. */ MCache getLocalCache(String cacheName); /** Get the named distributed Cache, creating one based on configuration and defaults if none exists. * If the cache is configured without type != 'distributed' this will return an error. */ Cache getDistributedCache(String cacheName); /** Register an externally created cache for future gets, inclusion in cache management tools, etc. * If a cache with the same name exists the call will be ignored (ie like putIfAbsent). */ void registerCache(Cache cache); Set getCacheNames(); boolean cacheExists(String cacheName); } ================================================ FILE: framework/src/main/java/org/moqui/context/ElasticFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.util.RestClient; import java.util.List; import java.util.Map; import java.util.concurrent.Future; /** A facade for ElasticSearch operations. * * Designed for ElasticSearch 7.0 and later with the one doc type per index constraint. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/removal-of-types.html */ public interface ElasticFacade { /** Get a client for the 'default' cluster, same as calling getClient("default") */ ElasticClient getDefault(); /** Get a client for named cluster configured in Moqui Conf XML elastic-facade.cluster */ ElasticClient getClient(String clusterName); List getClientList(); interface ElasticClient { String getClusterName(); String getClusterLocation(); /** Returns a Map with the response from ElasticSearch for GET on the root path with ES server info */ Map getServerInfo(); /** Returns true if index or alias exists. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-exists.html */ boolean indexExists(String index); /** Returns true if alias exists. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-alias-exists.html */ boolean aliasExists(String alias); /** Create an index with optional document mapping and alias. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-create-index.html */ void createIndex(String index, Map docMapping, String alias); /** Put document mapping on an existing index. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-put-mapping.html */ void putMapping(String index, Map docMapping); /** Delete an index. See https://www.elastic.co/guide/en/elasticsearch/reference/current/indices-delete-index.html */ void deleteIndex(String index); /** Index a complete document (create or update). See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-index_.html */ void index(String index, String _id, Map document); /** Partial document update. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-update.html */ void update(String index, String _id, Map documentFragment); /** Delete a document. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete.html */ void delete(String index, String _id); /** Delete documents by query. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-delete-by-query.html */ Integer deleteByQuery(String index, Map queryMap); /** Perform bulk operations. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-bulk.html * @param actionSourceList List of action objects each followed by a source object if relevant. */ void bulk(String index, List actionSourceList); /** Bulk index documents with given index name and _id from the idField in each document (if idField empty don't specify ID, let ES generate) */ void bulkIndex(String index, String idField, List documentList); void bulkIndex(String index, String docType, String idField, List documentList, boolean refresh); /** Get full/wrapped single document by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html */ Map get(String index, String _id); /** Get source for a single document by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-get.html */ Map getSource(String index, String _id); /** Get multiple documents by ID. See https://www.elastic.co/guide/en/elasticsearch/reference/current/docs-multi-get.html */ List get(String index, List _idList); /** Search documents and get the plain object response. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html */ Map search(String index, Map searchMap); /** Search documents. Result is the list in 'hits.hits' from the plain object returned for convenience. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-request-body.html */ List searchHits(String index, Map searchMap); /** Validate a query Map. * Returns null if valid otherwise returns Map from JSON response with 'valid' boolean and if explain is true also 'explanations' for more information. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-validate.html * @param queryMap Map sent to ElasticSearch as the 'query' field (should not include 'query' entry, may include 'bool', 'query_string', etc) */ Map validateQuery(String index, Map queryMap, boolean explain); /** Count documents and get the long int value from the response. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-count.html */ long count(String index, Map countMap); /** Count documents and get the plain object response. * See https://www.elastic.co/guide/en/elasticsearch/reference/current/search-count.html */ Map countResponse(String index, Map countMap); /** Create a Point-In-Time checkpoint and get the ID * See https://www.elastic.co/guide/en/elasticsearch/reference/current/paginate-search-results.html#scroll-search-results */ String getPitId(String index, String keepAlive); /** Delete a Point-In-Time checkpoint, should always be done when finished (close operation) */ void deletePit(String pitId); /** Basic REST endpoint synchronous call */ RestClient.RestResponse call(RestClient.Method method, String index, String path, Map parameters, Object bodyJsonObject); /** Basic REST endpoint future (asynchronous) call */ Future callFuture(RestClient.Method method, String index, String path, Map parameters, Object bodyJsonObject); /** Make a RestClient with configured protocol/host/port and user/password if configured, RequestFactory for this ElasticClient, and the given parameters */ RestClient makeRestClient(RestClient.Method method, String index, String path, Map parameters); /** Check and if needed create ElasticSearch indexes for all DataDocument records with given indexName */ void checkCreateDataDocumentIndexes(String indexName); /** Check and if needed create ElasticSearch index for DataDocument with given ID */ void checkCreateDataDocumentIndex(String dataDocumentId); /** Put document mappings for all DataDocument records with given indexName */ void putDataDocumentMappings(String indexName); /** Verify index aliases and dataDocumentId based indexes from all distinct _index and _type values in documentList */ void verifyDataDocumentIndexes(List documentList); /** Bulk index documents with standard _index, _type, _id, and _timestamp fields which are used for the index and id per * document but removed from the actual document sent to ElasticSearch; note that for legacy reasons related to one type * per index the _type is used for the index name */ void bulkIndexDataDocument(List documentList); /** Convert Object (generally Map or List) to JSON String using internal ElasticSearch specific settings */ String objectToJson(Object jsonObject); /** Convert JSON String to Object (generally Map or List) using internal ElasticSearch specific settings */ Object jsonToObject(String jsonString); } } ================================================ FILE: framework/src/main/java/org/moqui/context/ExecutionContext.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.util.List; import java.util.Map; import java.util.concurrent.Future; import groovy.lang.Closure; import org.moqui.entity.EntityFacade; import org.moqui.screen.ScreenFacade; import org.moqui.service.ServiceFacade; import org.moqui.util.ContextBinding; import org.moqui.util.ContextStack; import javax.annotation.Nonnull; import javax.annotation.Nullable; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; /** * Interface definition for object used throughout the Moqui Framework to manage contextual execution information and * tool interfaces. One instance of this object will exist for each thread running code and will be applicable for that * thread only. */ @SuppressWarnings("unused") public interface ExecutionContext { /** Get the ExecutionContextFactory this came from. */ @Nonnull ExecutionContextFactory getFactory(); /** Returns a Map that represents the current local variable space (context) in whatever is being run. */ @Nonnull ContextStack getContext(); @Nonnull ContextBinding getContextBinding(); /** Returns a Map that represents the global/root variable space (context), ie the bottom of the context stack. */ @Nonnull Map getContextRoot(); /** Get an instance object from the named ToolFactory instance (loaded by configuration). Some tools return a * singleton instance, others a new instance each time it is used and that instance is saved with this * ExecutionContext to be reused. The instanceClass may be null in scripts or other contexts where static typing * is not needed */ V getTool(@Nonnull String toolName, Class instanceClass, Object... parameters); /** If running through a web (HTTP servlet) request offers access to the various web objects/information. * If not running in a web context will return null. */ @Nullable WebFacade getWeb(); /** For information about the user and user preferences (including locale, time zone, currency, etc). */ @Nonnull UserFacade getUser(); /** For user messages including general feedback, errors, and field-specific validation errors. */ @Nonnull MessageFacade getMessage(); /** For information about artifacts as they are being executed. */ @Nonnull ArtifactExecutionFacade getArtifactExecution(); /** For localization (l10n) functionality, like localizing messages. */ @Nonnull L10nFacade getL10n(); /** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */ @Nonnull ResourceFacade getResource(); /** For trace, error, etc logging to the console, files, etc. */ @Nonnull LoggerFacade getLogger(); /** For managing and accessing caches. */ @Nonnull CacheFacade getCache(); /** For transaction operations use this facade instead of the JTA UserTransaction and TransactionManager. See javadoc comments there for examples of code usage. */ @Nonnull TransactionFacade getTransaction(); /** For interactions with a relational database. */ @Nonnull EntityFacade getEntity(); /** For interactions with ElasticSearch using the built in HTTP REST client. */ @Nonnull ElasticFacade getElastic(); /** For calling services (local or remote, sync or async or scheduled). */ @Nonnull ServiceFacade getService(); /** For rendering screens for general use (mostly for things other than web pages or web page snippets). */ @Nonnull ScreenFacade getScreen(); @Nonnull NotificationMessage makeNotificationMessage(); @Nonnull List getNotificationMessages(@Nullable String topic); /** This should be called by a filter or servlet at the beginning of an HTTP request to initialize a web facade * for the current thread. */ void initWebFacade(@Nonnull String webappMoquiName, @Nonnull HttpServletRequest request, @Nonnull HttpServletResponse response); /** A lightweight asynchronous executor. ExecutionContext aware and uses a new ExecutionContext in the separate thread * based on the current (retaining user, disable authz, etc and may be improved over time to copy more). */ Future runAsync(@Nonnull Closure closure); /** This should be called when the ExecutionContext won't be used any more. Implementations should make sure * any active transactions, database connections, etc are closed. */ void destroy(); } ================================================ FILE: framework/src/main/java/org/moqui/context/ExecutionContextFactory.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import groovy.lang.GroovyClassLoader; import org.moqui.entity.EntityFacade; import org.moqui.screen.ScreenFacade; import org.moqui.service.ServiceFacade; import jakarta.servlet.ServletContext; import javax.annotation.Nonnull; import jakarta.websocket.server.ServerContainer; import java.util.LinkedHashMap; import java.util.List; /** * Interface for the object that will be used to get an ExecutionContext object and manage framework life cycle. */ public interface ExecutionContextFactory { /** Get the ExecutionContext associated with the current thread or initialize one and associate it with the thread. */ @Nonnull ExecutionContext getExecutionContext(); /** Destroy the active Execution Context. When another is requested in this thread a new one will be created. */ void destroyActiveExecutionContext(); /** Called after construction but before registration with Moqui/Servlet, check for empty database and load configured data. * If empty-db-load is not done and on-start-load-types has a value handles that as well. * Also loads type 'test' data if instance_purpose=test. */ boolean checkEmptyDb(); /** Destroy this ExecutionContextFactory and all resources it uses (all facades, tools, etc) */ void destroy(); boolean isDestroyed(); /** Get the path of the runtime directory */ @Nonnull String getRuntimePath(); @Nonnull String getMoquiVersion(); /** Get the named ToolFactory instance (loaded by configuration) */ ToolFactory getToolFactory(@Nonnull String toolName); /** Get an instance object from the named ToolFactory instance (loaded by configuration); the instanceClass may be * null in scripts or other contexts where static typing is not needed */ V getTool(@Nonnull String toolName, Class instanceClass, Object... parameters); /** Get a Map where each key is a component name and each value is the component's base location. */ @Nonnull LinkedHashMap getComponentBaseLocations(); /** For localization (l10n) functionality, like localizing messages. */ @Nonnull L10nFacade getL10n(); /** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */ @Nonnull ResourceFacade getResource(); /** For trace, error, etc logging to the console, files, etc. */ @Nonnull LoggerFacade getLogger(); /** For managing and accessing caches. */ @Nonnull CacheFacade getCache(); /** For transaction operations use this facade instead of the JTA UserTransaction and TransactionManager. See javadoc comments there for examples of code usage. */ @Nonnull TransactionFacade getTransaction(); /** For interactions with a relational database. */ @Nonnull EntityFacade getEntity(); /** For interactions with ElasticSearch using the built in HTTP REST client. */ @Nonnull ElasticFacade getElastic(); /** For calling services (local or remote, sync or async or scheduled). */ @Nonnull ServiceFacade getService(); /** For rendering screens for general use (mostly for things other than web pages or web page snippets). */ @Nonnull ScreenFacade getScreen(); /** Get the framework ClassLoader, aware of all additional classes in runtime and in components. */ @Nonnull ClassLoader getClassLoader(); /** Get a GroovyClassLoader for runtime compilation, etc. */ @Nonnull GroovyClassLoader getGroovyClassLoader(); /** The ServletContext, if Moqui was initialized in a webapp (generally through MoquiContextListener) */ @Nonnull ServletContext getServletContext(); /** The WebSocket ServerContainer, if found in 'jakarta.websocket.server.ServerContainer' ServletContext attribute */ @Nonnull ServerContainer getServerContainer(); /** For starting initialization only, tell the ECF about the ServletContext for getServletContext() and getServerContainer() */ void initServletContext(ServletContext sc); void registerNotificationMessageListener(@Nonnull NotificationMessageListener nml); void registerLogEventSubscriber(@Nonnull LogEventSubscriber subscriber); List getLogEventSubscribers(); } ================================================ FILE: framework/src/main/java/org/moqui/context/L10nFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.Timestamp; import java.util.Calendar; import java.util.Locale; import java.util.TimeZone; /** For localization (l10n) functionality, like localizing messages. */ public interface L10nFacade { /** Use the current locale (see ec.user.getLocale() method) to localize the message based on data in the * moqui.basic.LocalizedMessage entity. The localized message may have variables inserted using the ${} syntax that * when this is called through ec.resource.expand(). * * The approach here is that original messages are actual messages in the primary language of the application. This * reduces issues with duplicated messages compared to the approach of explicit/artificial property keys. Longer * messages (over 255 characters) should use an artificial message key with the actual value always coming * from the database. */ String localize(String original); /** Localize a String using the given Locale instead of the current user's. */ String localize(String original, Locale locale); /** Format currency amount for user to view. * @param amount An object representing the amount, should be a subclass of Number. * @param uomId The uomId (ISO currency code), required. * @param fractionDigits Number of digits after the decimal point to display. If null defaults to number defined * by java.util.Currency.defaultFractionDigits() for the specified currency in uomId. * @param locale Locale to use for formatting. * @param hideSymbol option to hide the Symbol of the currency and only display the number formatted according * to locale. * @return The formatted currency amount. */ String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale, boolean hideSymbol); String formatCurrency(Object amount, String uomId, Integer fractionDigits, Locale locale); String formatCurrency(Object amount, String uomId, Integer fractionDigits); String formatCurrency(Object amount, String uomId); String formatCurrencyNoSymbol(Object amount, String uomId); /** Round currency according to the currency's specified amount of digits and rounding method. * @param amount The amount in BigDecimal to be rounded. * @param uomId The currency uomId (ISO currency code), required * @param precise A boolean indicating whether the currency should be treated with an additional digit * @param roundingMode Rounding method to use (e.g. RoundingMode.HALF_UP) * @return The rounded currency amount. */ BigDecimal roundCurrency(BigDecimal amount, String uomId, boolean precise, RoundingMode roundingMode); BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId, boolean precise, int roundingMethod); BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId, boolean precise); BigDecimal roundCurrency(java.math.BigDecimal amount, String uomId); /** Format a Number, Timestamp, Date, Time, or Calendar object using the given format string. If no format string * is specified the default for the user's locale and time zone will be used. * * @param value The value to format. Must be a Number, Timestamp, Date, Time, or Calendar object. * @param format The format string used to specify how to format the value. * @return The value as a String formatted according to the format string. */ String format(Object value, String format); String format(Object value, String format, Locale locale, TimeZone tz); java.sql.Time parseTime(String input, String format); java.sql.Date parseDate(String input, String format); Timestamp parseTimestamp(String input, String format); Timestamp parseTimestamp(String input, String format, Locale locale, TimeZone timeZone); java.util.Calendar parseDateTime(String input, String format); String formatDateTime(Calendar input, String format, Locale locale, TimeZone tz); java.math.BigDecimal parseNumber(String input, String format); String formatNumber(Number input, String format, Locale locale); } ================================================ FILE: framework/src/main/java/org/moqui/context/LogEventSubscriber.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.apache.logging.log4j.core.LogEvent; /** A simple interface for a method to receive LogEvent instances. * To use implement this interface and call ExecutionContextFactory.registerLogEventSubscriber(). */ public interface LogEventSubscriber { void process(LogEvent event); } ================================================ FILE: framework/src/main/java/org/moqui/context/LoggerFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; /** For trace, error, etc logging to the console, files, etc. */ public interface LoggerFacade { /** Log level copied from org.apache.logging.log4j.spi.StandardLevel to avoid requiring that on the classpath. */ int OFF_INT = 0; int FATAL_INT = 100; int ERROR_INT = 200; int WARN_INT = 300; int INFO_INT = 400; int DEBUG_INT = 500; int TRACE_INT = 600; int ALL_INT = 2147483647; /** Log a message and/or Throwable error at the given level. * * This is meant to be used for scripts, xml-actions, etc. * * In Java or Groovy classes it is better to use SLF4J directly, with something like: * * public class Wombat { * final static org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(Wombat.class); * * public void setTemperature(Integer temperature) { * Integer oldT = t; * Integer t = temperature; * logger.debug("Temperature set to {}. Old temperature was {}.", t, oldT); * if(temperature.intValue() > 50) { * logger.info("Temperature has risen above 50 degrees."); * } * } * } * * * @param level The logging level. Options should come from org.apache.log4j.Level. * @param message The message text to log. If contains ${} syntax will be expanded from the current context. * @param thrown Throwable with stack trace, etc to be logged along with the message. */ void log(int level, String message, Throwable thrown); void trace(String message); void debug(String message); void info(String message); void warn(String message); void error(String message); void trace(String message, Throwable thrown); void debug(String message, Throwable thrown); void info(String message, Throwable thrown); void warn(String message, Throwable thrown); void error(String message, Throwable thrown); /** Is the given logging level enabled? */ boolean logEnabled(int level); } ================================================ FILE: framework/src/main/java/org/moqui/context/MessageFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.io.Serializable; import java.util.List; import org.moqui.context.NotificationMessage.NotificationType; /** For user messages including general feedback, errors, and field-specific validation errors. */ public interface MessageFacade { NotificationType info = NotificationType.info; NotificationType success = NotificationType.success; NotificationType warning = NotificationType.warning; NotificationType danger = NotificationType.danger; /** Immutable List of general (non-error) messages that will be shown to the user. */ List getMessages(); /** Immutable List of general (non-error) messages that will be shown to the user. */ List getMessageInfos(); /** Make a single String with all messages separated by the new-line character. * @return String with all messages. */ String getMessagesString(); /** Add a non-error message for internal user to see, for messages not meant for display on public facing sites and portals. * @param message The message to add. */ void addMessage(String message); /** Add a message not meant for display on public facing sites and portals. */ void addMessage(String message, NotificationType type); /** A variation on addMessage() where the type is a String instead of NotificationType. * @param type String representing one of the NotificationType values: info, success, warning, danger. Defaults to info. */ void addMessage(String message, String type); /** Add a message meant for display on public facing sites and portals leaving standard messages and errors for internal * applications. Also adds the message like a call to addMessage() for internal and other display so that does not also need to be called. */ void addPublic(String message, NotificationType type); /** A variation on addPublic where the type is a String instead of NotificationType. * Also adds the message like a call to addMessage() for internal and other display so that does not also need to be called. * @param type String representing one of the NotificationType values: info, success, warning, danger. Defaults to info. */ void addPublic(String message, String type); List getPublicMessages(); List getPublicMessageInfos(); /** Immutable List of error messages that should be shown to internal users. */ List getErrors(); /** Add a error message for the user to see. * NOTE: system errors not meant for the user should be thrown as exceptions instead. * @param error The error message to add */ void addError(String error); /** Immutable List of ValidationError objects that should be shown to internal or public users in the context of the * fields that triggered the error. */ List getValidationErrors(); void addValidationError(String form, String field, String serviceName, String message, Throwable nested); void addError(ValidationError error); /** See if there is are any errors. Checks both error strings and validation errors. */ boolean hasError(); /** Make a single String with all error messages separated by the new-line character. * @return String with all error messages. */ String getErrorsString(); /** Clear all messages: general/internal from addMessage() and public from addPublic(), then calls clearErrors() for * errors from addError(), and validation errors from addValidationError() */ void clearAll(); /** Clear error messages including errors from addError(), and validation errors from addValidationError(); * before clearing adds these error messages to the general/internal messages list (same as addMessage()) so the * messages are not lost and make it back to the user (if applicable) */ void clearErrors(); /** Copy all messages from this instance of MessageFacade to another, mostly for internal framework use */ void copyMessages(MessageFacade mf); /** Save current errors on a stack and clear them, mostly for internal framework use */ void pushErrors(); /** Remove last pushed errors from the stack and add them to current errors, mostly for internal framework use */ void popErrors(); class MessageInfo implements Serializable { String message; NotificationType type; public MessageInfo(String message, NotificationType type) { this.message = message; this.type = type != null ? type : info; } public MessageInfo(String message, String type) { this.message = message; if (type != null && !type.isEmpty()) { switch (Character.toLowerCase(type.charAt(0))) { case 's': this.type = success; break; case 'w': this.type = warning; break; case 'd': this.type = danger; break; default: this.type = info; } } else { this.type = info; } } public String getMessage() { return message; } public NotificationType getType() { return type; } public String getTypeString() { return type.toString(); } public String toString() { return "[" + type.toString() + "] " + message; } } } ================================================ FILE: framework/src/main/java/org/moqui/context/MessageFacadeException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; public class MessageFacadeException extends BaseArtifactException { protected final MessageFacade messageFacade; public MessageFacadeException(MessageFacade mf, Throwable nested) { super(mf.getErrorsString(), nested); messageFacade = mf; } public MessageFacade getMessageFacade() { return messageFacade; } } ================================================ FILE: framework/src/main/java/org/moqui/context/MoquiLog4jAppender.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.io.Serializable; import java.util.List; import org.apache.logging.log4j.core.*; import org.apache.logging.log4j.core.appender.AbstractAppender; import org.apache.logging.log4j.core.config.Property; import org.apache.logging.log4j.core.config.plugins.Plugin; import org.apache.logging.log4j.core.config.plugins.PluginAttribute; import org.apache.logging.log4j.core.config.plugins.PluginElement; import org.apache.logging.log4j.core.config.plugins.PluginFactory; import org.moqui.Moqui; @Plugin(name="MoquiLog4jAppender", category="Core", elementType="appender", printObject=true) public final class MoquiLog4jAppender extends AbstractAppender { // private final ReadWriteLock rwLock = new ReentrantReadWriteLock(); // private final Lock readLock = rwLock.readLock(); protected MoquiLog4jAppender(String name, Filter filter, Layout layout, final boolean ignoreExceptions, final Property[] properties) { super(name, filter, layout, ignoreExceptions, properties); } @Override public void append(LogEvent event) { ExecutionContextFactory ecf = Moqui.getExecutionContextFactory(); // ECF may not yet be initialized if (ecf == null) return; List subscribers = ecf.getLogEventSubscribers(); int subscribersSize = subscribers.size(); for (int i = 0; i < subscribersSize; i++) { LogEventSubscriber subscriber = subscribers.get(i); subscriber.process(event); } /* readLock.lock(); try { final String message = (String) getLayout().toSerializable(event); System.out.write(message); } catch (Exception e) { if (!ignoreExceptions()) { throw new AppenderLoggingException(e); } } finally { readLock.unlock(); } */ } @PluginFactory public static MoquiLog4jAppender createAppender(@PluginAttribute("name") String name, @PluginElement("Filter") final Filter filter) { // not using Layout config, let subscribers choose: @PluginElement("Layout") Layout layout if (name == null) { LOGGER.error("No name provided for MoquiLog4jAppender"); return null; } return new MoquiLog4jAppender(name, filter, null, true, null); } } ================================================ FILE: framework/src/main/java/org/moqui/context/NotificationMessage.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.util.Map; import java.util.Set; @SuppressWarnings("unused") public interface NotificationMessage extends java.io.Serializable { enum NotificationType { info, success, warning, danger } NotificationType info = NotificationType.info; NotificationType success = NotificationType.success; NotificationType warning = NotificationType.warning; NotificationType danger = NotificationType.danger; NotificationMessage userId(String userId); NotificationMessage userIds(Set userIds); Set getUserIds(); NotificationMessage userGroupId(String userGroupId); String getUserGroupId(); /** Get userId for all users associated with this notification, directly or through the UserGroup, and who have * NotificationTopicUser.receiveNotifications=Y, which if not set (or there is no NotificationTopicUser record) * defaults to NotificationTopic.receiveNotifications (if not set defaults to Y) */ Set getNotifyUserIds(); NotificationMessage topic(String topic); String getTopic(); NotificationMessage subTopic(String subTopic); String getSubTopic(); /** Set the message as a JSON String. The top-level should be a Map (JSON Object). * @param messageJson The message as a JSON string containing a Map (JSON Object) * @return Self-reference for convenience */ NotificationMessage message(String messageJson); /** Set the message as a JSON String. The top-level should be a Map (JSON Object). * @param message The message as a Map (JSON Object), must be convertible to JSON String * @return Self-reference for convenience */ NotificationMessage message(Map message); String getMessageJson(); Map getMessageMap(); /** Set the title to display, a GString (${} syntax) that will be expanded using the message Map; may be a localization template name */ NotificationMessage title(String title); /** Get the title, expanded using the message Map; if not set and topic has a NotificationTopic record will default to value there */ String getTitle(); /** Set the link to get more detail about the notification or go to its source, a GString (${} syntax) expanded using the message Map */ NotificationMessage link(String link); /** Get the link to detail/source, expanded using the message Map; if not set and topic has a NotificationTopic record will default to value there */ String getLink(); NotificationMessage type(NotificationType type); /** Must be a String for a valid NotificationType (ie info, success, warning, or danger) */ NotificationMessage type(String type); /** Get the type as a String; if not set and topic has a NotificationTopic record will default to value there */ String getType(); NotificationMessage showAlert(boolean show); /** Show an alert for this notification? If not set and topic has a NotificationTopic record will default to value there */ boolean isShowAlert(); NotificationMessage alertNoAutoHide(boolean noAutoHide); boolean isAlertNoAutoHide(); NotificationMessage persistOnSend(Boolean persist); boolean isPersistOnSend(); NotificationMessage emailTemplateId(String id); String getEmailTemplateId(); NotificationMessage emailMessageSave(Boolean save); boolean isEmailMessageSave(); /** Call after send() to get emailMessageId values (if emailMessageSave is true) */ Map getEmailMessageIdByUserId(); /** Send this Notification Message. * @param persist If true this is persisted and message received is tracked. If false this is sent to active topic * listeners only. * @return Self-reference for convenience */ NotificationMessage send(boolean persist); /** Send this Notification Message using persistOnSend setting (defaults to false). */ NotificationMessage send(); String getNotificationMessageId(); NotificationMessage markSent(String userId); NotificationMessage markViewed(String userId); /** Get a Map with: topic, sentDate, notificationMessageId, message, title, link, type, and showAlert using the get method for each */ Map getWrappedMessageMap(); /** Result of getWrappedMessageMap() as a JSON String */ String getWrappedMessageJson(); } ================================================ FILE: framework/src/main/java/org/moqui/context/NotificationMessageListener.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; public interface NotificationMessageListener { void init(ExecutionContextFactory ecf); void destroy(); void onMessage(NotificationMessage nm); } ================================================ FILE: framework/src/main/java/org/moqui/context/PasswordChangeRequiredException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.apache.shiro.authc.CredentialsException; /** Thrown when user's password is correct but account requires second factor authentication. */ public class PasswordChangeRequiredException extends CredentialsException { public PasswordChangeRequiredException() { super(); } public PasswordChangeRequiredException(String str) { super(str); } public PasswordChangeRequiredException(Throwable nested) { super(nested); } public PasswordChangeRequiredException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/context/ResourceFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.resource.ResourceReference; import jakarta.activation.DataSource; import javax.xml.transform.stream.StreamSource; import java.io.InputStream; import java.io.OutputStream; import java.io.Writer; import java.net.URI; import java.util.Map; /** For accessing resources by location string (http://, jar://, component://, content://, classpath://, etc). */ public interface ResourceFacade { /** Get a ResourceReference representing the Moqui location string passed. * * @param location A URL-style location string. In addition to the standard URL protocols (http, https, ftp, jar, * and file) can also have the special Moqui protocols of "component://" for a resource location relative to a * component base location, "content://" for a resource in the content repository, and "classpath://" to get a * resource from the Java classpath. */ ResourceReference getLocationReference(String location); ResourceReference getUriReference(URI uri); /** Open an InputStream to read the contents of a file/document at a location. * * @param location A URL-style location string that also support the Moqui-specific component and content protocols. */ InputStream getLocationStream(String location); /** Get the text at the given location, optionally from the cache (resource.text.location). */ String getLocationText(String location, boolean cache); DataSource getLocationDataSource(String location); /** Render a template at the given location using the current context and write the output to the given writer. */ void template(String location, Writer writer); void template(String location, Writer writer, String defaultExtension); String template(String location, String defaultExtension); /** Run a script at the given location (optionally with the given method, like in a groovy class) using the current * context for its variable space. * * @return The value returned by the script, if any. */ Object script(String location, String method); Object script(String location, String method, Map additionalContext); /** Evaluate a Groovy expression as a condition. * * @return boolean representing the result of evaluating the expression */ boolean condition(String expression, String debugLocation); boolean condition(String expression, String debugLocation, Map additionalContext); /** Evaluate a Groovy expression as a context field, or more generally as an expression that evaluates to an Object * reference. This can be used to get a value from an expression or to run any general expression or script. * * @return Object reference representing result of evaluating the expression */ Object expression(String expr, String debugLocation); Object expression(String expr, String debugLocation, Map additionalContext); /** Evaluate a Groovy expression as a GString to be expanded/interpolated into a simple String. * * NOTE: the inputString is always run through the L10nFacade.localize() method before evaluating the * expression in order to implicitly internationalize string expansion. * * @return String representing localized and expanded inputString */ String expand(String inputString, String debugLocation); String expand(String inputString, String debugLocation, Map additionalContext); String expand(String inputString, String debugLocation, Map additionalContext, boolean localize); String expandNoL10n(String inputString, String debugLocation); Integer xslFoTransform(StreamSource xslFoSrc, StreamSource xsltSrc, OutputStream out, String contentType); String getContentType(String filename); } ================================================ FILE: framework/src/main/java/org/moqui/context/ScriptRunner.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseException; public interface ScriptRunner { ScriptRunner init(ExecutionContextFactory ecf); Object run(String location, String method, ExecutionContext ec) throws BaseException; void destroy(); } ================================================ FILE: framework/src/main/java/org/moqui/context/SecondFactorRequiredException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.apache.shiro.authc.AuthenticationException; /** Thrown when user's password is correct but account requires second factor authentication. */ public class SecondFactorRequiredException extends AuthenticationException { public SecondFactorRequiredException() { super(); } public SecondFactorRequiredException(String str) { super(str); } public SecondFactorRequiredException(Throwable nested) { super(nested); } public SecondFactorRequiredException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/context/TemplateRenderer.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseException; import java.io.Writer; public interface TemplateRenderer { TemplateRenderer init(ExecutionContextFactory ecf); void render(String location, Writer writer) throws BaseException; String stripTemplateExtension(String fileName); void destroy(); } ================================================ FILE: framework/src/main/java/org/moqui/context/ToolFactory.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; /** Implement this interface to manage lifecycle and factory for tools initialized and destroyed with Moqui Framework. * Implementations must have a public no parameter constructor. */ public interface ToolFactory { /** Return a name that the factory will be available under through the ExecutionContextFactory.getToolFactory() * method and instances will be available under through the ExecutionContextFactory.getTool() method. */ default String getName() { String className = this.getClass().getSimpleName(); int tfIndex = className.indexOf("ToolFactory"); if (tfIndex > 0) className = className.substring(0, tfIndex); return className; } /** Initialize the underlying tool and if the instance is a singleton also the instance. */ default void init(ExecutionContextFactory ecf) { } /** Rarely used, initialize before Moqui Facades are initialized; useful for tools that ResourceReference, * ScriptRunner, TemplateRenderer, ServiceRunner, etc implementations depend on. */ default void preFacadeInit(ExecutionContextFactory ecf) { } /** Called by ExecutionContextFactory.getTool() to get an instance object for this tool. * May be created for each call or a singleton. * * @throws IllegalStateException if not initialized */ V getInstance(Object... parameters); /** Called on destroy/shutdown of Moqui to destroy (shutdown, close, etc) the underlying tool. */ default void destroy() { } /** Rarely used, like destroy() but runs after the facades are destroyed. */ default void postFacadeDestroy() { } } ================================================ FILE: framework/src/main/java/org/moqui/context/TransactionException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; /** * TransactionException */ public class TransactionException extends BaseArtifactException { public TransactionException(String str) { super(str); } public TransactionException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/context/TransactionFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import groovy.lang.Closure; import jakarta.transaction.Synchronization; import javax.transaction.xa.XAResource; /** Use this interface to do transaction demarcation and related operations. * This should be used instead of using the JTA UserTransaction and TransactionManager interfaces. * * When you do transaction demarcation yourself use something like: * *
 * boolean beganTransaction = transactionFacade.begin(timeout);
 * try {
 *     ...
 * } catch (Throwable t) {
 *     transactionFacade.rollback(beganTransaction, "...", t);
 *     throw t;
 * } finally {
 *     if (transactionFacade.isTransactionInPlace()) transactionFacade.commit(beganTransaction);
 * }
 * 
* * This code will use a transaction if one is already in place (including setRollbackOnly instead of rollbackon * error), or begin a new one if not. * * When you want to suspend the current transaction and create a new one use something like: * *
 * boolean suspendedTransaction = false;
 * try {
 *     if (transactionFacade.isTransactionInPlace()) suspendedTransaction = transactionFacade.suspend();
 *
 *     boolean beganTransaction = transactionFacade.begin(timeout);
 *     try {
 *         ...
 *     } catch (Throwable t) {
 *         transactionFacade.rollback(beganTransaction, "...", t);
 *         throw t;
 *     } finally {
 *         if (transactionFacade.isTransactionInPlace()) transactionFacade.commit(beganTransaction);
 *     }
 * } catch (TransactionException e) {
 *     ...
 * } finally {
 *     if (suspendedTransaction) transactionFacade.resume();
 * }
 * 
*/ @SuppressWarnings("unused") public interface TransactionFacade { /** Run in current transaction if one is in place, begin and commit/rollback if none is. */ Object runUseOrBegin(Integer timeout, String rollbackMessage, Closure closure); /** Run in a separate transaction, even if one is in place. */ Object runRequireNew(Integer timeout, String rollbackMessage, Closure closure); jakarta.transaction.TransactionManager getTransactionManager(); jakarta.transaction.UserTransaction getUserTransaction(); /** Get the status of the current transaction */ int getStatus() throws TransactionException; String getStatusString() throws TransactionException; boolean isTransactionInPlace() throws TransactionException; /** Begins a transaction in the current thread. Only tries if the current transaction status is not ACTIVE, if * ACTIVE it returns false since no transaction was begun. * * @param timeout Optional Integer for the timeout. If null the default configured will be used. * @return True if a transaction was begun, otherwise false. * @throws TransactionException */ boolean begin(Integer timeout) throws TransactionException; /** Commits the transaction in the current thread if beganTransaction is true */ void commit(boolean beganTransaction) throws TransactionException; /** Commits the transaction in the current thread */ void commit() throws TransactionException; /** Rollback current transaction if beganTransaction is true, otherwise setRollbackOnly is called to mark current * transaction as rollback only. */ void rollback(boolean beganTransaction, String causeMessage, Throwable causeThrowable) throws TransactionException; /** Rollback current transaction */ void rollback(String causeMessage, Throwable causeThrowable) throws TransactionException; /** Mark current transaction as rollback-only (transaction can only be rolled back) */ void setRollbackOnly(String causeMessage, Throwable causeThrowable) throws TransactionException; boolean suspend() throws TransactionException; void resume() throws TransactionException; java.sql.Connection enlistConnection(javax.sql.XAConnection con) throws TransactionException; void enlistResource(XAResource resource) throws TransactionException; XAResource getActiveXaResource(String resourceName); void putAndEnlistActiveXaResource(String resourceName, XAResource xar); void registerSynchronization(Synchronization sync) throws TransactionException; Synchronization getActiveSynchronization(String syncName); void putAndEnlistActiveSynchronization(String syncName, Synchronization sync); void initTransactionCache(boolean readOnly); boolean isTransactionCacheActive(); void flushAndDisableTransactionCache(); } ================================================ FILE: framework/src/main/java/org/moqui/context/TransactionInternal.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.entity.EntityFacade; import org.moqui.util.MNode; import javax.sql.DataSource; import jakarta.transaction.TransactionManager; import jakarta.transaction.UserTransaction; public interface TransactionInternal { TransactionInternal init(ExecutionContextFactory ecf); TransactionManager getTransactionManager(); UserTransaction getUserTransaction(); DataSource getDataSource(EntityFacade ef, MNode datasourceNode); void destroy(); } ================================================ FILE: framework/src/main/java/org/moqui/context/UserFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.entity.EntityValue; import java.sql.Timestamp; import java.util.*; /** For information about the user and user preferences (including locale, time zone, currency, etc). */ @SuppressWarnings("unused") public interface UserFacade { /** @return Locale The active Locale from user preference or system default. */ Locale getLocale(); /** Set the user's Locale. This is used in this context and saved to the database for future contexts. * @param locale The new Locale. */ void setLocale(Locale locale); /** @return TimeZone The active TimeZone from user preference or system default. */ TimeZone getTimeZone(); /** Set the user's Time Zone. This is used in this context and saved to the database for future contexts. * @param tz The new TimeZone. */ void setTimeZone(TimeZone tz); /** @return String The active ISO currency code from user preference or system default. */ String getCurrencyUomId(); /** Set the user's Time Zone. This is used in this context and saved to the database for future contexts. * @param uomId The new currency UOM ID (ISO currency code). */ void setCurrencyUomId(String uomId); /** Get the value of a user preference. * @param preferenceKey The key for the preference, looked up on UserPreference.preferenceKey * @return The value of the preference from the UserPreference.preferenceValue field */ String getPreference(String preferenceKey); /** Set the value of a user preference. * @param preferenceKey The key for the preference, used to create or update a record with UserPreference.preferenceKey * @param preferenceValue The value to set on the preference, set in UserPreference.preferenceValue */ void setPreference(String preferenceKey, String preferenceValue); /** Get a Map with multiple preferences, optionally filtered by a regular expression matched against each key */ Map getPreferences(String keyRegexp); /** A per-user context like the execution context for but data specific to a user and maintained through service * calls, etc unlike ExecutionContext.getContext(). Used for security data, etc such as entity filter values. */ Map getContext(); /** Get the current date and time in a Timestamp object. This is either the current system time, or the Effective * Time if that has been set for this context (allowing testing of past and future system behavior). * * All internal tools and code built on the framework should treat this as the actual current time. * * @return Timestamp representing current date/time, or the values passed to setEffectiveTime(). */ Timestamp getNowTimestamp(); /** Get a Calendar object with user's TimeZone and Locale set, and set to same time as returned by getNowTimestamp(). */ Calendar getNowCalendar(); /** Get a Timestamp range (from/thru) based on period (day, week, month, year; 7d, 30d, etc), offset, and anchor date (defaults to now) * @return ArrayList with 2 entries, entry 0 is the from Timestamp, entry 1 is the thru Timestamp */ ArrayList getPeriodRange(String period, String poffset, String pdate); ArrayList getPeriodRange(String period, int poffset, java.sql.Date pdate); ArrayList getPeriodRange(String period, String poffset); String getPeriodDescription(String period, String poffset, String pdate); ArrayList getPeriodRange(String baseName, Map inputFieldsMap); /** Set an EffectiveTime for the current context which will then be returned from the getNowTimestamp() method. * This is used to test past and future behavior of applications. * * @param effectiveTime The new effective date/time. Pass in null to reset to the default of the current system time. */ void setEffectiveTime(Timestamp effectiveTime); /** Authenticate a user and make active in this ExecutionContext (and session of WebExecutionContext if applicable). * @param username An ID to match the UserAccount.username field. * @param password The user's current password. * @return true if user was logged in, otherwise false */ boolean loginUser(String username, String password); /** Remove (logout) active user. */ void logoutUser(); /** Authenticate a user and make active using a login key */ boolean loginUserKey(String loginKey); /** Get a login key for the active user. By default expires in the number of hours configured in the Conf XML file in: user-facade.login-key.@expire-hours */ String getLoginKey(); String getLoginKey(float expireHours); /** If no user is logged in consider an anonymous user logged in. For internal purposes to run things that require authentication. */ boolean loginAnonymousIfNoUser(); /** Check to see if current user has the given permission. To have a permission a user must be in a group * (UserGroupMember => UserGroup) that has the given permission (UserGroupPermission). * * @param userPermissionId Permission ID for record in UserPermission or any arbitrary permission name (does * not have to be pre-configured, ie does not have to be in the UserPermission entity's table) * @return boolean set to true if user has permission, false if not. If no user is logged in, returns false. */ boolean hasPermission(String userPermissionId); /** Check to see if current user is in the given group (UserGroup). The user group concept in Moqui is similar to * the "role" concept in many security contexts (including Apache Shiro which is used in Moqui) though that term is * avoided because of the use of the term "role" for the Party part of the Mantle Universal Data Model. * * @param userGroupId The user group ID to check against. * @return boolean set to true if user is a member of the group, false if not. If no user is logged in, returns false. */ boolean isInGroup(String userGroupId); Set getUserGroupIdSet(); /** @return ID of the current active user (from the moqui.security.UserAccount entity). */ String getUserId(); /** @return Username of the current active user (NOT the UserAccount.userId, may even be a username from another system). */ String getUsername(); /** @return EntityValue for the current active user (the moqui.security.UserAccount entity). */ EntityValue getUserAccount(); /** @return ID of the user associated with the visit. May be different from the active user ID if a service or something is run explicitly as another user. */ String getVisitUserId(); /** @return ID for the current visit (aka session; from the Visit entity). Depending on the artifact being executed this may be null. */ String getVisitId(); /** @return The current visit (aka session; from the Visit entity). Depending on the artifact being executed this may be null. */ EntityValue getVisit(); String getVisitorId(); /** @return Client IP address from HTTP request or the configured client IP header (like X-Forwarded-For) */ String getClientIp(); } ================================================ FILE: framework/src/main/java/org/moqui/context/ValidationError.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; import org.moqui.util.StringUtilities; import java.util.HashMap; import java.util.Map; /** * ValidationError - used to track information about validation errors. * * This extends the BaseException and has additional information about the field that had the error, etc. * * This is not generally thrown all the way up to the user and is instead added to a list of validation errors as * things are running, and then all of them can be shown in context of the fields with the errors. */ @SuppressWarnings("unused") public class ValidationError extends BaseArtifactException { protected final String form; protected final String field; protected final String serviceName; public ValidationError(String field, String message, Throwable nested) { super(message, nested); this.form = null; this.field = field; this.serviceName = null; } public ValidationError(String form, String field, String serviceName, String message, Throwable nested) { super(message, nested); this.form = form; this.field = field; this.serviceName = serviceName; } public String getForm() { return form; } public String getFormPretty() { return StringUtilities.camelCaseToPretty(form); } public String getField() { return field; } public String getFieldPretty() { return StringUtilities.camelCaseToPretty(field); } public String getServiceName() { return serviceName; } public String getServiceNamePretty() { return StringUtilities.camelCaseToPretty(serviceName); } public String toStringPretty() { StringBuilder errorBuilder = new StringBuilder(); String message = getMessage(); if (message != null) errorBuilder.append(message); errorBuilder.append('('); String fieldPretty = getFieldPretty(); if (fieldPretty != null && !fieldPretty.isEmpty()) errorBuilder.append("for field ").append(fieldPretty); String formPretty = getFormPretty(); if (formPretty != null && !formPretty.isEmpty()) errorBuilder.append(" on form ").append(formPretty); String serviceNamePretty = getServiceNamePretty(); if (serviceNamePretty != null && !serviceNamePretty.isEmpty()) errorBuilder.append(" of service ").append(serviceNamePretty); return errorBuilder.toString(); } public Map getMap() { Map veMap = new HashMap<>(); veMap.put("form", form); veMap.put("field", field); veMap.put("serviceName", serviceName); veMap.put("formPretty", getFormPretty()); veMap.put("fieldPretty", getFieldPretty()); veMap.put("serviceNamePretty", getServiceNamePretty()); veMap.put("message", getMessage()); if (getCause() != null) veMap.put("cause", getCause().toString()); return veMap; } } ================================================ FILE: framework/src/main/java/org/moqui/context/WebFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import java.util.ArrayList; import java.util.List; import java.util.Map; import jakarta.servlet.ServletContext; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import jakarta.servlet.http.HttpSession; import org.moqui.context.MessageFacade.MessageInfo; /** Web Facade for access to HTTP Servlet objects and information. */ @SuppressWarnings("unused") public interface WebFacade { String getRequestUrl(); Map getParameters(); HttpServletRequest getRequest(); Map getRequestAttributes(); /** Returns a Map with request parameters including session saved, multi-part body, json body, declared and named * path parameters, and standard Servlet request parameters (query string parameters, form body parameters). */ Map getRequestParameters(); /** Returns a Map with only secure (encrypted if over HTTPS) request parameters including session saved, * multi-part body, json body, and form body parameters (standard Servlet request parameters not in query string). */ Map getSecureRequestParameters(); String getHostName(boolean withPort); /** Alternative to HttpServletRequest.getPathInfo() that uses URLDecoder to decode path segments to match the use of URLEncoder * for URL generation using the 'application/x-www-form-urlencoded' MIME format */ String getPathInfo(); /** Like getPathInfo() but returns a list of decoded path segment Strings. * If there is no extra path after the servlet path returns an empty list. */ ArrayList getPathInfoList(); /** If Content-Type request header is a text type and body length is greater than zero you can get the full body text here */ String getRequestBodyText(); /** Returns a String to append to a URL to make it distinct to force browser reload */ String getResourceDistinctValue(); HttpServletResponse getResponse(); HttpSession getSession(); Map getSessionAttributes(); /** Get the token to include in all POST requests with the name moquiSessionToken or the X-CSRF-Token request header (in the session as 'moqui.session.token') */ String getSessionToken(); ServletContext getServletContext(); Map getApplicationAttributes(); String getWebappRootUrl(boolean requireFullUrl, Boolean useEncryption); Map getErrorParameters(); List getSavedMessages(); List getSavedPublicMessages(); List getSavedErrors(); List getSavedValidationErrors(); /** Get saved (in session) and current MessageFacade validation errors for the given field name, if null returns all errors; if no errors found returns null */ List getFieldValidationErrors(String fieldName); /** A list of recent screen requests to show to a user (does not include requests to transitions or standalone screens). * Map contains 'name' (screen name plus up to 2 parameter values), 'url' (full URL with parameters), * 'screenLocation', 'image' (last menu image in screen render path), and 'imageType' fields. */ List getScreenHistory(); void sendJsonResponse(Object responseObj); void sendJsonError(int statusCode, String message, Throwable origThrowable); void sendTextResponse(String text); void sendTextResponse(String text, String contentType, String filename); /** Send content of specified resource location to client via HttpResponse. Always uses attachment Content-Disposition to tell browser to download. */ void sendResourceResponse(String location); /** Send content of specified resource location to client via HttpResponse. * @param location Resource location * @param inline If true use inline Content-Disposition to tell browser to display, otherwise use attachment to tell browser to download. */ void sendResourceResponse(String location, boolean inline); void sendError(int errorCode, String message, Throwable origThrowable); void handleJsonRpcServiceCall(); void handleEntityRestCall(List extraPathNameList, boolean masterNameInPath); void handleServiceRestCall(List extraPathNameList); void handleSystemMessage(List extraPathNameList); } ================================================ FILE: framework/src/main/java/org/moqui/context/WebMediaTypeException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.context; import org.moqui.BaseArtifactException; import java.util.Deque; public class WebMediaTypeException extends BaseArtifactException { public WebMediaTypeException(String str) { super(str); } public WebMediaTypeException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityCondition.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.io.Externalizable; import java.util.Map; /** Represents the conditions to be used to constrain a query. * * These can be used in various combinations using the different condition types. * * This class is mostly empty because it is a placeholder for use in the EntityConditionFactory and most functionality * is internal only. */ public interface EntityCondition extends Externalizable { ComparisonOperator EQUALS = ComparisonOperator.EQUALS; ComparisonOperator NOT_EQUAL = ComparisonOperator.NOT_EQUAL; ComparisonOperator LESS_THAN = ComparisonOperator.LESS_THAN; ComparisonOperator GREATER_THAN = ComparisonOperator.GREATER_THAN; ComparisonOperator LESS_THAN_EQUAL_TO = ComparisonOperator.LESS_THAN_EQUAL_TO; ComparisonOperator GREATER_THAN_EQUAL_TO = ComparisonOperator.GREATER_THAN_EQUAL_TO; ComparisonOperator IN = ComparisonOperator.IN; ComparisonOperator NOT_IN = ComparisonOperator.NOT_IN; ComparisonOperator BETWEEN = ComparisonOperator.BETWEEN; ComparisonOperator NOT_BETWEEN = ComparisonOperator.NOT_BETWEEN; ComparisonOperator LIKE = ComparisonOperator.LIKE; ComparisonOperator NOT_LIKE = ComparisonOperator.NOT_LIKE; ComparisonOperator IS_NULL = ComparisonOperator.IS_NULL; ComparisonOperator IS_NOT_NULL = ComparisonOperator.IS_NOT_NULL; JoinOperator AND = JoinOperator.AND; JoinOperator OR = JoinOperator.OR; enum ComparisonOperator { EQUALS, NOT_EQUAL, LESS_THAN, GREATER_THAN, LESS_THAN_EQUAL_TO, GREATER_THAN_EQUAL_TO, IN, NOT_IN, BETWEEN, NOT_BETWEEN, LIKE, NOT_LIKE, IS_NULL, IS_NOT_NULL } enum JoinOperator { AND, OR } /** Evaluate the condition in memory. */ boolean mapMatches(Map map); /** Used for EntityCache view-entity clearing by member-entity change */ boolean mapMatchesAny(Map map); /** Used for EntityCache view-entity clearing by member-entity change */ boolean mapKeysNotContained(Map map); /** Create a map of name/value pairs representing the condition. Returns false if the condition can't be * represented as simple name/value pairs ANDed together. */ boolean populateMap(Map map); /** Set this condition to ignore case in the query. * This may not have an effect for all types of conditions. * * @return Returns reference to the query for convenience. */ EntityCondition ignoreCase(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityConditionFactory.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.sql.Timestamp; import java.util.List; import java.util.Map; /** * Represents the conditions to be used to constrain a query. * * These can be used in various combinations using the different condition types. * */ @SuppressWarnings("unused") public interface EntityConditionFactory { EntityCondition getTrueCondition(); EntityCondition makeCondition(EntityCondition lhs, EntityCondition.JoinOperator operator, EntityCondition rhs); EntityCondition makeCondition(String fieldName, EntityCondition.ComparisonOperator operator, Object value); EntityCondition makeCondition(String fieldName, EntityCondition.ComparisonOperator operator, Object value, boolean orNull); EntityCondition makeConditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName); /** Default to JoinOperator of AND */ EntityCondition makeCondition(List conditionList); EntityCondition makeCondition(List conditionList, EntityCondition.JoinOperator operator); /** More convenient for scripts, etc. The conditionList parameter may contain Map or EntityCondition objects. */ EntityCondition makeCondition(List conditionList, String listOperator, String mapComparisonOperator, String mapJoinOperator); EntityCondition makeCondition(Map fieldMap, EntityCondition.ComparisonOperator comparisonOperator, EntityCondition.JoinOperator joinOperator); /** Default to ComparisonOperator of EQUALS and JoinOperator of AND */ EntityCondition makeCondition(Map fieldMap); EntityCondition makeConditionDate(String fromFieldName, String thruFieldName, Timestamp compareStamp); EntityCondition makeConditionWhere(String sqlWhereClause); /** Get a ComparisonOperator using an enumId for enum type "ComparisonOperator" */ EntityCondition.ComparisonOperator comparisonOperatorFromEnumId(String enumId); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityDataLoader.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.util.List; import java.util.Map; import java.util.Set; /** Used to load XML entity data into the database or into an EntityList. The XML can come from * a specific location, XML text already read from somewhere, or by searching all component data directories * and the entity-facade.load-data elements for XML entity data files that match a type in the Set of types * specified. * * The document should have a root element like <entity-facade-xml type="seed">. The * type attribute will be used to determine if the file should be loaded by whether or not it matches the values * specified for data types on the loader. */ @SuppressWarnings("unused") public interface EntityDataLoader { /** Location of the data file to load. Can be called multiple times to load multiple files. * @return Reference to this for convenience. */ EntityDataLoader location(String location); /** List of locations of files to load. Will be added to running list, so can be called multiple times and along * with the location() method too. * @return Reference to this for convenience. */ EntityDataLoader locationList(List locationList); /** String with XML text in it, ready to be parsed. * @return Reference to this for convenience. */ EntityDataLoader xmlText(String xmlText); EntityDataLoader csvText(String csvText); EntityDataLoader jsonText(String jsonText); /** A Set of data types to match against the candidate files from the component data directories and the * entity-facade.load-data elements. * @return Reference to this for convenience. */ EntityDataLoader dataTypes(Set dataTypes); /** Used along with dataTypes; a list of component names to load data from. If none specified will load from all components. */ EntityDataLoader componentNameList(List componentNames); /** The transaction timeout for this data load in seconds. Defaults to 3600 which is 1 hour. * @return Reference to this for convenience. */ EntityDataLoader transactionTimeout(int tt); /** If true instead of doing a query for each value from the file it will just try to insert it and if it fails then * it will try to update the existing record. Good for situations where most of the values will be new in the db. * @return Reference to this for convenience. */ EntityDataLoader useTryInsert(boolean useTryInsert); /** If true only creates records that don't exist, does not update existing records. * @return Reference to this for convenience. */ EntityDataLoader onlyCreate(boolean onlyInsert); /** If true will check all foreign key relationships for each value and if any of them are missing create a new * record with primary key only to avoid foreign key constraint errors. * * This should only be used when you are confident that the rest of the data for these new fk records will be loaded * from somewhere else to avoid having orphaned records. * * @return Reference to this for convenience. */ EntityDataLoader dummyFks(boolean dummyFks); /** Files with no actions (or no messages for check) are logged in the check and load message list by default, * set to false to not add messages for them */ EntityDataLoader messageNoActionFiles(boolean messageNoActionFiles); /** Set to true to disable Entity Facade ECA rules (for this import only, does not affect other things happening * in the system). * @return Reference to this for convenience. */ EntityDataLoader disableEntityEca(boolean disable); EntityDataLoader disableAuditLog(boolean disable); EntityDataLoader disableFkCreate(boolean disable); EntityDataLoader disableDataFeed(boolean disable); EntityDataLoader csvDelimiter(char delimiter); EntityDataLoader csvCommentStart(char commentStart); EntityDataLoader csvQuoteChar(char quoteChar); /** For CSV files use this name (entity or service name) instead of looking for it on line one in the file */ EntityDataLoader csvEntityName(String entityName); /** For CSV files use these field names instead of looking for them on line two in the file */ EntityDataLoader csvFieldNames(List fieldNames); /** Default values for all records to load, if applicable for given entity or service */ EntityDataLoader defaultValues(Map defaultValues); /** Only check the data against matching records in the database. Report on records that don't exist in the database * and any differences with records that have matching primary keys. * * @return List of messages about each comparison between data in the file(s) and data in the database. */ List check(); long check(List messageList); /** A variation on check() that returns structured field diff information instead of diff info in messages */ List> checkInfo(); long checkInfo(List> diffInfoList, List messageList); /** Load the values into the database(s). */ long load(); long load(List messageList); /** Create an EntityList with all of the values from the data file(s). * * @return EntityList representing a List of EntityValue objects for the values in the XML document(s). */ EntityList list(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityDataWriter.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.io.OutputStream; import java.io.Writer; import java.sql.Timestamp; import java.util.Collection; import java.util.List; import java.util.Map; /** Used to write XML entity data from the database to a writer. * * The document will have a root element like <entity-facade-xml>. */ @SuppressWarnings("unused") public interface EntityDataWriter { FileType XML = FileType.XML; FileType JSON = FileType.JSON; FileType CSV = FileType.CSV; enum FileType { XML, JSON, CSV } EntityDataWriter fileType(FileType ft); EntityDataWriter fileType(String ft); /** Specify the name of an entity to query and export. Data is queried and exporting from entities in the order they * are added by calling this or entityNames() multiple times. * @param entityName The entity name * @return Reference to this for convenience. */ EntityDataWriter entityName(String entityName); /** A List of entity names to query and export. Data is queried and exporting from entities in the order they are * specified in this list and other calls to this or entityName(). * @param entityNames The list of entity names * @return Reference to this for convenience. */ EntityDataWriter entityNames(Collection entityNames); EntityDataWriter skipEntityName(String entityName); EntityDataWriter skipEntityNames(Collection enList); /** * Add all entities to entity names. * For backward compatibility (before the skip entity names feature), if any entity names were specified before * calling this they are excluded from all entities instead of included. */ EntityDataWriter allEntities(); /** Should the dependent records of each record be written? If set will include 2 levels of dependents by default, * use dependentLevels() to specify a different number of levels. * @param dependents Boolean dependents indicator * @return Reference to this for convenience. */ EntityDataWriter dependentRecords(boolean dependents); /** The number of levels of dependents to include for records written. If set dependentRecords will be considered true. * If dependentRecords is set but no level limit is specified all levels found will be written (may be large and not * what is desired). */ EntityDataWriter dependentLevels(int levels); /** The name of a master definition, applied to all written entities that have a matching master definition otherwise * just the single record is written, or dependent records if dependentREcords or dependentLevels options specified. */ EntityDataWriter master(String masterName); /** A Map of field name, value pairs to filter the results by. Each name/value only used on entities that have a * field matching the name. * @param filterMap Map with name/value pairs to filter by * @return Reference to this for convenience. */ EntityDataWriter filterMap(Map filterMap); /** Field names to order (sort) the results by. Each name only used on entities with a field matching the name. * May be called multiple times. Each entry may be a comma-separated list of field names. * @param orderByList List with field names to order by * @return Reference to this for convenience. */ EntityDataWriter orderBy(List orderByList); /** From date for lastUpdatedStamp on each entity (lastUpdatedStamp must be greater than or equal (>=) to fromDate). * @param fromDate The from date * @return Reference to this for convenience. */ EntityDataWriter fromDate(Timestamp fromDate); /** Thru date for lastUpdatedStamp on each entity (lastUpdatedStamp must be less than (<) to thruDate). * @param thruDate The thru date * @return Reference to this for convenience. */ EntityDataWriter thruDate(Timestamp thruDate); /** Write Date, Time, and Timestamp fields in ISO format instead of millis since epoch integer; currently only supported for CSV */ EntityDataWriter isoDateTime(boolean iso); /** Write table and column names instead of entity and field names; currently only supported for CSV */ EntityDataWriter tableColumnNames(boolean tcn); /** Write all results to a single file. * @param filename The path and name of the file to write values to * @return Count of values written */ int file(String filename); int zipFile(String filenameWithinZip, String zipFilename); /** Write the results to a file for each entity in the specified directory. * @param path The path of the directory to create files in * @return Count of values written */ int directory(String path); /** Write to a directory in a zip file located at zipFilename */ int zipDirectory(String pathWithinZip, String zipFilename); /** Write to a directory in a zip file in an OutputStream; NOTE: closes OutputStream when done */ int zipDirectory(String pathWithinZip, OutputStream outputStream); /** Write the results to a Writer. * @param writer The Writer to write to * @return Count of values written */ int writer(Writer writer); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityDatasourceFactory.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import org.moqui.util.MNode; import javax.sql.DataSource; import java.util.List; public interface EntityDatasourceFactory { EntityDatasourceFactory init(EntityFacade ef, MNode datasourceNode); void destroy(); boolean checkTableExists(String entityName); boolean checkAndAddTable(String entityName); int checkAndAddAllTables(); EntityValue makeEntityValue(String entityName); EntityFind makeEntityFind(String entityName); void createBulk(List valueList); /** Return the JDBC DataSource, if applicable. Return null if no JDBC DataSource exists for this Entity Datasource. */ DataSource getDataSource(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityDynamicView.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import org.moqui.util.MNode; import java.util.List; import java.util.Map; /** This class is used for declaring Dynamic View Entities, to be used and thrown away. * A special method exists on the EntityFind to accept a EntityDynamicView instead of an entityName. * The methods here return a reference to itself (this) for convenience. */ @SuppressWarnings("unused") public interface EntityDynamicView { /** This optionally sets a name for the dynamic view entity. If not used will default to "DynamicView" */ EntityDynamicView setEntityName(String entityName); EntityDynamicView addMemberEntity(String entityAlias, String entityName, String joinFromAlias, Boolean joinOptional, Map entityKeyMaps); EntityDynamicView addRelationshipMember(String entityAlias, String joinFromAlias, String relationshipName, Boolean joinOptional); List getMemberEntityNodes(); EntityDynamicView addAliasAll(String entityAlias, String prefix); EntityDynamicView addAlias(String entityAlias, String name); /** Add an alias, full detail. All parameters can be null except entityAlias and name. */ EntityDynamicView addAlias(String entityAlias, String name, String field, String function); EntityDynamicView addRelationship(String type, String title, String relatedEntityName, Map entityKeyMaps); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; /** * EntityException * */ public class EntityException extends org.moqui.BaseException { public EntityException(String str) { super(str); } public EntityException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.sql.Connection; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Calendar; import java.util.List; import java.util.Map; import org.moqui.etl.SimpleEtl; import org.moqui.util.MNode; import org.w3c.dom.Element; /** The main interface for general database operations in Moqui. */ @SuppressWarnings("unused") public interface EntityFacade { /** Get a EntityDatasourceFactory implementation for a group. This is most useful for non-SQL databases to get * access to underlying details. */ EntityDatasourceFactory getDatasourceFactory(String groupName); /** Get a EntityConditionFactory object that can be used to create and assemble conditions used for finds. * * @return The facade's active EntityConditionFactory object. */ EntityConditionFactory getConditionFactory(); /** Creates a Entity in the form of a EntityValue without persisting it * * @param entityName The name of the entity to make a value object for. * @return EntityValue for the named entity. */ EntityValue makeValue(String entityName); /** Create an EntityFind object that can be used to specify additional options, and then to execute one or more * finds (queries). * * @param entityName The Name of the Entity as defined in the entity XML file, can be null. * @return An EntityFind object. */ EntityFind find(String entityName); EntityFind find(MNode entityFindNode); EntityValue fastFindOne(String entityName, Boolean useCache, boolean disableAuthz, Object... values); /** Bulk create EntityValue records. All values must be for the same entity. */ void createBulk(List valueList); /** Meant for processing entity REST requests, but useful more generally as a simple way to perform entity operations. * * @param operation Can be get/find, post/create, put/store, patch/update, or delete/delete. * @param entityPath First element should be an entity name or short-alias, followed by (optionally depending on * operation) the PK fields for the entity in the order they appear in the entity definition * followed optionally by (one or more) relationship name or short-alias and then PK values for * the related entity, not including any PK fields defined in the relationship. * @param parameters A Map of extra parameters, used depending on the operation. For find operations these can be * any parameters handled by the EntityFind.searchFormInputs() method. For create, update, store, * and delete operations these parameters are for non-PK fields and as an alternative to the * entity path for PK field values. For find operations also supports a "dependents" parameter * that if true will get dependent values of the record referenced in the entity path. * @param masterNameInPath If true the second entityPath entry must be the name of a master entity definition */ Object rest(String operation, List entityPath, Map parameters, boolean masterNameInPath); /** Do a database query with the given SQL and return the results as an EntityList for the given entity and with * selected columns mapped to the listed fields. * * @param sql The actual SQL to run. * @param sqlParameterList Parameter values that correspond with any question marks (?) in the SQL. * @param entityName Name of the entity to map the results to, may be a view-entity. * @param fieldList List of entity field names in order that they match columns selected in the query. * If not specified all fields will be used in the order they are specified in the entity definition. * @return EntityListIterator with results of query. */ EntityListIterator sqlFind(String sql, List sqlParameterList, String entityName, List fieldList); /** Find and assemble data documents represented by a Map that can be easily turned into a JSON document. This is * used for searching by the Data Search feature and for data feeds to other systems with the Data Feed feature. * * @param dataDocumentId Used to look up the DataDocument and related records (DataDocument* entities). * @param condition An optional condition to AND with from/thru updated timestamps and any DataDocumentCondition * records associated with the DataDocument. * @param fromUpdateStamp The lastUpdatedStamp on at least one entity selected must be after (>=) this Timestamp. * @param thruUpdatedStamp The lastUpdatedStamp on at least one entity selected must be before (<) this Timestamp. * @return List of Maps with these entries: * - _index = DataDocument.indexName * - _type = dataDocumentId * - _id = pk field values from primary entity, underscore separated * - _timestamp = timestamp when the document was created * - Map for primary entity (with primaryEntityName as key) * - nested List of Maps for each related entity from DataDocumentField records with aliased fields * (with relationship name as key) */ ArrayList getDataDocuments(String dataDocumentId, EntityCondition condition, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp); /** Find and assemble data documents represented by a Map that can be easily turned into a JSON document. This is * similar to the getDataDocuments() method except that the dataDocumentId(s) are looked up using the dataFeedId. * * @param dataFeedId Used to look up the DataFeed records to find the associated DataDocument records. * @param fromUpdateStamp The lastUpdatedStamp on at least one entity selected must be after (>=) this Timestamp. * @param thruUpdatedStamp The lastUpdatedStamp on at least one entity selected must be before (<) this Timestamp. * @return List of Maps with these entries: */ ArrayList getDataFeedDocuments(String dataFeedId, Timestamp fromUpdateStamp, Timestamp thruUpdatedStamp); /** Get the next guaranteed unique seq id from the sequence with the given sequence name; * if the named sequence doesn't exist, it will be created. * * @param seqName The name of the sequence to get the next seq id from * @param staggerMax The maximum amount to stagger the sequenced ID, if 1 the sequence will be incremented by 1, * otherwise the current sequence ID will be incremented by a value between 1 and staggerMax * @param bankSize The size of the "bank" of values to get from the database (defaults to 1) * @return Long with the next seq id for the given sequence name */ String sequencedIdPrimary(String seqName, Long staggerMax, Long bankSize); /** Gets the group name for specified entityName * @param entityName The name of the entity to get the group name * @return String with the group name that corresponds to the entityName */ String getEntityGroupName(String entityName); /** Use this to get a Connection if you want to do JDBC operations directly. This Connection will be enlisted in * the active Transaction. * * @param groupName The name of entity group to get a connection for. * Corresponds to the entity.@group attribute and the moqui-conf datasource.@group-name attribute. * @return JDBC Connection object for the associated database * @throws EntityException if there is an error getting a Connection */ Connection getConnection(String groupName) throws EntityException; Connection getConnection(String groupName, boolean useClone) throws EntityException; // ======= Import/Export (XML, CSV, etc) Related Methods ======== /** Make an object used to load XML or CSV entity data into the database or into an EntityList. The files come from * a specific location, text already read from somewhere, or by searching all component data directories * and the entity-facade.load-data elements for entity data files that match a type in the Set of types * specified. * * An XML document should have a root element like <entity-facade-xml type="seed">. The * type attribute will be used to determine if the file should be loaded by whether or not it matches the values * specified for data types on the loader. * * @return EntityDataLoader instance */ EntityDataLoader makeDataLoader(); /** Used to write XML entity data from the database to a writer. * * The document will have a root element like <entity-facade-xml>. * * @return EntityDataWriter instance */ EntityDataWriter makeDataWriter(); SimpleEtl.Loader makeEtlLoader(); /** Make an EntityValue and populate it with the data (attributes and sub-elements) from the given XML element. * * @param element A XML DOM element representing a single value/record for an entity. * @return EntityValue object populated with data from the element. */ EntityValue makeValue(Element element); Calendar getCalendarForTzLc(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityFind.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import org.moqui.etl.SimpleEtl; import java.util.ArrayList; import java.util.Collection; import java.util.List; import java.util.Map; /** * Used to setup various options for an entity find (query). * * All methods to set options modify the option then return this modified object to allow method call chaining. It is * important to note that this object is not immutable and is modified internally, and returning EntityFind is just a * self reference for convenience. * * Even after a query a find object can be modified and then used to perform another query. */ @SuppressWarnings("unused") public interface EntityFind extends java.io.Serializable, SimpleEtl.Extractor { /** The Name of the Entity to use, as defined in an entity XML file. * * @return Returns this for chaining of method calls. */ EntityFind entity(String entityName); String getEntity(); /** Make a dynamic view object to use instead of the entity name (if used the entity name will be ignored). * * If called multiple times will return the same object. * * @return EntityDynamicView object to add view details to. */ EntityDynamicView makeEntityDynamicView(); // ======================== Conditions (Where and Having) ================= /** Add a field to the find (where clause). * If a field has been set with the same name, this will replace that field's value. * If any other constraints are already in place this will be ANDed to them. * * @return Returns this for chaining of method calls. */ EntityFind condition(String fieldName, Object value); /** Compare the named field to the value using the operator. */ EntityFind condition(String fieldName, EntityCondition.ComparisonOperator operator, Object value); EntityFind condition(String fieldName, String operator, Object value); /** Compare a field to another field using the operator. */ EntityFind conditionToField(String fieldName, EntityCondition.ComparisonOperator operator, String toFieldName); /** Add a Map of fields to the find (where clause). * If a field has been set with the same name and any of the Map keys, this will replace that field's value. * Fields set in this way will be combined with other conditions (if applicable) just before doing the query. * * This will do conversions if needed from Strings to field types as needed, and will only get keys that match * entity fields. In other words, it does the same thing as: * EntityValue.setFields(fields, true, null, null) * * @return Returns this for chaining of method calls. */ EntityFind condition(Map fields); /** Add a EntityCondition to the find (where clause). * * @return Returns this for chaining of method calls. */ EntityFind condition(EntityCondition condition); /** Add conditions for the standard effective date query pattern including from field is null or earlier than * or equal to compareStamp and thru field is null or later than or equal to compareStamp. */ EntityFind conditionDate(String fromFieldName, String thruFieldName, java.sql.Timestamp compareStamp); boolean getHasCondition(); boolean getHasHavingCondition(); /** Add a EntityCondition to the having clause of the find. * If any having constraints are already in place this will be ANDed to them. * * @return Returns this for chaining of method calls. */ EntityFind havingCondition(EntityCondition condition); /** Get the current where EntityCondition. */ EntityCondition getWhereEntityCondition(); /** Get the current having EntityCondition. */ EntityCondition getHavingEntityCondition(); /** Adds conditions for the fields found in the inputFieldsMapName Map. * * The fields and special fields with suffixes supported are the same as the *-find fields in the XML * Forms. This means that you can use this to process the data from the various inputs generated by XML * Forms. The suffixes include things like *_op for operators and *_ic for ignore case. * * For historical reference, this does basically what the Apache OFBiz prepareFind service does. * * @param inputFieldsMapName The map to get form fields from. If empty will look at the ec.web.parameters map if * the web facade is available, otherwise the current context (ec.context). * @param defaultOrderBy If there is not an orderByField parameter this is used instead. * @param alwaysPaginate If true pagination offset/limit will be set even if there is no pageIndex parameter. * @return Returns this for chaining of method calls. */ EntityFind searchFormInputs(String inputFieldsMapName, String defaultOrderBy, boolean alwaysPaginate); EntityFind searchFormMap(Map inputFieldsMap, Map defaultParameters, String skipFields, String defaultOrderBy, boolean alwaysPaginate); // ======================== General/Common Options ======================== /** The field of the named entity to get from the database. * If any select fields have already been specified this will be added to the set. * @return Returns this for chaining of method calls. */ EntityFind selectField(String fieldToSelect); /** The fields of the named entity to get from the database; if empty or null all fields will be retrieved. * @return Returns this for chaining of method calls. */ EntityFind selectFields(Collection fieldsToSelect); List getSelectFields(); /** A field of the find entity to order the query by. Optionally add a " ASC" to the end or "+" to the * beginning for ascending, or " DESC" to the end of "-" to the beginning for descending. * If any other order by fields have already been specified this will be added to the end of the list. * The String may be a comma-separated list of field names. Only fields that actually exist on the entity will be * added to the order by list. * * @return Returns this for chaining of method calls. */ EntityFind orderBy(String orderByFieldName); /** Each List entry is passed to the orderBy(String orderByFieldName) method, see it for details. * @return Returns this for chaining of method calls. */ EntityFind orderBy(List orderByFieldNames); List getOrderBy(); /** Look in the cache before finding in the datasource. * Defaults to setting on entity definition. * * @return Returns this for chaining of method calls. */ EntityFind useCache(Boolean useCache); boolean getUseCache(); /** Use a clone of the configured datasource, if at least one clone is configured */ EntityFind useClone(boolean uc); // ======================== Advanced Options ============================== /** Specifies whether the values returned should be filtered to remove duplicate values. * Default is false. * * @return Returns this for chaining of method calls. */ EntityFind distinct(boolean distinct); boolean getDistinct(); /** The offset, ie the starting row to return. Default (null) means start from the first actual row. * Only applicable for list() and iterator() finds. * * @return Returns this for chaining of method calls. */ EntityFind offset(Integer offset); /** Specify the offset in terms of page index and size. Actual offset is pageIndex * pageSize. */ EntityFind offset(int pageIndex, int pageSize); Integer getOffset(); /** The limit, ie max number of rows to return. Default (null) means all rows. * Only applicable for list() and iterator() finds. * * @return Returns this for chaining of method calls. */ EntityFind limit(Integer limit); Integer getLimit(); /** For use with searchFormInputs when paginated. Equals offset (default 0) divided by page size. */ int getPageIndex(); /** For use with searchFormInputs when paginated. Equals limit (default 20; exists for consistency/convenience along with getPageIndex()). */ int getPageSize(); /** Lock the selected record so only this transaction can change it until it is ended. * If this is set when the find is done the useCache setting will be ignored as this will always get the data from * the database. * Default is false. * * @return Returns this for chaining of method calls. */ EntityFind forUpdate(boolean forUpdate); boolean getForUpdate(); // ======================== JDBC Options ============================== /** Specifies how the ResultSet will be traversed. Available values: ResultSet.TYPE_FORWARD_ONLY, * ResultSet.TYPE_SCROLL_INSENSITIVE (default) or ResultSet.TYPE_SCROLL_SENSITIVE. See the java.sql.ResultSet JavaDoc for * more information. If you want it to be fast, use the common option: ResultSet.TYPE_FORWARD_ONLY. * For partial results where you want to jump to an index make sure to use TYPE_SCROLL_INSENSITIVE. * Defaults to ResultSet.TYPE_SCROLL_INSENSITIVE. * * @return Returns this for chaining of method calls. */ EntityFind resultSetType(int resultSetType); int getResultSetType(); /** Specifies whether or not the ResultSet can be updated. Available values: * ResultSet.CONCUR_READ_ONLY (default) or ResultSet.CONCUR_UPDATABLE. Should pretty much always be * ResultSet.CONCUR_READ_ONLY with the Entity Facade since updates are generally done as separate operations. * Defaults to CONCUR_READ_ONLY. * * @return Returns this for chaining of method calls. */ EntityFind resultSetConcurrency(int resultSetConcurrency); int getResultSetConcurrency(); /** The JDBC fetch size for this query. Default (null) will fall back to datasource settings. * This is not the fetch as in the OFFSET/FETCH SQL clause (use limit for that), and is rather the JDBC fetch to * determine how many rows to get back on each round-trip to the database. * * Only applicable for list() and iterator() finds. * * @return Returns this for chaining of method calls. */ EntityFind fetchSize(Integer fetchSize); Integer getFetchSize(); /** The JDBC max rows for this query. Default (null) will fall back to datasource settings. * This is the maximum number of rows the ResultSet will keep in memory at any given time before releasing them * and if requested they are retrieved from the database again. * * Only applicable for list() and iterator() finds. * * @return Returns this for chaining of method calls. */ EntityFind maxRows(Integer maxRows); Integer getMaxRows(); /** Disable authorization for this find */ EntityFind disableAuthz(); /** If true don't do find (return empty list or null) when there are no search form parameters */ EntityFind requireSearchFormParameters(boolean req); /** Determine if this find should be cached by the various options on entity definition and EntityFind */ boolean shouldCache(); // ======================== Run Find Methods ============================== /** Runs a find with current options to get a single record by primary key. */ EntityValue one() throws EntityException; /** Runs a find with current options to get a single record by primary key, then gets all related/dependent * entities according to the named master definition (default name is 'default'). */ Map oneMaster(String name) throws EntityException; /** Runs a find with current options to get a list of records. */ EntityList list() throws EntityException; /** Runs a find with current options to get a list of records, then for each result gets all related/dependent * entities according to the named master definition (default name is 'default') */ List> listMaster(String name) throws EntityException; /** * Runs a find with current options and returns an EntityListIterator object which retains an open JDBC Connection * and ResultSet until closed. This method ignores the cache setting and always gets results from the database. * * The returned EntityListIterator must be closed when you are done with it using the close() method in a finally * block to ensure it is closed regardless of exceptions. For example: * *
     * EntityListIterator eli = entityFind.iterator();
     * try {
     *     EntityValue ev;
     *     while ((ev = eli.next()) != null) {
     *         // do stuff with ev
     *     }
     * } finally {
     *     eli.close();
     * }
     * 
*/ EntityListIterator iterator() throws EntityException; /** Runs a find with current options to get a count of matching records. */ long count() throws EntityException; /** Update a set of values that match a condition. * * @param fieldsToSet The fields of the named entity to set in the database * @return long representing number of rows effected by this operation * @throws EntityException */ long updateAll(Map fieldsToSet) throws EntityException; /** Delete entity records that match a condition. * * @return long representing number of rows effected by this operation * @throws EntityException */ long deleteAll() throws EntityException; /** If supported by underlying data source get the text (SQL, etc) used for the find query. * Will have multiple values if multiple queries done with this find. */ ArrayList getQueryTextList(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityList.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import groovy.lang.Closure; import java.io.Externalizable; import java.io.Writer; import java.sql.Timestamp; import java.util.*; /** * Contains a list of EntityValue objects. * Entity List that adds some additional operations like filtering to the basic List<EntityValue>. * * The various methods here modify the internal list for efficiency and return a reference to this for convenience. * If you want a new EntityList with the modifications, use clone() or cloneList() then modify it. */ @SuppressWarnings("unused") public interface EntityList extends List, Iterable, Cloneable, RandomAccess, Externalizable { /** Get the first value in the list. * * @return EntityValue that is first in the list. */ EntityValue getFirst(); /** Modify this EntityList so that it contains only the values that are active for the moment passed in. * The results include values that match the fromDate, but exclude values that match the thruDate. * *@param fromDateName The name of the from/beginning date/time field. Defaults to "fromDate". *@param thruDateName The name of the thru/ending date/time field. Defaults to "thruDate". *@param moment The point in time to compare the values to; if null the current system date/time is used. *@return A reference to this for convenience. */ EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment); EntityList filterByDate(String fromDateName, String thruDateName, Timestamp moment, boolean ignoreIfEmpty); /** Modify this EntityList so that it contains only the values that match the values in the fields parameter. * *@param fields The name/value pairs that must match for a value to be included in the output list. *@return List of EntityValue objects that match the values in the fields parameter. */ EntityList filterByAnd(Map fields); EntityList filterByAnd(Map fields, Boolean include); /** Modify this EntityList so that it contains only the values that match the values in the namesAndValues parameter. * *@param namesAndValues Must be an even number of parameters as field name then value repeated as needed *@return List of EntityValue objects that match the values in the fields parameter. */ EntityList filterByAnd(Object... namesAndValues); EntityList removeByAnd(Map fields); /** Modify this EntityList so that it includes (or excludes) values matching the condition. * * @param condition EntityCondition to filter by. * @param include If true include matching values, if false exclude matching values. * Defaults to true (include, ie only include values that do meet condition). * @return List with filtered values. */ EntityList filterByCondition(EntityCondition condition, Boolean include); /** Modify this EntityList so that it includes (or excludes) entity values where the closure evaluates to true. * The closure is called with a single argument, the current EntityValue in the list, and should evaluate to a Boolean. */ EntityList filter(Closure closure, Boolean include); /** Find the first value in this EntityList where the closure evaluates to true. */ EntityValue find(Closure closure); EntityValue findByAnd(Map fields); EntityValue findByAnd(Object... namesAndValues); /** Different from filter* method semantics, does not modify this EntityList. Returns a new EntityList with just the * values where the closure evaluates to true. */ EntityList findAll(Closure closure); /** Modify this EntityList to only contain up to limit values starting at the offset. * * @param offset Starting index to include * @param limit Include only this many values * @return List with filtered values. */ EntityList filterByLimit(Integer offset, Integer limit); /** For limit filter in a cached entity-find with search-form-inputs, done after the query */ EntityList filterByLimit(String inputFieldsMapName, boolean alwaysPaginate); /** The offset used to filter the list, if filterByLimit has been called. */ Integer getOffset(); /** The limit used to filter the list, if filterByLimit has been called. */ Integer getLimit(); /** For use with filterByLimit when paginated. Equals offset (default 0) divided by page size. */ int getPageIndex(); /** For use with filterByLimit when paginated. Equals limit (default 20; for use along with getPageIndex()). */ int getPageSize(); /** Modify this EntityList so that is ordered by the field names passed in. * *@param fieldNames The field names for the entity values to sort the list by. Optionally prefix each field name * with a plus sign (+) for ascending or a minus sign (-) for descending. Defaults to ascending. *@return List of EntityValue objects in the specified order. */ EntityList orderByFields(List fieldNames); int indexMatching(Map valueMap); void move(int fromIndex, int toIndex); /** Adds the value to this list if the value isn't already in it. Returns reference to this list. */ EntityList addIfMissing(EntityValue value); /** Adds each value in the passed list to this list if the value isn't already in it. Returns reference to this list. */ EntityList addAllIfMissing(EntityList el); /** Writes XML text with an attribute or CDATA element for each field of each record. If dependents is true also * writes all dependent (descendant) records. * @param writer A Writer object to write to * @param prefix A prefix to put in front of the entity name in the tag name * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents * @return The number of records written */ int writeXmlText(Writer writer, String prefix, int dependentLevels); /** Method to implement the Iterable interface to allow an EntityList to be used in a foreach loop. * * @return Iterator<EntityValue> to iterate over internal list. */ @Override Iterator iterator(); /** Get a list of Map (not EntityValue) objects. If dependentLevels is greater than zero includes nested dependents * in the Map for each value. */ List> getPlainValueList(int dependentLevels); List> getMasterValueList(String name); ArrayList> getValueMapList(); EntityList cloneList(); void setFromCache(); boolean isFromCache(); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityListIterator.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import java.io.Writer; import java.util.ListIterator; /** * Entity Cursor List Iterator for Handling Cursored Database Results */ @SuppressWarnings("unused") public interface EntityListIterator extends ListIterator, AutoCloseable { /** Close the underlying ResultSet and Connection. This must ALWAYS be called when done with an EntityListIterator. */ void close() throws EntityException; /** Sets the cursor position to just after the last result so that previous() will return the last result */ void afterLast() throws EntityException; /** Sets the cursor position to just before the first result so that next() will return the first result */ void beforeFirst() throws EntityException; /** Sets the cursor position to last result; if result set is empty returns false */ boolean last() throws EntityException; /** Sets the cursor position to last result; if result set is empty returns false */ boolean first() throws EntityException; /** NOTE: Calling this method does return the current value, but so does calling next() or previous(), so calling * one of those AND this method will cause the value to be created twice */ EntityValue currentEntityValue() throws EntityException; int currentIndex() throws EntityException; /** performs the same function as the ResultSet.absolute method; * if rowNum is positive, goes to that position relative to the beginning of the list; * if rowNum is negative, goes to that position relative to the end of the list; * a rowNum of 1 is the same as first(); a rowNum of -1 is the same as last() */ boolean absolute(int rowNum) throws EntityException; /** performs the same function as the ResultSet.relative method; * if rows is positive, goes forward relative to the current position; * if rows is negative, goes backward relative to the current position; */ boolean relative(int rows) throws EntityException; /** * PLEASE NOTE: Because of the nature of the JDBC ResultSet interface this method can be very inefficient; it is * much better to just use next() until it returns null. * * For example, you could use the following to iterate through the results in an EntityListIterator: * *
     * EntityValue nextValue;
     * while ((nextValue = eli.next()) != null) { ... }
     * 
*/ @Override boolean hasNext(); /** PLEASE NOTE: Because of the nature of the JDBC ResultSet interface this method can be very inefficient; it is * much better to just use previous() until it returns null. */ @Override boolean hasPrevious(); /** Moves the cursor to the next position and returns the EntityValue object for that position; if there is no next, * returns null. * * For example, you could use the following to iterate through the results in an EntityListIterator: * *
     * EntityValue nextValue;
     * while ((nextValue = eli.next()) != null) { ... }
     * 
*/ @Override EntityValue next(); /** Returns the index of the next result, but does not guarantee that there will be a next result */ @Override int nextIndex(); /** Moves the cursor to the previous position and returns the EntityValue object for that position; if there is no * previous, returns null. */ @Override EntityValue previous(); /** Returns the index of the previous result, but does not guarantee that there will be a previous result */ @Override int previousIndex(); void setFetchSize(int rows) throws EntityException; EntityList getCompleteList(boolean closeAfter) throws EntityException; /** Gets a partial list of results starting at start and containing at most number elements. * Start is a one based value, ie 1 is the first element. */ EntityList getPartialList(int offset, int limit, boolean closeAfter) throws EntityException; /** Writes XML text with an attribute or CDATA element for each field of each record. If dependents is true also * writes all dependent (descendant) records. * @param writer A Writer object to write to * @param prefix A prefix to put in front of the entity name in the tag name * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents * @return The number of records written */ int writeXmlText(Writer writer, String prefix, int dependentLevels); int writeXmlTextMaster(Writer writer, String prefix, String masterName); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityNotFoundException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; public class EntityNotFoundException extends EntityException { public EntityNotFoundException(String str) { super(str); } // public EntityNotFoundException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityValue.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; import org.moqui.etl.SimpleEtl; import org.w3c.dom.Document; import org.w3c.dom.Element; import javax.sql.rowset.serial.SerialBlob; import java.io.Externalizable; import java.io.Writer; import java.math.BigDecimal; import java.util.List; import java.util.Map; import java.util.Set; /** Entity Value Interface - Represents a single database record. */ @SuppressWarnings("unused") public interface EntityValue extends Map, Externalizable, Comparable, Cloneable, SimpleEtl.Entry { String resolveEntityName(); String resolveEntityNamePretty(); /** Returns true if any field has been modified */ boolean isModified(); /** Returns true if the field has been modified */ boolean isFieldModified(String name); /** Treat field as modified for update() even if not to force a resend to the DB */ EntityValue touchField(String name); /** Returns true if a value for the field is set, even if it is null */ boolean isFieldSet(String name); /** Returns true if the name is a valid field name for the entity this is a value of, * false otherwise (meaning get(), set(), etc calls with throw an exception with the field name) */ boolean isField(String name); boolean isMutable(); /** Gets a cloned, mutable Map with the field values that is independent of this value object. Can be augmented or * modified without modifying or being constrained by this entity value. */ Map getMap(); /** Get the named field. * * If there is a matching entry in the moqui.basic.LocalizedEntityField entity using the Locale in the current * ExecutionContext then that will be returned instead. * * This method also supports getting related entities using their relationship name, formatted as * "${title}${related-entity-name}". When doing so it is like calling * findRelated(relationshipName, null, null, null, null) for type many relationships, or * findRelatedOne(relationshipName, null, null) for type one relationships. * * @param name The field name to get, or the name of the relationship to get one or more related values from. * @return Object with the value of the field, or the related EntityValue or EntityList. */ Object get(String name); /** Get simple fields only (no localization, no relationship) and don't check to see if it is a valid field; mostly * for performance reasons and for well tested code with known field names. If it is not a valid field name will * just return null and not throw an error, ie doesn't check for valid field names. */ Object getNoCheckSimple(String name); /** Returns true if the entity contains all of the primary key fields. */ boolean containsPrimaryKey(); Map getPrimaryKeys(); String getPrimaryKeysString(); /** Sets the named field to the passed value, even if the value is null * @param name The field name to set * @param value The value to set * @return reference to this for convenience */ EntityValue set(String name, Object value); /** Sets fields on this entity from the Map of fields passed in using the entity definition to only get valid * fields from the Map. For any String values passed in this will call setString to convert based on the field * definition, otherwise it sets the Object as-is. * * @param fields The fields Map to get the values from * @return reference to this for convenience */ EntityValue setAll(Map fields); /** Sets the named field to the passed value, converting the value from a String to the corresponding type using * Type.valueOf() * * If the String "null" is passed in it will be treated the same as a null value. If you really want to set a * String of "null" then pass in "\null". * * @param name The field name to set * @param value The String value to convert and set * @return reference to this for convenience */ EntityValue setString(String name, String value); Boolean getBoolean(String name); String getString(String name); java.sql.Timestamp getTimestamp(String name); java.sql.Time getTime(String name); java.sql.Date getDate(String name); Long getLong(String name); Double getDouble(String name); BigDecimal getBigDecimal(String name); byte[] getBytes(String name); EntityValue setBytes(String name, byte[] theBytes); SerialBlob getSerialBlob(String name); /** Sets fields on this entity from the Map of fields passed in using the entity definition to only get valid * fields from the Map. For any String values passed in this will call setString to convert based on the field * definition, otherwise it sets the Object as-is. * * @param fields The fields Map to get the values from * @param setIfEmpty Used to specify whether empty/null values in the field Map should be set * @param namePrefix If not null or empty will be pre-pended to each field name (upper-casing the first letter of * the field name first), and that will be used as the fields Map lookup name instead of the field-name * @param pks If null, get all values, if TRUE just get PKs, if FALSE just get non-PKs * @return reference to this for convenience */ EntityValue setFields(Map fields, boolean setIfEmpty, String namePrefix, Boolean pks); /** Get the next guaranteed unique seq id for this entity, and set it in the primary key field. This will set it in * the first primary key field in the entity definition, but it really should be used for entities with only one * primary key field. * * @return reference to this for convenience */ EntityValue setSequencedIdPrimary(); /** Look at existing values with the same primary sequenced ID (first PK field) and get the highest existing * value for the secondary sequenced ID (the second PK field), add 1 to it and set the result in this entity value. * * The current value object must have the primary sequenced field already populated. * * @return reference to this for convenience */ EntityValue setSequencedIdSecondary(); /** Compares this EntityValue to the passed object * @param that Object to compare this to * @return int representing the result of the comparison (-1,0, or 1) */ @Override int compareTo(EntityValue that); /** Returns true if all entries in the Map match field values. */ boolean mapMatches(Map theMap); EntityValue cloneValue(); /** Creates a record for this entity value. * @return reference to this for convenience */ EntityValue create() throws EntityException; /** Creates a record for this entity value, or updates the record if one exists that matches the primary key. * @return reference to this for convenience */ EntityValue createOrUpdate() throws EntityException; /** Alias for createOrUpdate() */ EntityValue store() throws EntityException; /** Updates the record that matches the primary key. * @return reference to this for convenience */ EntityValue update() throws EntityException; /** Deletes the record that matches the primary key. * @return reference to this for convenience */ EntityValue delete() throws EntityException; /** Refreshes this value based on the record that matches the primary key. * @return true if a record was found, otherwise false also meaning no refresh was done */ boolean refresh() throws EntityException; Object getOriginalDbValue(String name); /** Get the named Related Entity for the EntityValue from the persistent store * @param relationshipName String containing the relationship name which is the combination of relationship.title * and relationship.related-entity-name as specified in the entity XML definition file * @param byAndFields the fields that must equal in order to keep; may be null * @param orderBy The fields of the named entity to order the query by; may be null; * optionally add a " ASC" for ascending or " DESC" for descending * @param useCache Look in the cache before finding in the datasource. Defaults to setting on entity definition. * @return List of EntityValue instances as specified in the relation definition */ EntityList findRelated(String relationshipName, Map byAndFields, List orderBy, Boolean useCache, Boolean forUpdate) throws EntityException; /** Get the named Related Entity for the EntityValue from the persistent store * @param relationshipName String containing the relationship name which is the combination of relationship.title * and relationship.related-entity-name as specified in the entity XML definition file * @param useCache Look in the cache before finding in the datasource. Defaults to setting on entity definition. * @return List of EntityValue instances as specified in the relation definition */ EntityValue findRelatedOne(String relationshipName, Boolean useCache, Boolean forUpdate) throws EntityException; long findRelatedCount(final String relationshipName, Boolean useCache); /** Find all records with a foreign key reference to this record. Operates on relationship definitions for any related entity * that has a type one relationship to this entity. * * Does not recurse, finds directly related (dependant) records only. * * Will skip any related records whose entity name is in skipEntities. * * Useful as a validation before calling deleteWithCascade(). */ EntityList findRelatedFk(Set skipEntities); /** Remove the named Related Entity for the EntityValue from the persistent store * @param relationshipName String containing the relationship name which is the combination of relationship.title * and relationship.related-entity-name as specified in the entity XML definition file */ void deleteRelated(String relationshipName) throws EntityException; /** Delete this record plus records for all relationships specified. If any records exist for other relationships not specified * that depend on this record returns false and does not delete anything. * * Returns true if this and related records were deleted. */ boolean deleteWithRelated(Set relationshipsToDelete); /** Deletes this record and all records that depend on it, doing the same for each (cascading delete). * Deletes related records that depend on this record (records with a foreign key reference to this record). * * To clear the reference (set fields to null) instead of deleting records specify the entity names, related to this or any * related entity, in the clearRefEntities parameter. * * To check for records that should prevent a delete you can optionally pass a Set of entities names in the * validateAllowDeleteEntities parameter. If this is not null an exception will be thrown instead of deleting * any record for an entity NOT in that Set. * * WARNING: this may delete records you don't want to. Look at the nested relationships in the Entity Reference in the * Tools app to see what might might get deleted (anything with a type one relationship to this entity, or recursing * anything with a type one relationship to those). */ void deleteWithCascade(Set clearRefEntities, Set validateAllowDeleteEntities); /** * Checks to see if all foreign key records exist in the database (records this record refers to). * Will attempt to create a dummy value (PK only) for those missing when specified insertDummy is true. * * @param insertDummy Create a dummy record using the provided fields * @return true if all FKs exist (or when all missing are created) */ boolean checkFks(boolean insertDummy) throws EntityException; /** Compare this value to the database, adding messages about fields that differ or if the record doesn't exist to messages. */ long checkAgainstDatabase(List messages); long checkAgainstDatabaseInfo(List> diffInfoList, List messages, String location); /** Makes an XML Element object with an attribute for each field of the entity * @param document The XML Document that the new Element will be part of * @param prefix A prefix to put in front of the entity name in the tag name * @return org.w3c.dom.Element object representing this entity value */ Element makeXmlElement(Document document, String prefix); /** Writes XML text with an attribute or CDATA element for each field of the entity. If dependents is true also * writes all dependent (descendant) records. * @param writer A Writer object to write to * @param prefix A prefix to put in front of the entity name in the tag name * @param dependentLevels Write dependent (descendant) records this many levels deep, zero for no dependents * @return The number of records written */ int writeXmlText(Writer writer, String prefix, int dependentLevels); int writeXmlTextMaster(Writer pw, String prefix, String masterName); /** Get a Map with all non-null field values. If dependentLevels is greater than zero includes nested dependents * in the Map as an entry with key of the dependent relationship's short-alias or if no short-alias then the * relationship name (title + related-entity-name). Each dependent entity's Map may have its own dependent records * up to dependentLevels levels deep.*/ Map getPlainValueMap(int dependentLevels); /** List getPlainValueMap() but uses a master definition to determine which dependent/related records to get. */ Map getMasterValueMap(String name); } ================================================ FILE: framework/src/main/java/org/moqui/entity/EntityValueNotFoundException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.entity; public class EntityValueNotFoundException extends EntityException { public EntityValueNotFoundException(String str) { super(str); } // public EntityValueNotFoundException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/etl/FlatXmlExtractor.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.etl; import org.moqui.BaseException; import org.moqui.resource.ResourceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.Locator; import org.xml.sax.XMLReader; import org.xml.sax.helpers.DefaultHandler; import javax.xml.parsers.SAXParserFactory; import java.io.IOException; import java.io.InputStream; import java.util.HashMap; public class FlatXmlExtractor implements SimpleEtl.Extractor { protected final static Logger logger = LoggerFactory.getLogger(FlatXmlExtractor.class); SimpleEtl etl = null; private ResourceReference resourceRef; FlatXmlExtractor(ResourceReference xmlRef) { resourceRef = xmlRef; } @Override public void extract(SimpleEtl etl) throws Exception { this.etl = etl; if (resourceRef == null || !resourceRef.getExists()) { logger.warn("Resource does not exist, not extracting data from " + (resourceRef != null ? resourceRef.getLocation() : "[null ResourceReference]")); return; } InputStream is = resourceRef.openStream(); if (is == null) return; try { FlatXmlHandler xmlHandler = new FlatXmlHandler(this); XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader(); reader.setContentHandler(xmlHandler); reader.parse(new InputSource(is)); } catch (Exception e) { throw new BaseException("Error parsing XML from " + resourceRef.getLocation(), e); } finally { try { is.close(); } catch (IOException e) { logger.error("Error closing XML stream from " + resourceRef.getLocation(), e); } } } private static class FlatXmlHandler extends DefaultHandler { Locator locator = null; FlatXmlExtractor extractor; String rootName = null; SimpleEtl.SimpleEntry curEntry = null; String curTextName = null; StringBuilder curText = null; private boolean stopParse = false; FlatXmlHandler(FlatXmlExtractor fxe) { extractor = fxe; } @Override public void startElement(String ns, String localName, String qName, Attributes attributes) { if (stopParse) return; if (rootName == null) { rootName = qName; return; } if (curEntry == null) { curEntry = new SimpleEtl.SimpleEntry(qName, new HashMap<>()); int length = attributes.getLength(); for (int i = 0; i < length; i++) { String name = attributes.getLocalName(i); String value = attributes.getValue(i); if (name == null || name.length() == 0) name = attributes.getQName(i); curEntry.values.put(name, value); } } else { curTextName = qName; curText = new StringBuilder(); } } @Override public void characters(char[] chars, int offset, int length) { if (stopParse) return; if (curText == null) curText = new StringBuilder(); curText.append(chars, offset, length); } @Override public void endElement(String ns, String localName, String qName) { if (stopParse) return; if (curEntry == null) { // should be the root element in a flat record file if (rootName != null && rootName.equals(qName)) rootName = null; return; } if (curTextName != null) { curEntry.values.put(curTextName, curText.toString()); curTextName = null; curText = null; return; } if (!qName.equals(curEntry.type)) throw new IllegalStateException("Invalid close element " + qName + ", was expecting " + curEntry.type); try { extractor.etl.processEntry(curEntry); } catch (SimpleEtl.StopException e) { logger.warn("Got StopException", e); stopParse = true; } curEntry = null; curTextName = null; curText = null; } @Override public void setDocumentLocator(Locator locator) { this.locator = locator; } } } ================================================ FILE: framework/src/main/java/org/moqui/etl/SimpleEtl.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.etl; import javax.annotation.Nonnull; import java.util.*; @SuppressWarnings("unused") public class SimpleEtl { private Extractor extractor; private TransformConfiguration internalConfig = null; private Loader loader; private List messages = new LinkedList<>(); private Exception extractException = null; private List transformErrors = new LinkedList<>(); private List loadErrors = new LinkedList<>(); private boolean stopOnError = false; private Integer timeout = 3600; // default to one hour private int extractCount = 0, skipCount = 0, loadCount = 0; private long startTime = 0, endTime = 0; public SimpleEtl(@Nonnull Extractor extractor, @Nonnull Loader loader) { this.extractor = extractor; this.loader = loader; } /** Call this to add a transformer to run for any type, will be run in order added */ public SimpleEtl addTransformer(@Nonnull Transformer transformer) { if (internalConfig == null) internalConfig = new TransformConfiguration(); internalConfig.addTransformer(transformer); return this; } /** Call this to add a transformer to run a particular type, which will be run in order for entries of the type */ public SimpleEtl addTransformer(@Nonnull String type, @Nonnull Transformer transformer) { if (internalConfig == null) internalConfig = new TransformConfiguration(); internalConfig.addTransformer(type, transformer); return this; } /** Add from an external TransformConfiguration, copies configuration to avoid modification */ public SimpleEtl addConfiguration(TransformConfiguration config) { if (internalConfig == null) internalConfig = new TransformConfiguration(); internalConfig.copyFrom(config); return this; } /** Use an external configuration as-is. Overrides any previous addTransformer() and addConfiguration() calls. * Any calls to addTransformer() and addConfiguration() will modify this configuration. */ public SimpleEtl setConfiguration(TransformConfiguration config) { internalConfig = config; return this; } /** Call this to set stop on error flag */ public SimpleEtl stopOnError() { this.stopOnError = true; return this; } /** Set timeout in seconds; passed to Loader.init() for transactions, etc */ public SimpleEtl setTimeout(Integer timeout) { this.timeout = timeout; return this; } /** Call this to process the ETL */ public SimpleEtl process() { startTime = System.currentTimeMillis(); // initialize loader loader.init(timeout); try { // kick off extraction to process extracted entries extractor.extract(this); } catch (Exception e) { extractException = e; } finally { // close the loader loader.complete(this); endTime = System.currentTimeMillis(); } return this; } public Extractor getExtractor() { return extractor; } public Loader getLoader() { return loader; } public SimpleEtl addMessage(String msg) { this.messages.add(msg); return this; } public List getMessages() { return Collections.unmodifiableList(messages); } public int getExtractCount() { return extractCount; } public int getSkipCount() { return skipCount; } public int getLoadCount() { return loadCount; } public long getRunTime() { return endTime - startTime; } public Exception getExtractException() { return extractException; } public List getTransformErrors() { return Collections.unmodifiableList(transformErrors); } public List getLoadErrors() { return Collections.unmodifiableList(loadErrors); } public boolean hasError() { return extractException != null || transformErrors.size() > 0 || loadErrors.size() > 0; } public Throwable getSingleErrorCause() { if (extractException != null) return extractException; if (transformErrors.size() > 0) return transformErrors.get(0).error; if (loadErrors.size() > 0) return loadErrors.get(0).error; return null; } /** * Called by the Extractor to process an extracted entry. * @return true if the entry loaded, false otherwise * @throws StopException if thrown extraction should stop and return */ public boolean processEntry(Entry extractEntry) throws StopException { if (extractEntry == null) return false; extractCount++; ArrayList loadEntries = new ArrayList<>(); if (internalConfig != null && internalConfig.hasTransformers) { EntryTransform entryTransform = new EntryTransform(extractEntry); internalConfig.runTransformers(this, entryTransform, loadEntries); if (entryTransform.loadCurrent != null ? entryTransform.loadCurrent : entryTransform.newEntries == null || entryTransform.newEntries.size() == 0) { loadEntries.add(0, entryTransform.entry); } else if (entryTransform.newEntries == null || entryTransform.newEntries.size() == 0) { skipCount++; return false; } } else { loadEntries.add(extractEntry); } int loadEntriesSize = loadEntries.size(); for (int i = 0; i < loadEntriesSize; i++) { Entry loadEntry = loadEntries.get(i); try { loader.load(loadEntry); loadCount++; } catch (Throwable t) { loadErrors.add(new EtlError(loadEntry, t)); if (stopOnError) throw new StopException(t); return false; } } return true; } public static class TransformConfiguration { private ArrayList anyTransformers = new ArrayList<>(); private int anyTransformerSize = 0; private LinkedHashMap> typeTransformers = new LinkedHashMap<>(); boolean hasTransformers = false; public TransformConfiguration() { } /** Call this to add a transformer to run for any type, which will be run in order */ public TransformConfiguration addTransformer(@Nonnull Transformer transformer) { anyTransformers.add(transformer); anyTransformerSize = anyTransformers.size(); hasTransformers = true; return this; } /** Call this to add a transformer to run a particular type, which will be run in order for entries of the type */ public TransformConfiguration addTransformer(@Nonnull String type, @Nonnull Transformer transformer) { typeTransformers.computeIfAbsent(type, k -> new ArrayList<>()).add(transformer); hasTransformers = true; return this; } // returns true to skip the entry (or remove from load list) void runTransformers(SimpleEtl etl, EntryTransform entryTransform, ArrayList loadEntries) throws StopException { for (int i = 0; i < anyTransformerSize; i++) { transformEntry(etl, anyTransformers.get(i), entryTransform); } String curType = entryTransform.entry.getEtlType(); if (curType != null && !curType.isEmpty()) { ArrayList curTypeTrans = typeTransformers.get(curType); int curTypeTransSize = curTypeTrans != null ? curTypeTrans.size() : 0; for (int i = 0; i < curTypeTransSize; i++) { transformEntry(etl, curTypeTrans.get(i), entryTransform); } } // handle new entries, run transforms then add to load list if not skipped int newEntriesSize = entryTransform.newEntries != null ? entryTransform.newEntries.size() : 0; for (int i = 0; i < newEntriesSize; i++) { Entry newEntry = entryTransform.newEntries.get(i); if (newEntry == null) continue; EntryTransform newTransform = new EntryTransform(newEntry); runTransformers(etl, newTransform, loadEntries); if (newTransform.loadCurrent != null ? newTransform.loadCurrent : newTransform.newEntries == null || newTransform.newEntries.size() == 0) { loadEntries.add(newEntry); } } } // internal method, returns true to skip entry (or remove from load list) void transformEntry(SimpleEtl etl, Transformer transformer, EntryTransform entryTransform) throws StopException { try { transformer.transform(entryTransform); } catch (Throwable t) { etl.transformErrors.add(new EtlError(entryTransform.entry, t)); if (etl.stopOnError) throw new StopException(t); entryTransform.loadCurrent(false); } } void copyFrom(TransformConfiguration conf) { if (conf == null) return; anyTransformers.addAll(conf.anyTransformers); anyTransformerSize = anyTransformers.size(); for (Map.Entry> entry : conf.typeTransformers.entrySet()) { typeTransformers.computeIfAbsent(entry.getKey(), k -> new ArrayList<>()).addAll(entry.getValue()); } } } public static class StopException extends Exception { public StopException(Throwable t) { super(t); } } public static class EtlError { public final Entry entry; public final Throwable error; EtlError(Entry entry, Throwable t) { this.entry = entry; this.error = t; } } public interface Entry { String getEtlType(); Map getEtlValues(); } public static class SimpleEntry implements Entry { public final String type; public final Map values; public SimpleEntry(String type, Map values) { this.type = type; this.values = values; } @Override public String getEtlType() { return type; } @Override public Map getEtlValues() { return values; } // TODO: add equals and hash overrides } public static class EntryTransform { final Entry entry; ArrayList newEntries = null; Boolean loadCurrent = null; EntryTransform(Entry entry) { this.entry = entry; } /** Get the current entry to get type and get/put values as needed */ public Entry getEntry() { return entry; } /** By default the current entry is loaded only if no new entries are added; set to false to not load even if no entries are * added (filter); set to true to load even if no new entries are added */ public EntryTransform loadCurrent(boolean load) { loadCurrent = load; return this; } /** Add a new entry to be transformed and if not filtered then loaded */ public EntryTransform addEntry(Entry newEntry) { if (newEntries == null) newEntries = new ArrayList<>(); newEntries.add(newEntry); return this; } } public interface Extractor { /** Called once to start processing, should call etl.processEntry() for each entry and close itself once finished */ void extract(SimpleEtl etl) throws Exception; } /** Stateless ETL entry transformer and filter interface */ public interface Transformer { /** Call methods on EntryTransform to add new entries (generally with different types), modify the current entry's values, or filter the entry. */ void transform(EntryTransform entryTransform) throws Exception; } public interface Loader { /** Called before SimpleEtl processing begins */ void init(Integer timeout); /** Load a single, optionally transformed, entry into the data destination */ void load(Entry entry) throws Exception; /** Called after all entries processed to close files, commit/rollback transactions, etc; */ void complete(SimpleEtl etl); } } ================================================ FILE: framework/src/main/java/org/moqui/jcache/MCache.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.jcache; import javax.cache.Cache; import javax.cache.CacheException; import javax.cache.CacheManager; import javax.cache.configuration.CacheEntryListenerConfiguration; import javax.cache.configuration.CompleteConfiguration; import javax.cache.configuration.Configuration; import javax.cache.expiry.Duration; import javax.cache.expiry.ExpiryPolicy; import javax.cache.integration.CompletionListener; import javax.cache.management.CacheStatisticsMXBean; import javax.cache.processor.EntryProcessor; import javax.cache.processor.EntryProcessorException; import javax.cache.processor.EntryProcessorResult; import java.util.*; import java.util.concurrent.*; import java.util.concurrent.atomic.AtomicInteger; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** A simple implementation of the javax.cache.Cache interface. Basically a wrapper around a Map with stats and expiry. */ @SuppressWarnings("unused") public class MCache implements Cache { private static final Logger logger = LoggerFactory.getLogger(MCache.class); private String name; private CacheManager manager; private Configuration configuration; // NOTE: use ConcurrentHashMap for write locks and such even if can't so easily use putIfAbsent/etc private ConcurrentHashMap> entryStore = new ConcurrentHashMap<>(); // currently for future reference, no runtime type checking // private Class keyClass = null; // private Class valueClass = null; private MStats stats = new MStats(); private boolean statsEnabled = true; private Duration accessDuration = null; private Duration creationDuration = null; private Duration updateDuration = null; private final boolean hasExpiry; private boolean isClosed = false; private EvictRunnable evictRunnable = null; private ScheduledFuture evictFuture = null; private static class WorkerThreadFactory implements ThreadFactory { private final ThreadGroup workerGroup = new ThreadGroup("MCacheEvict"); private final AtomicInteger threadNumber = new AtomicInteger(1); @Override public Thread newThread(Runnable r) { return new Thread(workerGroup, r, "MCacheEvict-" + threadNumber.getAndIncrement()); } } private static ScheduledThreadPoolExecutor workerPool = new ScheduledThreadPoolExecutor(1, new WorkerThreadFactory()); static { workerPool.setRemoveOnCancelPolicy(true); } /** Supports a few configurations but both manager and configuration can be null. */ public MCache(String name, CacheManager manager, Configuration configuration) { this.name = name; this.manager = manager; this.configuration = configuration; if (configuration != null) { if (configuration instanceof CompleteConfiguration) { CompleteConfiguration compConf = (CompleteConfiguration) configuration; statsEnabled = compConf.isStatisticsEnabled(); if (compConf.getExpiryPolicyFactory() != null) { ExpiryPolicy ep = compConf.getExpiryPolicyFactory().create(); accessDuration = ep.getExpiryForAccess(); if (accessDuration != null && accessDuration.isEternal()) accessDuration = null; creationDuration = ep.getExpiryForCreation(); if (creationDuration != null && creationDuration.isEternal()) creationDuration = null; updateDuration = ep.getExpiryForUpdate(); if (updateDuration != null && updateDuration.isEternal()) updateDuration = null; } } // keyClass = configuration.getKeyType(); // valueClass = configuration.getValueType(); // TODO: support any other configuration? if (configuration instanceof MCacheConfiguration) { MCacheConfiguration mCacheConf = (MCacheConfiguration) configuration; if (mCacheConf.maxEntries > 0) { evictRunnable = new EvictRunnable(this, mCacheConf.maxEntries); evictFuture = workerPool.scheduleWithFixedDelay(evictRunnable, 30, mCacheConf.maxCheckSeconds, TimeUnit.SECONDS); } } } hasExpiry = accessDuration != null || creationDuration != null || updateDuration != null; } public synchronized void setMaxEntries(int elements) { if (elements == 0) { if (evictRunnable != null) { evictRunnable = null; evictFuture.cancel(false); evictFuture = null; } } else { if (evictRunnable != null) { evictRunnable.maxEntries = elements; } else { evictRunnable = new EvictRunnable(this, elements); long maxCheckSeconds = 30; if (configuration instanceof MCacheConfiguration) maxCheckSeconds = ((MCacheConfiguration) configuration).maxCheckSeconds; evictFuture = workerPool.scheduleWithFixedDelay(evictRunnable, 1, maxCheckSeconds, TimeUnit.SECONDS); } } } public int getMaxEntries() { return evictRunnable != null ? evictRunnable.maxEntries : 0; } @Override public String getName() { return name; } @Override public V get(K key) { MEntry entry = getEntryInternal(key, null, null, 0); if (entry == null) return null; return entry.value; } public V get(K key, ExpiryPolicy policy) { MEntry entry = getEntryInternal(key, policy, null, 0); if (entry == null) return null; return entry.value; } /** Get with expire if the entry's last updated time is before the expireBeforeTime. * Useful when last updated time of a resource is known to see if the cached entry is out of date. */ public V get(K key, long expireBeforeTime) { MEntry entry = getEntryInternal(key, null, expireBeforeTime, 0); if (entry == null) return null; return entry.value; } /** Get an entry, if it is in the cache and not expired, otherwise returns null. The policy can be null to use cache's policy. */ public MEntry getEntry(final K key, final ExpiryPolicy policy) { return getEntryInternal(key, policy, null, 0); } /** Simple entry get, doesn't check if expired. */ public MEntry getEntryNoCheck(K key) { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); if (key == null) throw new IllegalArgumentException("Cache key cannot be null"); MEntry entry = entryStore.get(key); if (entry != null) { if (statsEnabled) { stats.gets++; stats.hits++; } long accessTime = System.currentTimeMillis(); entry.accessCount++; if (accessTime > entry.lastAccessTime) entry.lastAccessTime = accessTime; } else { if (statsEnabled) { stats.gets++; stats.misses++; } } return entry; } private MEntry getEntryInternal(final K key, final ExpiryPolicy policy, final Long expireBeforeTime, long currentTime) { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); if (key == null) throw new IllegalArgumentException("Cache key cannot be null"); MEntry entry = entryStore.get(key); if (entry != null) { if (policy != null) { if (currentTime == 0) currentTime = System.currentTimeMillis(); if (entry.isExpired(currentTime, policy)) { entryStore.remove(key); entry = null; if (statsEnabled) stats.countExpire(); } } else if (hasExpiry) { if (currentTime == 0) currentTime = System.currentTimeMillis(); if (entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) { entryStore.remove(key); entry = null; if (statsEnabled) stats.countExpire(); } } if (expireBeforeTime != null && entry != null && entry.lastUpdatedTime < expireBeforeTime) { entryStore.remove(key); entry = null; if (statsEnabled) stats.countExpire(); } if (entry != null) { if (statsEnabled) { stats.gets++; stats.hits++; } entry.accessCount++; // at this point if an ad-hoc policy is used or hasExpiry == true currentTime will be set, otherwise will be 0 // meaning we don't need to track the lastAccessTime (only thing we need System.currentTimeMillis() for) // if (currentTime == 0) currentTime = System.currentTimeMillis(); if (currentTime > entry.lastAccessTime) entry.lastAccessTime = currentTime; } else { if (statsEnabled) { stats.gets++; stats.misses++; } } } else { if (statsEnabled) { stats.gets++; stats.misses++; } } return entry; } private MEntry getCheckExpired(K key) { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); if (key == null) throw new IllegalArgumentException("Cache key cannot be null"); MEntry entry = entryStore.get(key); if (hasExpiry && entry != null && entry.isExpired(accessDuration, creationDuration, updateDuration)) { entryStore.remove(key); entry = null; if (statsEnabled) stats.countExpire(); } return entry; } private MEntry getCheckExpired(K key, long currentTime) { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); if (key == null) throw new IllegalArgumentException("Cache key cannot be null"); MEntry entry = entryStore.get(key); if (hasExpiry && entry != null && entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) { entryStore.remove(key); entry = null; if (statsEnabled) stats.countExpire(); } return entry; } @Override public Map getAll(Set keys) { long currentTime = System.currentTimeMillis(); Map results = new HashMap<>(); for (K key: keys) { MEntry entry = getEntryInternal(key, null, null, currentTime); results.put(key, entry != null ? entry.value : null); } return results; } @Override public boolean containsKey(K key) { MEntry entry = getCheckExpired(key); return entry != null; } @Override public void put(K key, V value) { long currentTime = System.currentTimeMillis(); // get entry, count hit/miss MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { entry.setValue(value, currentTime); if (statsEnabled) stats.puts++; } else { entry = new MEntry<>(key, value, currentTime); entryStore.put(key, entry); if (statsEnabled) stats.puts++; } } @Override public V getAndPut(K key, V value) { long currentTime = System.currentTimeMillis(); // get entry, count hit/miss MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { V oldValue = entry.value; entry.setValue(value, currentTime); if (statsEnabled) stats.puts++; return oldValue; } else { entry = new MEntry<>(key, value, currentTime); entryStore.put(key, entry); if (statsEnabled) stats.puts++; return null; } } @Override public void putAll(Map map) { if (map == null) return; for (Map.Entry me: map.entrySet()) getAndPut(me.getKey(), me.getValue()); } @Override public boolean putIfAbsent(K key, V value) { long currentTime = System.currentTimeMillis(); MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { return false; } else { entry = new MEntry<>(key, value, currentTime); MEntry existingValue = entryStore.putIfAbsent(key, entry); if (existingValue == null) { if (statsEnabled) stats.puts++; return true; } else { return false; } } } @Override public boolean remove(K key) { MEntry entry = getCheckExpired(key); if (entry != null) { entryStore.remove(key); if (statsEnabled) stats.countRemoval(); return true; } else { return false; } } @Override public boolean remove(K key, V oldValue) { MEntry entry = getCheckExpired(key); if (entry != null) { boolean remove = entry.valueEquals(oldValue); if (remove) { // remove with dummy MEntry instance for comparison to ensure still equals remove = entryStore.remove(key, new MEntry<>(key, oldValue)); if (remove && statsEnabled) stats.countRemoval(); } return remove; } else { return false; } } @Override public V getAndRemove(K key) { // get entry, count hit/miss MEntry entry = getEntryInternal(key, null, null, 0); if (entry != null) { V oldValue = entry.value; entryStore.remove(key); if (statsEnabled) stats.countRemoval(); return oldValue; } return null; } @Override public boolean replace(K key, V oldValue, V newValue) { long currentTime = System.currentTimeMillis(); MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { boolean replaced = entry.setValueIfEquals(oldValue, newValue, currentTime); if (replaced) if (statsEnabled) stats.puts++; return replaced; } else { return false; } } @Override public boolean replace(K key, V value) { long currentTime = System.currentTimeMillis(); MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { entry.setValue(value, currentTime); if (statsEnabled) stats.puts++; return true; } else { return false; } } @Override public V getAndReplace(K key, V value) { long currentTime = System.currentTimeMillis(); // get entry, count hit/miss MEntry entry = getEntryInternal(key, null, null, currentTime); if (entry != null) { V oldValue = entry.value; entry.setValue(value, currentTime); if (statsEnabled) stats.puts++; return oldValue; } else { return null; } } @Override public void removeAll(Set keys) { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); for (K key: keys) remove(key); } @Override public void removeAll() { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); int size = entryStore.size(); entryStore.clear(); if (statsEnabled) stats.countBulkRemoval(size); } @Override public void clear() { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); // don't track removals or do anything else, removeAll does that entryStore.clear(); } @Override public > C getConfiguration(Class clazz) { if (configuration == null) return null; if (clazz.isAssignableFrom(configuration.getClass())) return clazz.cast(configuration); throw new IllegalArgumentException("Class " + clazz.getName() + " not compatible with configuration class " + configuration.getClass().getName()); } @Override public void loadAll(Set keys, boolean replaceExistingValues, CompletionListener completionListener) { throw new CacheException("loadAll not supported in MCache"); } @Override public T invoke(K key, EntryProcessor entryProcessor, Object... arguments) throws EntryProcessorException { throw new CacheException("invoke not supported in MCache"); } @Override public Map> invokeAll(Set keys, EntryProcessor entryProcessor, Object... arguments) { throw new CacheException("invokeAll not supported in MCache"); } @Override public void registerCacheEntryListener(CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { throw new CacheException("registerCacheEntryListener not supported in MCache"); } @Override public void deregisterCacheEntryListener(CacheEntryListenerConfiguration cacheEntryListenerConfiguration) { throw new CacheException("deregisterCacheEntryListener not supported in MCache"); } @Override public CacheManager getCacheManager() { return manager; } @Override public void close() { if (isClosed) throw new IllegalStateException("Cache " + name + " is already closed"); isClosed = true; entryStore.clear(); } @Override public boolean isClosed() { return isClosed; } @Override public T unwrap(Class clazz) { if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this); throw new IllegalArgumentException("Class " + clazz.getName() + " not compatible with MCache"); } @Override public Iterator> iterator() { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); return new CacheIterator<>(this); } private static class CacheIterator implements Iterator> { final MCache mCache; final long initialTime; final ArrayList> entryList; final int maxIndex; int curIndex = -1; MEntry curEntry = null; CacheIterator(MCache mCache) { this.mCache = mCache; entryList = new ArrayList<>(mCache.entryStore.values()); maxIndex = entryList.size() - 1; initialTime = System.currentTimeMillis(); } @Override public boolean hasNext() { return curIndex < maxIndex; } @Override public Entry next() { curEntry = null; while (curIndex < maxIndex) { curIndex++; curEntry = entryList.get(curIndex); if (curEntry.isExpired) { curEntry = null; } else if (mCache.hasExpiry && curEntry.isExpired(initialTime, mCache.accessDuration, mCache.creationDuration, mCache.updateDuration)) { mCache.entryStore.remove(curEntry.getKey()); if (mCache.statsEnabled) mCache.stats.countExpire(); curEntry = null; } else { if (mCache.statsEnabled) { mCache.stats.gets++; mCache.stats.hits++; } break; } } return curEntry; } @Override public void remove() { if (curEntry != null) { mCache.entryStore.remove(curEntry.getKey()); if (mCache.statsEnabled) mCache.stats.countRemoval(); curEntry = null; } } } /** Gets all entries, checking for expiry and counts a get for each */ public ArrayList> getEntryList() { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); long currentTime = System.currentTimeMillis(); ArrayList keyList = new ArrayList<>(entryStore.keySet()); int keyListSize = keyList.size(); ArrayList> entryList = new ArrayList<>(keyListSize); for (int i = 0; i < keyListSize; i++) { K key = keyList.get(i); MEntry entry = getCheckExpired(key, currentTime); if (entry != null) { entryList.add(entry); if (statsEnabled) { stats.gets++; stats.hits++; } entry.accessCount++; if (currentTime > entry.lastAccessTime) entry.lastAccessTime = currentTime; } } return entryList; } public int clearExpired() { if (isClosed) throw new IllegalStateException("Cache " + name + " is closed"); if (!hasExpiry) return 0; long currentTime = System.currentTimeMillis(); ArrayList keyList = new ArrayList<>(entryStore.keySet()); int keyListSize = keyList.size(); int expireCount = 0; for (int i = 0; i < keyListSize; i++) { K key = keyList.get(i); MEntry entry = entryStore.get(key); if (entry != null && entry.isExpired(currentTime, accessDuration, creationDuration, updateDuration)) { entryStore.remove(key); if (statsEnabled) stats.countExpire(); expireCount++; } } return expireCount; } public CacheStatisticsMXBean getStats() { return stats; } public MStats getMStats() { return stats; } public int size() { return entryStore.size(); } public Duration getAccessDuration() { return accessDuration; } public Duration getCreationDuration() { return creationDuration; } public Duration getUpdateDuration() { return updateDuration; } private static class EvictRunnable implements Runnable { static AccessComparator comparator = new AccessComparator(); MCache cache; int maxEntries; EvictRunnable(MCache mc, int entries) { cache = mc; maxEntries = entries; } @Override @SuppressWarnings("unchecked") public void run() { if (maxEntries == 0) return; int entriesToEvict = cache.entryStore.size() - maxEntries; if (entriesToEvict <= 0) return; long startTime = System.currentTimeMillis(); Collection entrySet = (Collection) cache.entryStore.values(); PriorityQueue priorityQueue = new PriorityQueue<>(entrySet.size(), comparator); priorityQueue.addAll(entrySet); int entriesEvicted = 0; while (entriesToEvict > 0 && priorityQueue.size() > 0) { MEntry curEntry = priorityQueue.poll(); // if an entry was expired after pulling the initial value set if (curEntry.isExpired) continue; cache.entryStore.remove(curEntry.getKey()); cache.stats.evictions++; entriesEvicted++; entriesToEvict--; } long timeElapsed = System.currentTimeMillis() - startTime; logger.info("Evicted " + entriesEvicted + " entries in " + timeElapsed + "ms from cache " + cache.name); } } private static class AccessComparator implements Comparator { @Override public int compare(MEntry e1, MEntry e2) { if (e1.accessCount == e2.accessCount) { if (e1.lastAccessTime == e2.lastAccessTime) return 0; else return e1.lastAccessTime > e2.lastAccessTime ? 1 : -1; } else { return e1.accessCount > e2.accessCount ? 1 : -1; } } } } ================================================ FILE: framework/src/main/java/org/moqui/jcache/MCacheConfiguration.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.jcache; import javax.cache.configuration.CompleteConfiguration; import javax.cache.configuration.MutableConfiguration; @SuppressWarnings("unused") public class MCacheConfiguration extends MutableConfiguration { public MCacheConfiguration() { super(); } public MCacheConfiguration(CompleteConfiguration conf) { super(conf); } int maxEntries = 0; long maxCheckSeconds = 30; /** Set maximum number of entries in the cache, 0 means no limit (default). Limit is enforced in a scheduled worker, not on put operations. */ public MCacheConfiguration setMaxEntries(int elements) { maxEntries = elements; return this; } public int getMaxEntries() { return maxEntries; } /** Set maximum number of entries in the cache, 0 means no limit (default). */ public MCacheConfiguration setMaxCheckSeconds(long seconds) { maxCheckSeconds = seconds; return this; } public long getMaxCheckSeconds() { return maxCheckSeconds; } } ================================================ FILE: framework/src/main/java/org/moqui/jcache/MCacheManager.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.jcache; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.cache.Cache; import javax.cache.CacheManager; import javax.cache.configuration.Configuration; import javax.cache.spi.CachingProvider; import java.net.URI; import java.net.URISyntaxException; import java.util.LinkedHashMap; import java.util.Map; import java.util.Properties; /** This class does not completely support the javax.cache.CacheManager spec, it is just enough to use as a factory for MCache instances. */ public class MCacheManager implements CacheManager { private static final Logger logger = LoggerFactory.getLogger(MCacheManager.class); private static final MCacheManager singleCacheManager = new MCacheManager(); public static MCacheManager getMCacheManager() { return singleCacheManager; } private URI cmUri = null; private ClassLoader localClassLoader; private Properties props = new Properties(); private Map cacheMap = new LinkedHashMap<>(); private boolean isClosed = false; private MCacheManager() { try { cmUri = new URI("MCacheManager"); } catch (URISyntaxException e) { logger.error("URI Syntax error initializing MCacheManager", e); } localClassLoader = Thread.currentThread().getContextClassLoader(); } @Override public CachingProvider getCachingProvider() { return null; } @Override public URI getURI() { return cmUri; } @Override public ClassLoader getClassLoader() { return localClassLoader; } @Override public Properties getProperties() { return props; } @Override @SuppressWarnings("unchecked") public synchronized > Cache createCache(String cacheName, C configuration) throws IllegalArgumentException { if (isClosed) throw new IllegalStateException("MCacheManager is closed"); if (cacheMap.containsKey(cacheName)) { // not per spec, but be more friendly and just return the existing cache: throw new CacheException("Cache with name " + cacheName + " already exists"); return cacheMap.get(cacheName); } MCache newCache = new MCache(cacheName, this, configuration); cacheMap.put(cacheName, newCache); return newCache; } @Override @SuppressWarnings("unchecked") public Cache getCache(String cacheName, Class keyType, Class valueType) { if (isClosed) throw new IllegalStateException("MCacheManager is closed"); return cacheMap.get(cacheName); } @Override @SuppressWarnings("unchecked") public Cache getCache(String cacheName) { if (isClosed) throw new IllegalStateException("MCacheManager is closed"); return cacheMap.get(cacheName); } @Override public Iterable getCacheNames() { if (isClosed) throw new IllegalStateException("MCacheManager is closed"); return cacheMap.keySet(); } @Override public void destroyCache(String cacheName) { if (isClosed) throw new IllegalStateException("MCacheManager is closed"); MCache cache = cacheMap.get(cacheName); if (cache != null) { cacheMap.remove(cacheName); cache.close(); } else { throw new IllegalStateException("Cache with name " + cacheName + " does not exist, cannot be destroyed"); } } @Override public void enableManagement(String cacheName, boolean enabled) { throw new UnsupportedOperationException("MCacheManager does not support CacheMXBean"); } @Override public void enableStatistics(String cacheName, boolean enabled) { throw new UnsupportedOperationException("MCacheManager does not support registered statistics; use the MCache.getStats() or getMStats() methods"); } @Override public void close() { cacheMap.clear(); // doesn't work well with current singleton approach: isClosed = true; } @Override public boolean isClosed() { return isClosed; } @Override public T unwrap(Class clazz) { if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this); throw new IllegalArgumentException("Class " + clazz.getName() + " not compatible with MCacheManager"); } } ================================================ FILE: framework/src/main/java/org/moqui/jcache/MEntry.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.jcache; import javax.cache.Cache; import javax.cache.expiry.Duration; import javax.cache.expiry.ExpiryPolicy; public class MEntry implements Cache.Entry { private static final Class thisClass = MEntry.class; private final K key; V value; private long createdTime = 0; long lastUpdatedTime = 0; long lastAccessTime = 0; long accessCount = 0; boolean isExpired = false; /** * Use this only to create MEntry to compare with an existing entry */ MEntry(K key, V value) { this.key = key; this.value = value; } /** * Always use this for MEntry that may be put in the cache */ MEntry(K key, V value, long createdTime) { this.key = key; this.value = value; this.createdTime = createdTime; lastUpdatedTime = createdTime; lastAccessTime = createdTime; } @Override public K getKey() { return key; } @Override public V getValue() { return value; } @Override public T unwrap(Class clazz) { if (clazz.isAssignableFrom(this.getClass())) return clazz.cast(this); throw new IllegalArgumentException("Class " + clazz.getName() + " not compatible with MCache.MEntry"); } boolean valueEquals(V otherValue) { if (otherValue == null) { return value == null; } else { return otherValue.equals(value); } } void setValue(V val, long updateTime) { synchronized (key) { if (updateTime > lastUpdatedTime) { value = val; lastUpdatedTime = updateTime; } } } boolean setValueIfEquals(V oldVal, V val, long updateTime) { synchronized (key) { if (updateTime > lastUpdatedTime && valueEquals(oldVal)) { value = val; lastUpdatedTime = updateTime; return true; } else { return false; } } } public long getCreatedTime() { return createdTime; } public long getLastUpdatedTime() { return lastUpdatedTime; } public long getLastAccessTime() { return lastAccessTime; } public long getAccessCount() { return accessCount; } /* done directly on fields for performance reasons void countAccess(long accessTime) { accessCount++; if (accessTime > lastAccessTime) lastAccessTime = accessTime; } */ @SuppressWarnings("unused") public boolean isExpired(ExpiryPolicy policy) { return isExpired(System.currentTimeMillis(), policy.getExpiryForAccess(), policy.getExpiryForCreation(), policy.getExpiryForUpdate()); } boolean isExpired(long accessTime, ExpiryPolicy policy) { return isExpired(accessTime, policy.getExpiryForAccess(), policy.getExpiryForCreation(), policy.getExpiryForUpdate()); } boolean isExpired(Duration accessDuration, Duration creationDuration, Duration updateDuration) { return isExpired(System.currentTimeMillis(), accessDuration, creationDuration, updateDuration); } boolean isExpired(long accessTime, Duration accessDuration, Duration creationDuration, Duration updateDuration) { if (isExpired) return true; if (accessDuration != null && !accessDuration.isEternal()) { if (accessDuration.getAdjustedTime(lastAccessTime) < accessTime) { isExpired = true; return true; } } if (creationDuration != null && !creationDuration.isEternal()) { if (creationDuration.getAdjustedTime(createdTime) < accessTime) { isExpired = true; return true; } } if (updateDuration != null && !updateDuration.isEternal()) { if (updateDuration.getAdjustedTime(lastUpdatedTime) < accessTime) { isExpired = true; return true; } } return false; } @Override public int hashCode() { return value.hashCode(); } @Override public boolean equals(Object obj) { if (obj == null || thisClass != obj.getClass()) return false; MEntry that = (MEntry) obj; if (value == null) { return that.value == null; } else { return value.equals(that.value); } } } ================================================ FILE: framework/src/main/java/org/moqui/jcache/MStats.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.jcache; import javax.cache.management.CacheStatisticsMXBean; public class MStats implements CacheStatisticsMXBean { long hits = 0; long misses = 0; long gets = 0; long puts = 0; private long removals = 0; long evictions = 0; private long expires = 0; // long totalGetMicros = 0, totalPutMicros = 0, totalRemoveMicros = 0; @Override public void clear() { hits = 0; misses = 0; gets = 0; puts = 0; removals = 0; evictions = 0; expires = 0; } @Override public long getCacheHits() { return hits; } @Override public float getCacheHitPercentage() { return (hits / gets) * 100; } @Override public long getCacheMisses() { return misses; } @Override public float getCacheMissPercentage() { return (misses / gets) * 100; } @Override public long getCacheGets() { return gets; } @Override public long getCachePuts() { return puts; } @Override public long getCacheRemovals() { return removals; } @Override public long getCacheEvictions() { return evictions; } @Override public float getAverageGetTime() { return 0; } // totalGetMicros / gets @Override public float getAveragePutTime() { return 0; } // totalPutMicros / puts @Override public float getAverageRemoveTime() { return 0; } // totalRemoveMicros / removals public long getCacheExpires() { return expires; } /* have callers access fields directly for performance reasons: void countHit() { gets++; hits++; // totalGetMicros += micros; } void countMiss() { gets++; misses++; // totalGetMicros += micros; } void countPut() { puts++; // totalPutMicros += micros; } */ void countRemoval() { removals++; // totalRemoveMicros += micros; } void countBulkRemoval(long entries) { removals += entries; } void countExpire() { expires++; } } ================================================ FILE: framework/src/main/java/org/moqui/resource/ClasspathResourceReference.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.resource; import org.moqui.BaseException; import java.io.FileNotFoundException; import java.io.IOException; import java.io.InputStream; public class ClasspathResourceReference extends UrlResourceReference { private String strippedLocation; public ClasspathResourceReference() { super(); } @Override public ResourceReference init(String location) { strippedLocation = ResourceReference.stripLocationPrefix(location); // first try the current thread's context ClassLoader locationUrl = Thread.currentThread().getContextClassLoader().getResource(strippedLocation); // next try the ClassLoader that loaded this class if (locationUrl == null) locationUrl = this.getClass().getClassLoader().getResource(strippedLocation); // no luck? try the system ClassLoader if (locationUrl == null) locationUrl = ClassLoader.getSystemResource(strippedLocation); // if the URL was found this way then it exists, so remember that if (locationUrl != null) { exists = true; isFileProtocol = "file".equals(locationUrl.getProtocol()); } return this; } @Override public ResourceReference createNew(String location) { ClasspathResourceReference resRef = new ClasspathResourceReference(); resRef.init(location); return resRef; } @Override public InputStream openStream() { if (locationUrl == null) throw new IllegalStateException("Classpath Resource not found at " + strippedLocation); try { return locationUrl.openStream(); } catch (FileNotFoundException e) { return null; } catch (IOException e) { throw new BaseException("Error opening stream for " + locationUrl.toString(), e); } } @Override public boolean getExists() { // only count exists if true return exists != null && exists; } @Override public String getLocation() { return "classpath://" + strippedLocation; } } ================================================ FILE: framework/src/main/java/org/moqui/resource/ResourceReference.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.resource; import org.moqui.BaseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import jakarta.activation.MimetypesFileTypeMap; import java.io.ByteArrayInputStream; import java.io.InputStream; import java.io.OutputStream; import java.io.Serializable; import java.net.URI; import java.net.URISyntaxException; import java.net.URL; import java.sql.Timestamp; import java.util.*; public abstract class ResourceReference implements Serializable { private static final Logger logger = LoggerFactory.getLogger(ResourceReference.class); private static final MimetypesFileTypeMap mimetypesFileTypeMap = new MimetypesFileTypeMap(); protected ResourceReference childOfResource = null; private Map subContentRefByPath = null; public abstract ResourceReference init(String location); public abstract ResourceReference createNew(String location); public abstract String getLocation(); public abstract InputStream openStream(); public abstract OutputStream getOutputStream(); public abstract String getText(); public abstract boolean supportsAll(); public abstract boolean supportsUrl(); public abstract URL getUrl(); public abstract boolean supportsDirectory(); public abstract boolean isFile(); public abstract boolean isDirectory(); public abstract boolean supportsExists(); public abstract boolean getExists(); public abstract boolean supportsLastModified(); public abstract long getLastModified(); public abstract boolean supportsSize(); public abstract long getSize(); public abstract boolean supportsWrite(); public abstract void putText(String text); public abstract void putStream(InputStream stream); public abstract void move(String newLocation); public abstract ResourceReference makeDirectory(String name); public abstract ResourceReference makeFile(String name); public abstract boolean delete(); /** Get the entries of a directory */ public abstract List getDirectoryEntries(); public void putBytes(byte[] bytes) { putStream(new ByteArrayInputStream(bytes)); } public URI getUri() { try { if (supportsUrl()) { URL locUrl = getUrl(); if (locUrl == null) return null; // use the multi-argument constructor to have it do character encoding and avoid an exception // WARNING: a String from this URI may not equal the String from the URL (ie if characters are encoded) // NOTE: this doesn't seem to work on Windows for local files: when protocol is plain "file" and path starts // with a drive letter like "C:\moqui\..." it produces a parse error showing the URI as "file://C:/..." if (logger.isTraceEnabled()) logger.trace("Getting URI for URL " + locUrl.toExternalForm()); String path = locUrl.getPath(); // Support Windows local files. if ("file".equals(locUrl.getProtocol())) { if (!path.startsWith("/")) path = "/" + path; } return new URI(locUrl.getProtocol(), locUrl.getUserInfo(), locUrl.getHost(), locUrl.getPort(), path, locUrl.getQuery(), locUrl.getRef()); } else { String loc = getLocation(); if (loc == null || loc.isEmpty()) return null; return new URI(loc); } } catch (URISyntaxException e) { throw new BaseException("Error creating URI", e); } } /** One part of the URI not easy to get from the URI object, basically the last part of the path. */ public String getFileName() { String loc = getLocation(); if (loc == null || loc.length() == 0) return null; int slashIndex = loc.lastIndexOf("/"); return slashIndex >= 0 ? loc.substring(slashIndex + 1) : loc; } /** The content (MIME) type for this content, if known or can be determined. */ public String getContentType() { String fn = getFileName(); return fn != null && fn.length() > 0 ? getContentType(fn) : null; } public boolean isBinary() { return isBinaryContentType(getContentType()); } public boolean isText() { return isTextContentType(getContentType()); } /** Get the parent directory, null if it is the root (no parent). */ public ResourceReference getParent() { String curLocation = getLocation(); if (curLocation.endsWith("/")) curLocation = curLocation.substring(0, curLocation.length() - 1); String strippedLocation = stripLocationPrefix(curLocation); if (strippedLocation.isEmpty()) return null; if (strippedLocation.startsWith("/")) strippedLocation = strippedLocation.substring(1); if (strippedLocation.contains("/")) { return createNew(curLocation.substring(0, curLocation.lastIndexOf("/"))); } else { String prefix = getLocationPrefix(curLocation); if (prefix != null && !prefix.isEmpty()) return createNew(prefix); return null; } } /** Find the directory with a name that matches the current filename (minus the extension) */ public ResourceReference findMatchingDirectory() { if (this.isDirectory()) return this; StringBuilder dirLoc = new StringBuilder(getLocation()); ResourceReference directoryRef = this; while (!(directoryRef.getExists() && directoryRef.isDirectory()) && dirLoc.lastIndexOf(".") > 0) { // get rid of one suffix at a time (for screens probably .xml but use .* for other files, etc) dirLoc.delete(dirLoc.lastIndexOf("."), dirLoc.length()); directoryRef = createNew(dirLoc.toString()); // directoryRef = ecf.resource.getLocationReference(dirLoc.toString()) } return directoryRef; } /** Get a reference to the child of this directory or this file in the matching directory */ public ResourceReference getChild(String childName) { if (childName == null || childName.length() == 0) return null; ResourceReference directoryRef = findMatchingDirectory(); StringBuilder fileLoc = new StringBuilder(directoryRef.getLocation()); if (fileLoc.charAt(fileLoc.length()-1) == '/') fileLoc.deleteCharAt(fileLoc.length()-1); if (childName.charAt(0) != '/') fileLoc.append('/'); fileLoc.append(childName); // NOTE: don't really care if it exists or not at this point return createNew(fileLoc.toString()); } /** Get a list of references to all files in this directory or for a file in the matching directory */ public List getChildren() { List children = new LinkedList<>(); ResourceReference directoryRef = findMatchingDirectory(); if (directoryRef == null || !directoryRef.getExists()) return null; for (ResourceReference childRef : directoryRef.getDirectoryEntries()) if (childRef.isFile()) children.add(childRef); return children; } /** Find a file by path (can be single name) in the matching directory and child matching directories */ public ResourceReference findChildFile(String relativePath) { // no path to child? that means this resource if (relativePath == null || relativePath.length() == 0) return this; if (!supportsAll()) { throw new BaseException("Not looking for child file at " + relativePath + " under space root page " + getLocation() + " because exists, isFile, etc are not supported"); } // logger.warn("============= finding child resource of [${toString()}] path [${relativePath}]") // check the cache first ResourceReference childRef = getSubContentRefByPath().get(relativePath); if (childRef != null && childRef.getExists()) return childRef; // this finds a file in a directory with the same name as this resource, unless this resource is a directory ResourceReference directoryRef = findMatchingDirectory(); // logger.warn("============= finding child resource path [${relativePath}] directoryRef [${directoryRef}]") if (directoryRef.getExists()) { StringBuilder fileLoc = new StringBuilder(directoryRef.getLocation()); if (fileLoc.charAt(fileLoc.length() - 1) == '/') fileLoc.deleteCharAt(fileLoc.length() - 1); if (relativePath.charAt(0) != '/') fileLoc.append('/'); fileLoc.append(relativePath); ResourceReference theFile = createNew(fileLoc.toString()); if (theFile.getExists() && theFile.isFile()) childRef = theFile; // logger.warn("============= finding child resource path [${relativePath}] childRef [${childRef}]") if (childRef == null) { // didn't find it at a literal path, try searching for it in all subdirectories int lastSlashIdx = relativePath.lastIndexOf("/"); String directoryPath = lastSlashIdx > 0 ? relativePath.substring(0, lastSlashIdx) : ""; String childFilename = lastSlashIdx >= 0 ? relativePath.substring(lastSlashIdx + 1) : relativePath; // first find the most matching directory ResourceReference childDirectoryRef = directoryRef.findChildDirectory(directoryPath); // recursively walk the directory tree and find the childFilename childRef = internalFindChildFile(childDirectoryRef, childFilename, null); // logger.warn("============= finding child resource path [${relativePath}] directoryRef [${directoryRef}] childFilename [${childFilename}] childRef [${childRef}]") } // logger.warn("============= finding child resource path [${relativePath}] childRef 3 [${childRef}]") if (childRef != null) childRef.childOfResource = directoryRef; } if (childRef == null) { // still nothing? treat the path to the file as a literal and return it (exists will be false) if (directoryRef.getExists()) { childRef = createNew(directoryRef.getLocation() + "/" + relativePath); childRef.childOfResource = directoryRef; } else { String newDirectoryLoc = getLocation(); // pop off the extension, everything past the first dot after the last slash int lastSlashLoc = newDirectoryLoc.lastIndexOf("/"); if (newDirectoryLoc.contains(".")) newDirectoryLoc = newDirectoryLoc.substring(0, newDirectoryLoc.indexOf(".", lastSlashLoc)); childRef = createNew(newDirectoryLoc + "/" + relativePath); } } else { // put it in the cache before returning, but don't cache the literal reference getSubContentRefByPath().put(relativePath, childRef); } // logger.warn("============= finding child resource of [${toString()}] path [${relativePath}] got [${childRef}]") return childRef; } /** Find a directory by path (can be single name) in the matching directory and child matching directories */ public ResourceReference findChildDirectory(String relativePath) { if (relativePath == null || relativePath.isEmpty()) return this; if (!supportsAll()) { throw new BaseException("Not looking for child file at " + relativePath + " under space root page " + getLocation() + " because exists, isFile, etc are not supported"); } // check the cache first ResourceReference childRef = getSubContentRefByPath().get(relativePath); if (childRef != null && childRef.getExists()) return childRef; List relativePathNameList = Arrays.asList(relativePath.split("/")); ResourceReference childDirectoryRef = this; if (this.isFile()) childDirectoryRef = this.findMatchingDirectory(); // search remaining relativePathNameList, ie partial directories leading up to filename for (String relativePathName : relativePathNameList) { childDirectoryRef = internalFindChildDir(childDirectoryRef, relativePathName, null); if (childDirectoryRef == null) break; } if (childDirectoryRef == null) { // still nothing? treat the path to the file as a literal and return it (exists will be false) String newDirectoryLoc = getLocation(); if (this.isFile()) { // pop off the extension, everything past the first dot after the last slash int lastSlashLoc = newDirectoryLoc.lastIndexOf("/"); if (newDirectoryLoc.contains(".")) newDirectoryLoc = newDirectoryLoc.substring(0, newDirectoryLoc.indexOf(".", lastSlashLoc)); } childDirectoryRef = createNew(newDirectoryLoc + "/" + relativePath); } else { // put it in the cache before returning, but don't cache the literal reference getSubContentRefByPath().put(relativePath, childRef); } return childDirectoryRef; } private ResourceReference internalFindChildDir(ResourceReference directoryRef, String childDirName, Set parentLocSet) { if (directoryRef == null || !directoryRef.getExists()) return null; // no child dir name, means this/current dir if (childDirName == null || childDirName.isEmpty()) return directoryRef; // try a direct sub-directory, if it is there it's more efficient than a recursive search StringBuilder dirLocation = new StringBuilder(directoryRef.getLocation()); if (dirLocation.charAt(dirLocation.length() - 1) == '/') dirLocation.deleteCharAt(dirLocation.length() - 1); if (childDirName.charAt(0) != '/') dirLocation.append('/'); dirLocation.append(childDirName); ResourceReference directRef = createNew(dirLocation.toString()); if (directRef != null && directRef.getExists()) return directRef; // if no direct reference is found, try the more flexible search for (ResourceReference childRef : directoryRef.getDirectoryEntries()) { if (childRef.isDirectory() && (childRef.getFileName().equals(childDirName) || childRef.getFileName().contains(childDirName + "."))) { // matching directory name, use it return childRef; } else if (childRef.isDirectory()) { Set recurseParentLocSet = new HashSet<>(); if (parentLocSet != null) { if (parentLocSet.contains(childRef.getLocation())) { logger.error("In internalFindChildDir found loop in directory tree, " + childRef.getLocation() + " already visited, parent locations: " + parentLocSet); continue; } recurseParentLocSet.addAll(parentLocSet); } else { recurseParentLocSet.add(directoryRef.getLocation()); } recurseParentLocSet.add(childRef.getLocation()); // non-matching directory name, recurse into it ResourceReference subRef = internalFindChildDir(childRef, childDirName, recurseParentLocSet); if (subRef != null) return subRef; } } return null; } private ResourceReference internalFindChildFile(ResourceReference directoryRef, String childFilename, Set parentLocSet) { // logger.warn("internalFindChildFile " + directoryRef + " [" + childFilename + "] " + parentLocSet); if (directoryRef == null || !directoryRef.getExists()) return null; // find check exact filename first ResourceReference exactMatchRef = directoryRef.getChild(childFilename); if (exactMatchRef.isFile() && exactMatchRef.getExists()) return exactMatchRef; List childEntries = directoryRef.getDirectoryEntries(); // look through all files first, ie do a breadth-first search for (ResourceReference childRef : childEntries) { if (childRef.isFile() && (childRef.getFileName().equals(childFilename) || childRef.getFileName().startsWith(childFilename + "."))) { return childRef; } } for (ResourceReference childRef : childEntries) { if (childRef.isDirectory()) { Set recurseParentLocSet = new HashSet<>(); if (parentLocSet != null) { if (parentLocSet.contains(childRef.getLocation())) { logger.error("In internalFindChildFile found loop in directory tree, " + childRef.getLocation() + " already visited, parent locations: " + parentLocSet); continue; } recurseParentLocSet.addAll(parentLocSet); } else { recurseParentLocSet.add(directoryRef.getLocation()); } recurseParentLocSet.add(childRef.getLocation()); ResourceReference subRef = internalFindChildFile(childRef, childFilename, recurseParentLocSet); if (subRef != null) return subRef; } } return null; } public String getActualChildPath() { if (childOfResource == null) return null; String parentLocation = childOfResource.getLocation(); String childLocation = getLocation(); // this should be true, but just in case: if (childLocation.startsWith(parentLocation)) { String childPath = childLocation.substring(parentLocation.length()); if (childPath.startsWith("/")) return childPath.substring(1); else return childPath; } // if not, what to do? return null; } public void walkChildTree(List allChildFileFlatList, List childResourceList) { if (this.isFile()) walkChildFileTree(this, "", allChildFileFlatList, childResourceList); if (this.isDirectory()) for (ResourceReference childRef : getDirectoryEntries()) { childRef.walkChildFileTree(this, "", allChildFileFlatList, childResourceList); } } private void walkChildFileTree(ResourceReference rootResource, String pathFromRoot, List allChildFileFlatList, List childResourceList) { // logger.warn("================ walkChildFileTree rootResource=${rootResource} pathFromRoot=${pathFromRoot} curLocation=${getLocation()}") String childPathBase = pathFromRoot != null && !pathFromRoot.isEmpty() ? pathFromRoot + '/' : ""; if (this.isFile()) { List curChildResourceList = new LinkedList<>(); String curFileName = getFileName(); if (curFileName.contains(".")) curFileName = curFileName.substring(0, curFileName.lastIndexOf('.')); String curPath = childPathBase + curFileName; if (allChildFileFlatList != null) { Map infoMap = new HashMap<>(3); infoMap.put("path", curPath); infoMap.put("name", curFileName); infoMap.put("location", getLocation()); allChildFileFlatList.add(infoMap); } if (childResourceList != null) { Map infoMap = new HashMap<>(4); infoMap.put("path", curPath); infoMap.put("name", curFileName); infoMap.put("location", getLocation()); infoMap.put("childResourceList", curChildResourceList); childResourceList.add(infoMap); } ResourceReference matchingDirReference = this.findMatchingDirectory(); String childPath = childPathBase + matchingDirReference.getFileName(); for (ResourceReference childRef : matchingDirReference.getDirectoryEntries()) { childRef.walkChildFileTree(rootResource, childPath, allChildFileFlatList, curChildResourceList); } } // TODO: walk child directories somehow or just stick with files with matching directories? } public void destroy() { } @Override public String toString() { String loc = getLocation(); return loc != null && !loc.isEmpty() ? loc : ("[no location (" + getClass().getName() + ")]"); } private Map getSubContentRefByPath() { if (subContentRefByPath == null) subContentRefByPath = new HashMap<>(); return subContentRefByPath; } public static boolean isTextFilename(String filename) { String contentType = getContentType(filename); if (contentType == null || contentType.isEmpty()) return false; return isTextContentType(contentType); } public static boolean isBinaryFilename(String filename) { String contentType = getContentType(filename); if (contentType == null || contentType.isEmpty()) return false; return !isTextContentType(contentType); } public static String getContentType(String filename) { // need to check this, or type mapper handles it fine? || !filename.contains(".") if (filename == null || filename.length() == 0) return null; String type = mimetypesFileTypeMap.getContentType(filename); // strip any parameters, ie after the ; int semicolonIndex = type.indexOf(";"); if (semicolonIndex >= 0) type = type.substring(0, semicolonIndex); return type; } public static boolean isTextContentType(String contentType) { if (contentType == null) return false; contentType = contentType.trim(); int scIdx = contentType.indexOf(";"); contentType = scIdx >= 0 ? contentType.substring(0, scIdx).trim() : contentType; if (contentType.length() == 0) return false; if (contentType.startsWith("text/")) return true; // aside from text/*, a few notable exceptions: if ("application/javascript".equals(contentType)) return true; if ("application/json".equals(contentType)) return true; if ("application/jwt".equals(contentType)) return true; if (contentType.endsWith("+json")) return true; if ("application/rtf".equals(contentType)) return true; if (contentType.startsWith("application/xml")) return true; if (contentType.endsWith("+xml")) return true; if (contentType.startsWith("application/yaml")) return true; if (contentType.endsWith("+yaml")) return true; return false; } public static boolean isBinaryContentType(String contentType) { if (contentType == null || contentType.length() == 0) return false; return !isTextContentType(contentType); } public static String stripLocationPrefix(String location) { if (location == null || location.isEmpty()) return ""; // first remove colon (:) and everything before it StringBuilder strippedLocation = new StringBuilder(location); int colonIndex = strippedLocation.indexOf(":"); if (colonIndex == 0) { strippedLocation.deleteCharAt(0); } else if (colonIndex > 0) { strippedLocation.delete(0, colonIndex+1); } // delete all leading forward slashes while (strippedLocation.length() > 0 && strippedLocation.charAt(0) == '/') strippedLocation.deleteCharAt(0); return strippedLocation.toString(); } public static String getLocationPrefix(String location) { if (location == null || location.isEmpty()) return ""; if (location.contains("://")) { return location.substring(0, location.indexOf(":")) + "://"; } else if (location.contains(":")) { return location.substring(0, location.indexOf(":")) + ":"; } else { return ""; } } public boolean supportsVersion() { return false; } public Version getVersion(String versionName) { return null; } public Version getCurrentVersion() { return null; } public Version getRootVersion() { return null; } public ArrayList getVersionHistory() { return new ArrayList<>(); } public ArrayList getNextVersions(String versionName) { return new ArrayList<>(); } public InputStream openStream(String versionName) { return openStream(); } public String getText(String versionName) { return getText(); } public static class Version { private final ResourceReference ref; private final String versionName, previousVersionName, userId; private final Timestamp versionDate; public Version(ResourceReference ref, String versionName, String previousVersionName, String userId, Timestamp versionDate) { this.ref = ref; this.versionName = versionName; this.previousVersionName = previousVersionName; this.userId = userId; this.versionDate = versionDate; } public ResourceReference getRef() { return ref; } public String getVersionName() { return versionName; } public String getPreviousVersionName() { return previousVersionName; } public Version getPreviousVersion() { return ref.getVersion(previousVersionName); } public ArrayList getNextVersions() { return ref.getNextVersions(versionName); } public String getUserId() { return userId; } public Timestamp getVersionDate() { return versionDate; } public InputStream openStream() { return ref.openStream(versionName); } public String getText() { return ref.getText(versionName); } public Map getMap() { Map map = new LinkedHashMap<>(); map.put("versionName", versionName); map.put("previousVersionName", previousVersionName); map.put("userId", userId); map.put("versionDate", versionDate); return map; } } } ================================================ FILE: framework/src/main/java/org/moqui/resource/UrlResourceReference.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.resource; import org.moqui.BaseException; import org.moqui.util.ObjectUtilities; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.io.*; import java.net.MalformedURLException; import java.net.URL; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; import java.util.TreeSet; public class UrlResourceReference extends ResourceReference { private static final Logger logger = LoggerFactory.getLogger(UrlResourceReference.class); static final String runtimePrefix = "runtime://"; URL locationUrl = null; Boolean exists = null; boolean isFileProtocol = false; private transient File localFile = null; public UrlResourceReference() { } public UrlResourceReference(File file) { isFileProtocol = true; localFile = file; try { locationUrl = file.toURI().toURL(); } catch (MalformedURLException e) { throw new BaseException("Error creating URL for file " + file.getAbsolutePath(), e); } } @Override public ResourceReference init(String location) { if (location == null || location.isEmpty()) throw new BaseException("Cannot create URL Resource Reference with empty location"); if (location.startsWith(runtimePrefix)) location = location.substring(runtimePrefix.length()); if (location.startsWith("/") || !location.contains(":")) { // no prefix, local file: if starts with '/' is absolute, otherwise is relative to runtime path if (location.charAt(0) != '/') { String moquiRuntime = System.getProperty("moqui.runtime"); if (moquiRuntime != null && !moquiRuntime.isEmpty()) { File runtimeFile = new File(moquiRuntime); location = runtimeFile.getAbsolutePath() + "/" + location; } } try { locationUrl = new URL("file:" + location); } catch (MalformedURLException e) { throw new BaseException("Invalid file url for location " + location, e); } isFileProtocol = true; } else { try { locationUrl = new URL(location); } catch (MalformedURLException e) { if (logger.isTraceEnabled()) logger.trace("Ignoring MalformedURLException for location, trying a local file: " + e.toString()); // special case for Windows, try going through a file: try { locationUrl = new URL("file:/" + location); } catch (MalformedURLException se) { throw new BaseException("Invalid url for location " + location, e); } } isFileProtocol = "file".equals(getUrl().getProtocol()); } return this; } public File getFile() { if (!isFileProtocol) throw new IllegalArgumentException("File not supported for resource with protocol [" + locationUrl.getProtocol() + "]"); if (localFile != null) return localFile; // NOTE: using toExternalForm().substring(5) instead of toURI because URI does not allow spaces in a filename localFile = new File(locationUrl.toExternalForm().substring(5)); return localFile; } @Override public ResourceReference createNew(String location) { UrlResourceReference resRef = new UrlResourceReference(); resRef.init(location); return resRef; } @Override public String getLocation() { return locationUrl.toString(); } @Override public InputStream openStream() { try { return locationUrl.openStream(); } catch (FileNotFoundException e) { return null; } catch (IOException e) { throw new BaseException("Error opening stream for " + locationUrl.toString(), e); } } @Override public OutputStream getOutputStream() { if (!isFileProtocol) { final URL url = locationUrl; throw new IllegalArgumentException("Write not supported for resource [" + url.toString() + "] with protocol [" + url.getProtocol() + "]"); } // first make sure the directory exists that this is in File curFile = getFile(); if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs(); try { return new FileOutputStream(curFile); } catch (FileNotFoundException e) { throw new BaseException("Error opening output stream for file " + curFile.getAbsolutePath(), e); } } @Override public String getText() { return ObjectUtilities.getStreamText(openStream()); } @Override public boolean supportsAll() { return isFileProtocol; } @Override public boolean supportsUrl() { return true; } @Override public URL getUrl() { return locationUrl; } @Override public boolean supportsDirectory() { return isFileProtocol; } @Override public boolean isFile() { if (isFileProtocol) { return getFile().isFile(); } else { throw new IllegalArgumentException("Is file not supported for resource with protocol [" + locationUrl.getProtocol() + "]"); } } @Override public boolean isDirectory() { if (isFileProtocol) { return getFile().isDirectory(); } else { throw new IllegalArgumentException("Is directory not supported for resource with protocol [" + locationUrl.getProtocol() + "]"); } } @Override public List getDirectoryEntries() { if (isFileProtocol) { File f = getFile(); List children = new ArrayList<>(); String baseLocation = getLocation(); if (baseLocation.endsWith("/")) baseLocation = baseLocation.substring(0, baseLocation.length() - 1); File[] listFiles = f.listFiles(); TreeSet fileNameSet = new TreeSet<>(); if (listFiles != null) for (File dirFile : listFiles) fileNameSet.add(dirFile.getName()); for (String filename : fileNameSet) children.add(new UrlResourceReference().init(baseLocation + "/" + filename)); return children; } else { throw new IllegalArgumentException("Children not supported for resource with protocol [" + locationUrl.getProtocol() + "]"); } } @Override public boolean supportsExists() { return isFileProtocol || exists != null; } @Override public boolean getExists() { // only count exists if true if (exists != null && exists) return true; if (isFileProtocol) { exists = getFile().exists(); return exists; } else { final URL url = locationUrl; throw new IllegalArgumentException("Exists not supported for resource with protocol [" + (url == null ? null : url.getProtocol()) + "]"); } } @Override public boolean supportsLastModified() { return isFileProtocol; } @Override public long getLastModified() { if (isFileProtocol) { return getFile().lastModified(); } else { return System.currentTimeMillis(); } } @Override public boolean supportsSize() { return isFileProtocol; } @Override public long getSize() { return isFileProtocol ? getFile().length() : 0; } @Override public boolean supportsWrite() { return isFileProtocol; } @Override public void putText(String text) { if (!isFileProtocol) { final URL url = locationUrl; throw new IllegalArgumentException("Write not supported for resource [" + getLocation() + "] with protocol [" + (url == null ? null : getUrl().getProtocol()) + "]"); } // first make sure the directory exists that this is in File curFile = getFile(); if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs(); // now write the text to the file and close it try { Writer fw = new OutputStreamWriter(new FileOutputStream(curFile), StandardCharsets.UTF_8); fw.write(text); fw.close(); this.exists = null; } catch (IOException e) { throw new BaseException("Error writing text to file " + curFile.getAbsolutePath(), e); } } @Override public void putStream(InputStream stream) { if (!isFileProtocol) { throw new IllegalArgumentException("Write not supported for resource [" + locationUrl + "] with protocol [" + (locationUrl == null ? null : locationUrl.getProtocol()) + "]"); } // first make sure the directory exists that this is in File curFile = getFile(); if (!curFile.getParentFile().exists()) curFile.getParentFile().mkdirs(); try { OutputStream os = new FileOutputStream(curFile); ObjectUtilities.copyStream(stream, os); stream.close(); os.close(); this.exists = null; } catch (IOException e) { throw new BaseException("Error writing stream to file " + curFile.getAbsolutePath(), e); } } @Override public void move(final String newLocation) { if (newLocation == null || newLocation.isEmpty()) throw new IllegalArgumentException("No location specified, not moving resource at " + getLocation()); ResourceReference newRr = createNew(newLocation); if (!newRr.getUrl().getProtocol().equals("file")) throw new IllegalArgumentException("Location [" + newLocation + "] is not a file location, not moving resource at " + getLocation()); if (!isFileProtocol) throw new IllegalArgumentException("Move not supported for resource [" + locationUrl + "] with protocol [" + (locationUrl == null ? null : locationUrl.getProtocol()) + "]"); File curFile = getFile(); if (!curFile.exists()) throw new IllegalArgumentException("File at " + getLocation() + " [" + curFile.getAbsolutePath() + "] does not exist, cannot move"); String path = newRr.getUrl().toExternalForm().substring(5); File newFile = new File(path); File newFileParent = newFile.getParentFile(); if (newFileParent != null && !newFileParent.exists()) newFileParent.mkdirs(); if (!curFile.renameTo(newFile)) { throw new IllegalArgumentException("Could not move " + curFile + " to " + newFile); } } @Override public ResourceReference makeDirectory(final String name) { if (!isFileProtocol) { final URL url = locationUrl; throw new IllegalArgumentException("Write not supported for resource [" + getLocation() + "] with protocol [" + (url == null ? null : url.getProtocol()) + "]"); } UrlResourceReference newRef = (UrlResourceReference) new UrlResourceReference().init(getLocation() + "/" + name); newRef.getFile().mkdirs(); return newRef; } @Override public ResourceReference makeFile(final String name) { if (!isFileProtocol) { final URL url = locationUrl; throw new IllegalArgumentException("Write not supported for resource [" + getLocation() + "] with protocol [" + (url == null ? null : url.getProtocol()) + "]"); } UrlResourceReference newRef = (UrlResourceReference) new UrlResourceReference().init(getLocation() + "/" + name); // first make sure the directory exists that this is in if (!getFile().exists()) getFile().mkdirs(); try { newRef.getFile().createNewFile(); return newRef; } catch (IOException e) { throw new BaseException("Error writing text to file " + newRef.getLocation(), e); } } @Override public boolean delete() { if (!isFileProtocol) { final URL url = locationUrl; throw new IllegalArgumentException("Write not supported for resource [" + getLocation() + "] with protocol [" + (url == null ? null : url.getProtocol()) + "]"); } return getFile().delete(); } } ================================================ FILE: framework/src/main/java/org/moqui/screen/ScreenFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.screen; /** For rendering screens for general use (mostly for things other than web pages or web page snippets). */ public interface ScreenFacade { /** Make a ScreenRender object to render a screen. */ ScreenRender makeRender(); /** Make a ScreenTest object to test render one or more screens. */ ScreenTest makeTest(); } ================================================ FILE: framework/src/main/java/org/moqui/screen/ScreenRender.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.screen; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.OutputStream; import java.io.Writer; import java.util.List; public interface ScreenRender { /** Location of the root XML Screen file to render. * * @return Reference to this ScreenRender for convenience */ ScreenRender rootScreen(String screenLocation); /** Determine location of the root XML Screen file to render based on a host name. * * @param host The host name, usually from ServletRequest.getServerName() * @return Reference to this ScreenRender for convenience */ ScreenRender rootScreenFromHost(String host); /** A list of screen names used to determine which screens to use when rendering subscreens. * * @return Reference to this ScreenRender for convenience */ ScreenRender screenPath(List screenNameList); ScreenRender screenPath(String path); /** Alternative to lastStandalone parameter and accepts same values (true, false, positive numbers to render that many from * end of path (true = 1), negative to render not render that many from start of path */ ScreenRender lastStandalone(String ls); /** The mode to render for (type of output). Used to select sub-elements of the render-mode * element and the default macro template (if one is not specified for this render). * * If macroTemplateLocation is not specified is also used to determine the default macro template * based on configuration. * * @param outputType Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv. * @return Reference to this ScreenRender for convenience */ ScreenRender renderMode(String outputType); /** The MIME character encoding for the text produced. Defaults to UTF-8. Must be a valid charset in * the java.nio.charset.Charset class. * * @return Reference to this ScreenRender for convenience */ ScreenRender encoding(String characterEncoding); /** Location of an FTL file with macros used to generate output. If not specified macro file from the screen * configuration will be used depending on the outputType. * * @return Reference to this ScreenRender for convenience */ ScreenRender macroTemplate(String macroTemplateLocation); /** If specified will be used as the base URL for links. If not specified the base URL will come from configuration * on the webapp-list.webapp element and the servletContextPath. * * @return Reference to this ScreenRender for convenience */ ScreenRender baseLinkUrl(String baseLinkUrl); /** If baseLinkUrl is not specified then this is used along with the webapp-list.webapp configuration to create * a base URL. If this is not specified and the active ExecutionContext has a WebFacade active then it will get * it from that (meaning with a WebFacade this is not necessary to get a correct result). * * @param scp The servletContext.contextPath * @return Reference to this ScreenRender for convenience */ ScreenRender servletContextPath(String scp); /** The webapp name to use to look up webapp (webapp-list.webapp.@name) settings for URL building, request actions * running, etc. * * @param wan The webapp name * @return Reference to this ScreenRender for convenience */ ScreenRender webappName(String wan); /** By default history is not saved, set to true to save this screen render in the web session history */ ScreenRender saveHistory(boolean sh); /** Render a screen to a response using the current context. The screen will run in a sub-context so the original * context will not be changed. The request will be used to check web settings such as secure connection, etc. */ void render(HttpServletRequest request, HttpServletResponse response); /** Render a screen to a writer using the current context. The screen will run in a sub-context so the original * context will not be changed. */ void render(Writer writer); void render(OutputStream os); /** Render a screen and return the output as a String. Context semantics are the same as other render methods. */ String render(); } ================================================ FILE: framework/src/main/java/org/moqui/screen/ScreenTest.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.screen; import java.util.List; import java.util.Map; import java.util.Set; /** A test harness for screen rendering. Does internal rendering without HTTP request/response */ @SuppressWarnings("unused") public interface ScreenTest { /** Location of the root XML Screen file to render */ ScreenTest rootScreen(String screenLocation); /** A screen path prepended to the screenPath used for all subsequent render() calls */ ScreenTest baseScreenPath(String screenPath); /** @see ScreenRender#renderMode(String) */ ScreenTest renderMode(String outputType); /** @see ScreenRender#encoding(String) */ ScreenTest encoding(String characterEncoding); /** @see ScreenRender#macroTemplate(String) */ ScreenTest macroTemplate(String macroTemplateLocation); /** @see ScreenRender#baseLinkUrl(String) */ ScreenTest baseLinkUrl(String baseLinkUrl); /** @see ScreenRender#servletContextPath(String) */ ScreenTest servletContextPath(String scp); /** @see ScreenRender#webappName(String) */ ScreenTest webappName(String wan); /** Calls to WebFacade.sendJsonResponse will not be serialized, use along with ScreenTestRender.getJsonObject() */ ScreenTest skipJsonSerialize(boolean skip); /** Get screen name paths to all screens with no required parameters under the rootScreen and (if specified) baseScreenPath */ List getNoRequiredParameterPaths(Set screensToSkip); /** Test render a screen. * @param screenPath Path from rootScreen in the sub-screen hierarchy * @param parameters Map with name/value pairs to use as if they were URL or body parameters * @param requestMethod The HTTP request method to use when selecting a transition (defaults to get) * @return ScreenTestRender object with the render result */ ScreenTestRender render(String screenPath, Map parameters, String requestMethod); void renderAll(List screenPathList, Map parameters, String requestMethod); long getRenderCount(); long getErrorCount(); long getRenderTotalChars(); long getStartTime(); interface ScreenTestRender { ScreenRender getScreenRender(); String getOutput(); Object getJsonObject(); long getRenderTime(); Map getPostRenderContext(); List getErrorMessages(); boolean assertContains(String text); boolean assertNotContains(String text); boolean assertRegex(String regex); } } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCall.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; public interface ServiceCall { String getServiceName(); /** Map of name, value pairs that make up the context (in parameters) passed to the service. */ Map getCurrentParameters(); } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCallAsync.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; import java.util.concurrent.Callable; import java.util.concurrent.Future; @SuppressWarnings("unused") public interface ServiceCallAsync extends ServiceCall { /** Name of the service to run. The combined service name, like: "${path}.${verb}${noun}". To explicitly separate * the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}" (this is useful for calling the * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity). */ ServiceCallAsync name(String serviceName); ServiceCallAsync name(String verb, String noun); ServiceCallAsync name(String path, String verb, String noun); /** Map of name, value pairs that make up the context (in parameters) passed to the service. */ ServiceCallAsync parameters(Map context); /** Single name, value pairs to put in the context (in parameters) passed to the service. */ ServiceCallAsync parameter(String name, Object value); /** If true the service call will be run distributed and may run on a different member of the cluster. Parameter * entries MUST be java.io.Serializable (or java.io.Externalizable). * * If false it will be run local only (default). * * @return Reference to this for convenience. */ ServiceCallAsync distribute(boolean dist); /** * Call the service asynchronously, ignoring the result. * This effectively calls the service through a java.lang.Runnable implementation. */ void call() throws ServiceException; /** * Call the service asynchronously, and get a java.util.concurrent.Future object back so you can wait for the service to * complete and get the result. * * This is useful for running a number of service simultaneously and then getting * all of the results back which will reduce the total running time from the sum of the time to run each service * to just the time the longest service takes to run. * * This effectively calls the service through a java.util.concurrent.Callable implementation. */ Future> callFuture() throws ServiceException; /** Get a Runnable object to do this service call through an ExecutorService or other runner of your choice. */ Runnable getRunnable(); /** Get a Callable object to do this service call through an ExecutorService of your choice. */ Callable> getCallable(); } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCallJob.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; import java.util.concurrent.Future; /** * An interface for ad-hoc (explicit) run of configured service jobs (in the moqui.service.job.ServiceJob entity). * * This interface has minimal options as most should be configured using ServiceJob entity fields. */ @SuppressWarnings("unused") public interface ServiceCallJob extends ServiceCall, Future> { /** Map of name, value pairs that make up the context (in parameters) passed to the service. */ ServiceCallJob parameters(Map context); /** Single name, value pairs to put in the context (in parameters) passed to the service. */ ServiceCallJob parameter(String name, Object value); /** Set to true to run local even if a distributed executor service is configured (defaults to false) */ ServiceCallJob localOnly(boolean local); /** * Run a service job. * * The job will always run asynchronously. To get the results of the service call without looking at the * ServiceJobRun.results field keep a reference to this object and use the methods on the * java.util.concurrent.Future interface. * * If the ServiceJob.topic field has a value a notification will be sent to the current user and all users * configured using ServiceJobUser records. The NotificationMessage.message field will be the results of this * service call. * * @return The jobRunId for the corresponding moqui.service.job.ServiceJobRun record */ String run() throws ServiceException; } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCallSpecial.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; @SuppressWarnings("unused") public interface ServiceCallSpecial extends ServiceCall { /** Name of the service to run. The combined service name, like: "${path}.${verb}${noun}". To explicitly separate * the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}" (this is useful for calling the * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity). */ ServiceCallSpecial name(String serviceName); ServiceCallSpecial name(String verb, String noun); ServiceCallSpecial name(String path, String verb, String noun); /** Map of name, value pairs that make up the context (in parameters) passed to the service. */ ServiceCallSpecial parameters(Map context); /** Single name, value pairs to put in the context (in parameters) passed to the service. */ ServiceCallSpecial parameter(String name, Object value); /** Add a service to run on commit of the current transaction using the ServiceXaWrapper */ void registerOnCommit(); /** Add a service to run on rollback of the current transaction using the ServiceXaWrapper */ void registerOnRollback(); } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCallSync.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; @SuppressWarnings("unused") public interface ServiceCallSync extends ServiceCall { /** Name of the service to run. The combined service name, like: "${path}.${verb}${noun}". To explicitly separate * the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}" (this is useful for calling the * implicit entity CrUD services where verb is create, update, or delete and noun is the name of the entity). */ ServiceCallSync name(String serviceName); ServiceCallSync name(String verb, String noun); ServiceCallSync name(String path, String verb, String noun); /** Map of name, value pairs that make up the context (in parameters) passed to the service. */ ServiceCallSync parameters(Map context); /** Single name, value pairs to put in the context (in parameters) passed to the service. */ ServiceCallSync parameter(String name, Object value); /** By default a service uses the existing transaction or begins a new one if no tx is in place. Set this flag to * ignore the transaction, not checking for one or starting one if no transaction is in place. */ ServiceCallSync ignoreTransaction(boolean ignoreTransaction); /** If true suspend/resume the current transaction (if a transaction is active) and begin a new transaction for the * scope of this service call. * * @return Reference to this for convenience. */ ServiceCallSync requireNewTransaction(boolean requireNewTransaction); /** Override the transaction-timeout attribute in the service definition, only used if a transaction is begun in this service call. */ ServiceCallSync transactionTimeout(int timeout); /** Use the write-through TransactionCache. * * WARNING: test thoroughly with this. While various services will run much faster there can be issues with no * changes going to the database until commit (for view-entity queries depending on data, etc). * * Some known limitations: * - find list and iterate don't cache results (but do filter and add to results aside from limitations below) * - EntityListIterator.getPartialList(), .relative(), and .absolute() are not supported when tx cache is in place and values * have been created; getCompleteList(), iteration using next() calls, etc are supported * - find with DB limit will return wrong number of values if deleted values were in the results * - find count doesn't add for created values, subtract for deleted values, and for updates if old matched and new doesn't subtract and vice-versa * - view-entities won't work, they don't incorporate results from TX Cache * - for-update queries are remembered but for best results do for-update queries before non for-update queries on the same record * * @return Reference to this for convenience. */ ServiceCallSync useTransactionCache(boolean useTransactionCache); /** Normally service won't run if there was an error (ec.message.hasError()), set this to true to run anyway. */ ServiceCallSync ignorePreviousError(boolean ipe); /** If true add danger messages instead of hard error messages for validation */ ServiceCallSync softValidate(boolean sv); /** If true expect multiple sets of parameters passed in a single map, each set with a suffix of an underscore * and the row of the number, ie something like "userId_8" for the userId parameter in the 8th row. * @return Reference to this for convenience. */ ServiceCallSync multi(boolean mlt); /** Do not remember parameters in ArtifactExecutionFacade history and stack, * important for service calls with large parameters that should be de-referenced for GC before ExecutionContext is destroyed. */ ServiceCallSync noRememberParameters(); /** Disable authorization for the current thread during this service call. */ ServiceCallSync disableAuthz(); /* * If null defaults to configured value for service, or container. For possible values see JavaDoc for javax.sql.Connection. * @return Reference to this for convenience. */ /* not supported by Atomikos/etc right now, consider for later: ServiceCallSync transactionIsolation(int transactionIsolation); /** Call the service synchronously and immediately get the result. * @return Map containing the result (out parameters) from the service call. */ Map call() throws ServiceException; } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceCallback.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import java.util.Map; public interface ServiceCallback { boolean isEnabled(); void receiveEvent(Map context, Map result); void receiveEvent(Map context, Throwable t); } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceException.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; /** * ServiceFacade Exception */ public class ServiceException extends org.moqui.BaseException { public ServiceException(String str) { super(str); } public ServiceException(String str, Throwable nested) { super(str, nested); } } ================================================ FILE: framework/src/main/java/org/moqui/service/ServiceFacade.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.service; import org.moqui.util.RestClient; import java.util.Map; /** ServiceFacade Interface */ @SuppressWarnings("unused") public interface ServiceFacade { /** Get a service caller to call a service synchronously. */ ServiceCallSync sync(); /** Get a service caller to call a service asynchronously. */ ServiceCallAsync async(); /** * Get a service caller to call a service job. * * @param jobName The name of the job. There must be a moqui.service.job.ServiceJob record for this jobName. */ ServiceCallJob job(String jobName); /** Get a service caller for special service calls such as on commit and on rollback of current transaction. */ ServiceCallSpecial special(); /** Call a JSON remote service. For Moqui services the location will be something like "http://hostname/rpc/json". */ Map callJsonRpc(String location, String method, Map parameters); /** Get a RestClient instance to call remote REST services */ RestClient rest(); /** Register a callback listener on a specific service. * @param serviceName Name of the service to run. The combined service name, like: "${path}.${verb}${noun}". To * explicitly separate the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}". * @param serviceCallback The callback implementation. */ void registerCallback(String serviceName, ServiceCallback serviceCallback); } ================================================ FILE: framework/src/main/java/org/moqui/util/CollectionUtilities.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import groovy.util.Node; import groovy.util.NodeList; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.math.BigDecimal; import java.math.RoundingMode; import java.sql.Timestamp; import java.util.*; /** * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are * stupid but necessary for certain things. */ @SuppressWarnings("unused") public class CollectionUtilities { protected static final Logger logger = LoggerFactory.getLogger(CollectionUtilities.class); public static class KeyValue { public String key; public Object value; public KeyValue(String key, Object value) { this.key = key; this.value = value; } } public static HashMap toHashMap(Object... keyValues) { if (keyValues.length % 2 != 0) throw new IllegalArgumentException("Must have even number of arguments in name, value pairs"); HashMap newMap = new HashMap<>(); int pairs = keyValues.length / 2; for (int p = 0; p < pairs; p++) { int i = p * 2; newMap.put((String) keyValues[i], keyValues[i+1]); } return newMap; } public static ArrayList getMapArrayListValues(ArrayList> mapList, Object key, boolean excludeNullValues) { if (mapList == null) return null; int mapListSize = mapList.size(); ArrayList valList = new ArrayList<>(mapListSize); for (int i = 0; i < mapListSize; i++) { Map curMap = mapList.get(i); if (curMap == null) continue; Object curVal = curMap.get(key); if (excludeNullValues && curVal == null) continue; valList.add(curVal); } return valList; } public static void filterMapList(List theList, Map fieldValues) { filterMapList(theList, fieldValues, false); } /** Filter theList (of Map) using fieldValues; if exclude=true remove matching items, else keep only matching items */ public static void filterMapList(List theList, Map fieldValues, boolean exclude) { if (theList == null || fieldValues == null) return; int listSize = theList.size(); if (listSize == 0) return; int numFields = fieldValues.size(); if (numFields == 0) return; String[] fieldNameArray = new String[numFields]; Object[] fieldValueArray = new Object[numFields]; int index = 0; for (Map.Entry entry : fieldValues.entrySet()) { fieldNameArray[index] = entry.getKey(); fieldValueArray[index] = entry.getValue(); index++; } if (theList instanceof RandomAccess) { for (int li = 0; li < listSize; ) { Map curMap = theList.get(li); if (checkRemove(curMap, fieldNameArray, fieldValueArray, numFields, exclude)) { theList.remove(li); listSize--; } else { li++; } } } else { Iterator theIterator = theList.iterator(); while (theIterator.hasNext()) { Map curMap = theIterator.next(); if (checkRemove(curMap, fieldNameArray, fieldValueArray, numFields, exclude)) theIterator.remove(); } } } private static boolean checkRemove(Map curMap, String[] fieldNameArray, Object[] fieldValueArray, int numFields, boolean exclude) { boolean remove = exclude; for (int i = 0; i < numFields; i++) { String fieldName = fieldNameArray[i]; Object compareObj = fieldValueArray[i]; Object curObj = curMap.get(fieldName); if (compareObj == null) { if (curObj != null) { remove = !exclude; break; } } else { if (!compareObj.equals(curObj)) { remove = !exclude; break; } } } return remove; } public static List filterMapListByDate(List theList, String fromDateName, String thruDateName, Timestamp compareStamp) { if (theList == null || theList.size() == 0) return theList; if (fromDateName == null || fromDateName.isEmpty()) fromDateName = "fromDate"; if (thruDateName == null || thruDateName.isEmpty()) thruDateName = "thruDate"; // no access to ec.user here, so this should always be passed in, but just in case if (compareStamp == null) compareStamp = new Timestamp(System.currentTimeMillis()); Iterator theIterator = theList.iterator(); while (theIterator.hasNext()) { Map curMap = theIterator.next(); Timestamp fromDate = DefaultGroovyMethods.asType(curMap.get(fromDateName), Timestamp.class); if (fromDate != null && compareStamp.compareTo(fromDate) < 0) { theIterator.remove(); continue; } Timestamp thruDate = DefaultGroovyMethods.asType(curMap.get(thruDateName), Timestamp.class); if (thruDate != null && compareStamp.compareTo(thruDate) >= 0) theIterator.remove(); } return theList; } public static void filterMapListByDate(List theList, String fromDateName, String thruDateName, Timestamp compareStamp, boolean ignoreIfEmpty) { if (ignoreIfEmpty && compareStamp == null) return; filterMapListByDate(theList, fromDateName, thruDateName, compareStamp); } /** Order list elements in place (modifies the list passed in), returns the list for convenience */ public static List> orderMapList(List> theList, List fieldNames) { return orderMapList(theList, fieldNames, null); } public static List> orderMapList(List> theList, List fieldNames, Boolean nullsLast) { if (fieldNames == null) throw new IllegalArgumentException("Cannot order List of Maps with null order by field list"); if (theList != null && fieldNames.size() > 0) theList.sort(new MapOrderByComparator(fieldNames).nullsLast(nullsLast)); return theList; } public static class MapOrderByComparator implements Comparator { String[] fieldNameArray; Boolean nullsLast = null; public MapOrderByComparator(List fieldNameList) { ArrayList fieldArrayList = new ArrayList<>(); for (CharSequence fieldName : fieldNameList) { String fieldStr = fieldName.toString(); if (fieldStr.contains(",")) { String[] curFieldArray = fieldStr.split(","); for (int i = 0; i < curFieldArray.length; i++) { String curField = curFieldArray[i]; if (curField == null) continue; fieldArrayList.add(curField.trim()); } } else { fieldArrayList.add(fieldStr); } } fieldNameArray = fieldArrayList.toArray(new String[0]); // logger.warn("Order list by " + Arrays.asList(fieldNameArray)); } public MapOrderByComparator nullsLast(Boolean nl) { nullsLast = nl; return this; } @SuppressWarnings("unchecked") @Override public int compare(Map map1, Map map2) { if (map1 == null) return -1; if (map2 == null) return 1; for (int i = 0; i < fieldNameArray.length; i++) { String fieldName = fieldNameArray[i]; boolean ascending = true; boolean ignoreCase = false; if (fieldName.charAt(0) == '-') { ascending = false; fieldName = fieldName.substring(1); } else if (fieldName.charAt(0) == '+') { fieldName = fieldName.substring(1); } if (fieldName.charAt(0) == '^') { ignoreCase = true; fieldName = fieldName.substring(1); } boolean nullsFirst = nullsLast != null ? !nullsLast.booleanValue() : ascending; Comparable value1 = (Comparable) map1.get(fieldName); Comparable value2 = (Comparable) map2.get(fieldName); // NOTE: nulls go earlier in the list for ascending, later in the list for !ascending if (value1 == null) { if (value2 != null) return nullsFirst ? -1 : 1; } else { if (value2 == null) { return nullsFirst ? 1 : -1; } else { if (ignoreCase && value1 instanceof String && value2 instanceof String) { int comp = ((String) value1).compareToIgnoreCase((String) value2); if (comp != 0) return ascending ? comp : -comp; } else { if (value1.getClass() != value2.getClass()) { if (value1 instanceof Number && value2 instanceof Number) { value1 = new BigDecimal(value1.toString()); value2 = new BigDecimal(value2.toString()); } // NOTE: any other type normalization to avoid compareTo() casting exceptions? } int comp = value1.compareTo(value2); if (comp != 0) return ascending ? comp : -comp; } } } } // all evaluated to 0, so is the same, so return 0 return 0; } @Override public boolean equals(Object obj) { return obj instanceof MapOrderByComparator && Arrays.equals(fieldNameArray, ((MapOrderByComparator) obj).fieldNameArray); } @Override public String toString() { return Arrays.toString(fieldNameArray); } } /** * For a list of Map find the entry that best matches the fieldsByPriority Ordered Map; null field values in a Map * in mapList match against any value but do not contribute to maximal match score, otherwise value for each field * in fieldsByPriority must match for it to be a candidate. */ public static Map findMaximalMatch(List> mapList, LinkedHashMap fieldsByPriority) { int numFields = fieldsByPriority.size(); String[] fieldNames = new String[numFields]; Object[] fieldValues = new Object[numFields]; int index = 0; for (Map.Entry entry : fieldsByPriority.entrySet()) { fieldNames[index] = entry.getKey(); fieldValues[index] = entry.getValue(); index++; } int highScore = -1; Map highMap = null; for (Map curMap : mapList) { int curScore = 0; boolean skipMap = false; for (int i = 0; i < numFields; i++) { String curField = fieldNames[i]; Object compareValue = fieldValues[i]; // if curMap value is null skip field (null value in Map means allow any match value Object curValue = curMap.get(curField); if (curValue == null) continue; // if not equal skip Map if (!curValue.equals(compareValue)) { skipMap = true; break; } // add to score based on index (lower index higher score), also add numFields so more fields matched weights higher curScore += (numFields - i) + numFields; } if (skipMap) continue; // have a higher score? if (curScore > highScore) { highScore = curScore; highMap = curMap; } } return highMap; } @SuppressWarnings("unchecked") public static void addToListInMap(Object key, Object value, Map theMap) { if (theMap == null) return; List theList = (List) theMap.get(key); if (theList == null) { theList = new ArrayList(); theMap.put(key, theList); } theList.add(value); } @SuppressWarnings("unchecked") public static boolean addToSetInMap(Object key, Object value, Map theMap) { if (theMap == null) return false; Set theSet = (Set) theMap.get(key); if (theSet == null) { theSet = new LinkedHashSet(); theMap.put(key, theSet); } return theSet.add(value); } @SuppressWarnings("unchecked") public static void addToMapInMap(Object keyOuter, Object keyInner, Object value, Map theMap) { if (theMap == null) return; Map innerMap = (Map) theMap.get(keyOuter); if (innerMap == null) { innerMap = new LinkedHashMap(); theMap.put(keyOuter, innerMap); } innerMap.put(keyInner, value); } @SuppressWarnings("unchecked") public static void addToBigDecimalInMap(Object key, BigDecimal value, Map theMap) { if (value == null || theMap == null) return; Object curObj = theMap.get(key); if (curObj == null) { theMap.put(key, value); } else { BigDecimal curVal; if (curObj instanceof BigDecimal) curVal = (BigDecimal) curObj; else curVal = new BigDecimal(curObj.toString()); theMap.put(key, curVal.add(value)); } } public static void addBigDecimalsInMap(Map baseMap, Map addMap) { if (baseMap == null || addMap == null) return; for (Map.Entry entry : addMap.entrySet()) { if (!(entry.getValue() instanceof BigDecimal)) continue; BigDecimal addVal = (BigDecimal) entry.getValue(); Object baseObj = baseMap.get(entry.getKey()); if (baseObj == null || !(baseObj instanceof BigDecimal)) baseObj = BigDecimal.ZERO; BigDecimal baseVal = (BigDecimal) baseObj; baseMap.put(entry.getKey(), baseVal.add(addVal)); } } public static void divideBigDecimalsInMap(Map baseMap, BigDecimal divisor) { if (baseMap == null || divisor == null || divisor.doubleValue() == 0.0) return; for (Map.Entry entry : baseMap.entrySet()) { if (!(entry.getValue() instanceof BigDecimal)) continue; BigDecimal baseVal = (BigDecimal) entry.getValue(); entry.setValue(baseVal.divide(divisor, RoundingMode.HALF_UP)); } } /** Returns Map with total, squaredTotal, count, average, stdDev, maximum; fieldName field in Maps must have type BigDecimal; * if count of non-null fields is less than 2 returns null as cannot calculate a standard deviation */ public static Map stdDevMaxFromMapField(List> dataList, String fieldName, BigDecimal stdDevMultiplier) { BigDecimal total = BigDecimal.ZERO; BigDecimal squaredTotal = BigDecimal.ZERO; int count = 0; for (Map dataMap : dataList) { if (dataMap == null) continue; BigDecimal value = (BigDecimal) dataMap.get(fieldName); if (value == null) continue; total = total.add(value); squaredTotal = squaredTotal.add(value.multiply(value)); count++; } if (count < 2) return null; BigDecimal countBd = new BigDecimal(count); BigDecimal average = total.divide(countBd, RoundingMode.HALF_UP); double totalDouble = total.doubleValue(); BigDecimal stdDev = new BigDecimal(Math.sqrt(Math.abs(squaredTotal.doubleValue() - ((totalDouble*totalDouble) / count)) / (count - 1))); Map retMap = new HashMap<>(6); retMap.put("total", total); retMap.put("squaredTotal", squaredTotal); retMap.put("count", countBd); retMap.put("average", average); retMap.put("stdDev", stdDev); if (stdDevMultiplier != null) retMap.put("maximum", average.add(stdDev.multiply(stdDevMultiplier))); return retMap; } /** Find a field value in a nested Map containing fields, Maps, and Collections of Maps (Lists, etc) */ public static Object findFieldNestedMap(String key, Map theMap) { if (theMap.containsKey(key)) return theMap.get(key); for (Object value : theMap.values()) { if (value instanceof Map) { Object fieldValue = findFieldNestedMap(key, (Map) value); if (fieldValue != null) return fieldValue; } else if (value instanceof Collection) { // only look in Collections of Maps for (Object colValue : (Collection) value) { if (colValue instanceof Map) { Object fieldValue = findFieldNestedMap(key, (Map) colValue); if (fieldValue != null) return fieldValue; } } } } return null; } /** Find all values of a named field in a nested Map containing fields, Maps, and Collections of Maps (Lists, etc) */ public static void findAllFieldsNestedMap(String key, Map theMap, Set valueSet) { if (theMap instanceof LiteStringMap) { LiteStringMap lsm = (LiteStringMap) theMap; int keyLength = key != null ? key.length() : 0; int keyHashCode = key != null ? key.hashCode() : 0; boolean foundKey = false; int lsmSize = lsm.size(); for (int i = 0; i < lsmSize; i++) { String curKey = lsm.getKey(i); Object curValue = lsm.getValue(i); if (!foundKey && keyLength == curKey.length() && keyHashCode == curKey.hashCode() && curKey.equals(key)) { foundKey = true; if (curValue != null) valueSet.add(curValue); } if (curValue instanceof Map) { findAllFieldsNestedMap(key, (Map) curValue, valueSet); } else if (curValue instanceof Collection) { // only look in Collections of Maps for (Object colValue : (Collection) curValue) { if (colValue instanceof Map) findAllFieldsNestedMap(key, (Map) colValue, valueSet); } } } } else { Object localValue = theMap.get(key); if (localValue != null) valueSet.add(localValue); for (Object value : theMap.values()) { if (value instanceof Map) { findAllFieldsNestedMap(key, (Map) value, valueSet); } else if (value instanceof Collection) { // only look in Collections of Maps for (Object colValue : (Collection) value) { if (colValue instanceof Map) findAllFieldsNestedMap(key, (Map) colValue, valueSet); } } } } } /** Creates a single Map with fields from the passed in Map and all nested Maps (for Map and Collection of Map entry values) */ @SuppressWarnings("unchecked") public static Map flattenNestedMap(Map theMap) { if (theMap == null) return null; Map outMap = new LinkedHashMap(); for (Object entryObj : theMap.entrySet()) { Map.Entry entry = (Map.Entry) entryObj; Object value = entry.getValue(); if (value instanceof Map) { outMap.putAll(flattenNestedMap((Map) value)); } else if (value instanceof Collection) { for (Object colValue : (Collection) value) { if (colValue instanceof Map) outMap.putAll(flattenNestedMap((Map) colValue)); } } else { outMap.put(entry.getKey(), entry.getValue()); } } return outMap; } public static Map flattenNestedMapWithKeys(Map theMap) { return flattenNestedMapWithKeys(theMap, ""); } @SuppressWarnings("unchecked") private static Map flattenNestedMapWithKeys(Map theMap, String parentKey) { Map output = new LinkedHashMap<>(); if (theMap == null) return output; for (Map.Entry entry : theMap.entrySet()) { String key = entry.getKey(); Object value = entry.getValue(); String newKey = parentKey.isEmpty() ? key : parentKey + "[" + key + "]"; if (value instanceof Map) { output.putAll(flattenNestedMapWithKeys((Map) value, newKey)); } else if (value instanceof Collection) { int index = 0; for (Object colValue : (Collection) value) { if (colValue instanceof Map) { output.putAll(flattenNestedMapWithKeys((Map) colValue, newKey + "[" + index + "]")); } else { output.put(newKey + "[" + index + "]", colValue.toString()); } index++; } } else { output.put(newKey, value.toString()); } } return output; } @SuppressWarnings("unchecked") public static void mergeNestedMap(Map baseMap, Map overrideMap, boolean overrideEmpty) { if (baseMap == null || overrideMap == null) return; for (Map.Entry entry : overrideMap.entrySet()) { Object key = entry.getKey(); Object value = entry.getValue(); if (baseMap.containsKey(key)) { if (value == null) { if (overrideEmpty) baseMap.put(key, null); } else { if (value instanceof CharSequence) { if (overrideEmpty || ((CharSequence) value).length() > 0) baseMap.put(key, value); } else if (value instanceof Map) { Object baseValue = baseMap.get(key); if (baseValue != null && baseValue instanceof Map) { mergeNestedMap((Map) baseValue, (Map) value, overrideEmpty); } else { baseMap.put(key, value); } } else if (value instanceof Collection) { Object baseValue = baseMap.get(key); if (baseValue != null && baseValue instanceof Collection) { Collection baseCol = (Collection) baseValue; Collection overrideCol = (Collection) value; for (Object overrideObj : overrideCol) { // NOTE: if we have a Collection of Map we have no way to merge the Maps without knowing the 'key' entries to use to match them if (!baseCol.contains(overrideObj)) baseCol.add(overrideObj); } } else { baseMap.put(key, value); } } else { // NOTE: no way to check empty, if not null not empty so put it baseMap.put(key, value); } } } else { baseMap.put(key, value); } } } public final static Collection singleNullCollection; static { singleNullCollection = new ArrayList<>(); singleNullCollection.add(null); } /** Removes entries with a null value from the Map, returns the passed in Map for convenience (does not clone before removes!). */ @SuppressWarnings("unchecked") public static Map removeNullsFromMap(Map theMap) { if (theMap == null) return null; theMap.values().removeAll(singleNullCollection); return theMap; } public static boolean mapMatchesFields(Map baseMap, Map compareMap) { for (Map.Entry entry : compareMap.entrySet()) { Object compareObj = compareMap.get(entry.getKey()); Object baseObj = baseMap.get(entry.getKey()); if (compareObj == null) { if (baseObj != null) return false; } else { if (!compareObj.equals(baseObj)) return false; } } return true; } public static Node deepCopyNode(Node original) { return deepCopyNode(original, null); } @SuppressWarnings("unchecked") public static Node deepCopyNode(Node original, Node parent) { if (original == null) return null; Node newNode = new Node(parent, original.name(), new HashMap(original.attributes())); Object newValue = original.value(); if (newValue != null && newValue instanceof List) { NodeList childList = new NodeList(); for (Object child : (List) newValue) { if (child instanceof Node) { childList.add(deepCopyNode((Node) child, newNode)); } else if (child != null) { childList.add(child); } } newValue = childList; } if (newValue != null) newNode.setValue(newValue); return newNode; } public static String nodeText(Object nodeObj) { if (!DefaultGroovyMethods.asBoolean(nodeObj)) return ""; Node theNode = null; if (nodeObj instanceof Node) { theNode = (Node) nodeObj; } else if (nodeObj instanceof NodeList) { NodeList nl = DefaultGroovyMethods.asType((Collection) nodeObj, NodeList.class); if (nl.size() > 0) theNode = (Node) nl.get(0); } if (theNode == null) return ""; List textList = theNode.localText(); if (DefaultGroovyMethods.asBoolean(textList)) { if (textList.size() == 1) { return textList.get(0); } else { StringBuilder sb = new StringBuilder(); for (String txt : textList) sb.append(txt).append("\n"); return sb.toString(); } } else { return ""; } } public static Node nodeChild(Node parent, String childName) { if (parent == null) return null; NodeList childList = (NodeList) parent.get(childName); if (childList != null && childList.size() > 0) return (Node) childList.get(0); return null; } public static void paginateList(String listName, String pageListName, Map context) { if (pageListName == null || pageListName.isEmpty()) pageListName = listName; List theList = (List) context.get(listName); if (theList == null) theList = new ArrayList(); List pageList = paginateList(theList, pageListName, context); context.put(pageListName, pageList); } public static List paginateList(List theList, String pageListName, Map context) { // if this exists then was already paginated so don't do a subList() if (context.containsKey(pageListName + "AlreadyPaginated")) return theList; Integer pageRangeLow = (Integer) context.get(pageListName + "PageRangeLow"); Integer pageRangeHigh = (Integer) context.get(pageListName + "PageRangeHigh"); if (pageRangeLow == null || pageRangeHigh == null) { paginateParameters(theList != null ? theList.size() : 0, pageListName, context); pageRangeLow = (Integer) context.get(pageListName + "PageRangeLow"); pageRangeHigh = (Integer) context.get(pageListName + "PageRangeHigh"); } return theList.subList(pageRangeLow - 1, pageRangeHigh); } public static Map paginateParameters(int listSize, String pageListName, Map context) { final Object pageIndexObj = context.get("pageIndex"); int pageIndex = 0; if (!ObjectUtilities.isEmpty(pageIndexObj)) { try { pageIndex = Integer.parseInt(pageIndexObj.toString()); } catch (Exception e) { /* just use the 0 default above */ } } if (pageIndex < 0) pageIndex = 0; final Object pageSizeObj = context.get("pageSize"); int pageSize = 20; if (!ObjectUtilities.isEmpty(pageSizeObj)) { try { pageSize = Integer.parseInt(pageSizeObj.toString()); } catch (Exception e) { /* just use the 20 default above */ } } if (pageSize < 0) pageSize = 20; // NOTE: if context has a *Count field don't calc and set values, assume are already in place if (context.get(pageListName + "Count") == null) { int count = listSize; // calculate the pagination values int maxIndex = (new BigDecimal(count - 1)).divide(new BigDecimal(pageSize), 0, RoundingMode.DOWN).intValue(); int pageRangeLow = (pageIndex * pageSize) + 1; if (pageRangeLow > count) pageRangeLow = count + 1; int pageRangeHigh = (pageIndex * pageSize) + pageSize; if (pageRangeHigh > count) pageRangeHigh = count; context.put(pageListName + "Count", count); context.put(pageListName + "PageIndex", pageIndex); context.put(pageListName + "PageSize", pageSize); context.put(pageListName + "PageMaxIndex", maxIndex); context.put(pageListName + "PageRangeLow", pageRangeLow); context.put(pageListName + "PageRangeHigh", pageRangeHigh); } else { context.put(pageListName + "AlreadyPaginated", true); } return context; } } ================================================ FILE: framework/src/main/java/org/moqui/util/ContextBinding.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import groovy.lang.Binding; public class ContextBinding extends Binding { private ContextStack contextStack; public ContextBinding(ContextStack variables) { super(variables); contextStack = variables; } @Override public Object getVariable(String name) { // NOTE: this code is part of the original Groovy groovy.lang.Binding.getVariable() method and leaving it out // is the reason to override this method: //if (result == null && !variables.containsKey(name)) { // throw new MissingPropertyException(name, this.getClass()); //} return contextStack.getByString(name); } @Override public void setVariable(String name, Object value) { contextStack.put(name, value); } @Override public boolean hasVariable(String name) { // always treat it like the variable exists and is null to change the behavior for variable scope and // declaration, easier in simple scripts return true; } } ================================================ FILE: framework/src/main/java/org/moqui/util/ContextStack.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import javax.annotation.Nonnull; import java.util.*; @SuppressWarnings("unchecked") public class ContextStack implements Map { private final static org.slf4j.Logger logger = org.slf4j.LoggerFactory.getLogger(ContextStack.class); private final int INITIAL_STACK_SIZE = 32; private HashMap sharedMap = null; private LinkedList contextStack = null; private Map[] stackArray = new Map[INITIAL_STACK_SIZE]; private int stackIndex = 0; private boolean includeContext = true; private static class ContextInfo { Map[] stackArray; int stackIndex; ContextInfo(Map[] stackArray, int stackIndex) { this.stackArray = stackArray; this.stackIndex = stackIndex; } ContextInfo cloneInfo() { Map[] newArray = new Map[stackArray.length]; System.arraycopy(stackArray, 0, newArray, 0, stackIndex + 1); return new ContextInfo(newArray, stackIndex); } } public ContextStack() { } public ContextStack(boolean includeContext) { this.includeContext = includeContext; } public Map getSharedMap() { if (sharedMap == null) sharedMap = new HashMap<>(); return sharedMap; } /** Push (save) the entire context, ie the whole Map stack, to create an isolated empty context. */ public ContextStack pushContext() { if (contextStack == null) contextStack = new LinkedList<>(); contextStack.addFirst(new ContextInfo(stackArray, stackIndex)); stackArray = new Map[INITIAL_STACK_SIZE]; stackIndex = 0; return this; } /** Pop (restore) the entire context, ie the whole Map stack, undo isolated empty context and get the original one. */ public ContextStack popContext() { if (contextStack == null || contextStack.size() == 0) throw new IllegalStateException("Cannot pop context, no context pushed"); ContextInfo ci = contextStack.removeFirst(); stackArray = ci.stackArray; stackIndex = ci.stackIndex; return this; } private void pushInternal(Map theMap) { stackIndex++; if (stackIndex >= stackArray.length) growStackArray(); // NOTE: if null leave null for lazy init on put stackArray[stackIndex] = theMap; } private void growStackArray() { // logger.warn("Growing ContextStack internal array from " + stackArray.length); stackArray = Arrays.copyOf(stackArray, stackArray.length * 2); } /** Puts a new Map on the top of the stack for a fresh local context * @return Returns reference to this ContextStack */ public ContextStack push() { pushInternal(null); return this; } /** Puts an existing Map on the top of the stack (top meaning will override lower layers on the stack) * @param existingMap An existing Map * @return Returns reference to this ContextStack */ public ContextStack push(Map existingMap) { if (existingMap == null) throw new IllegalArgumentException("Cannot push null as an existing Map"); if (includeContext && existingMap.containsKey("context")) throw new IllegalArgumentException("Cannot push existing Map containing key 'context', reserved key"); pushInternal(existingMap); return this; } /** Remove and returns the Map from the top of the stack (the local context). * If there is only one Map on the stack it returns null and does not remove it. * * @return The first/top Map */ public Map pop() { if (stackIndex == 0) throw new IllegalArgumentException("ContextStack is empty, cannot pop the context"); Map oldMap = stackArray[stackIndex]; stackArray[stackIndex] = null; stackIndex--; return oldMap; } /** Add an existing Map as the Root Map, ie on the BOTTOM of the stack meaning it will be overridden by other Maps on the stack * @param existingMap An existing Map */ public void addRootMap(Map existingMap) { if (existingMap == null) throw new IllegalArgumentException("Cannot add null as an existing Map"); if (includeContext && existingMap.containsKey("context")) throw new IllegalArgumentException("Cannot push existing Map containing key 'context', reserved key"); if ((stackIndex + 1) >= stackArray.length) growStackArray(); // move all elements up one for (int i = stackIndex; i >= 0; i--) stackArray[i+1] = stackArray[i]; stackIndex++; stackArray[0] = existingMap; } public Map getRootMap() { return stackArray[0]; } /** * Creates a ContextStack object that has the same Map objects on its stack (a shallow clone). * Meant to be used to enable a situation where a parent and child context are operating simultaneously using two * different ContextStack objects, but sharing the Maps between them. * * @return Clone of this ContextStack */ @Override public ContextStack clone() throws CloneNotSupportedException { ContextStack newStack = new ContextStack(); newStack.stackArray = new Map[stackArray.length]; System.arraycopy(stackArray, 0, newStack.stackArray, 0, stackIndex + 1); newStack.stackIndex = stackIndex; if (sharedMap != null) newStack.sharedMap = new HashMap<>(sharedMap); if (contextStack != null) { newStack.contextStack = new LinkedList<>(); for (ContextInfo ci : contextStack) newStack.contextStack.add(ci.cloneInfo()); } newStack.includeContext = includeContext; return newStack; } @Override public int size() { // use the keySet since this gets a set of all unique keys for all Maps in the stack Set keys = keySet(); return keys.size(); } @Override public boolean isEmpty() { for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && !stackArray[i].isEmpty()) return false; } return true; } @Override public boolean containsKey(Object key) { for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && stackArray[i].containsKey(key)) return true; } return false; } @Override public boolean containsValue(Object value) { // this keeps track of keys looked at for values at each level of the stack so that the same key is not // considered more than once (the earlier Maps overriding later ones) Set keysObserved = new HashSet<>(); for (int i = stackIndex; i >= 0; i--) { Map curMap = stackArray[i]; for (Map.Entry curEntry : curMap.entrySet()) { String curKey = curEntry.getKey(); if (!keysObserved.contains(curKey)) { keysObserved.add(curKey); if (value == null) { if (curEntry.getValue() == null) return true; } else { if (value.equals(curEntry.getValue())) return true; } } } } return false; // maybe do simpler but not as correct? for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] != null && stackArray[i].containsValue(value)) return true; } } /** For faster access to multiple entries; do not write to this Map or use when any changes to ContextStack are possible */ public Map getCombinedMap() { Map combinedMap = new HashMap<>(); // opposite order of get(), root down so later maps override earlier for (int i = 0; i <= stackIndex; i++) { if (stackArray[i] != null) combinedMap.putAll(stackArray[i]); } return combinedMap; } public Object getByString(String key) { for (int i = stackIndex; i >= 0; i--) { Map curMap = stackArray[i]; if (curMap == null || curMap.isEmpty()) continue; // optimize for non-null get, avoid double lookup with containsKey/get Object value = curMap.get(key); if (value != null) return value; if (curMap.containsKey(key)) return null; } // handle "context" reserved key to represent this if (includeContext && "context".equals(key)) return this; return null; } @Override public Object get(Object keyObj) { String key = null; if (keyObj instanceof String) { key = (String) keyObj; } else if (keyObj != null) { if (keyObj instanceof CharSequence) { key = keyObj.toString(); } else { return null; } } // with combinedMap now handling all changes this is a simple call return getByString(key); } @Override public Object put(String key, Object value) { if (includeContext && "context".equals(key)) throw new IllegalArgumentException("Cannot put with key 'context', reserved key"); if (stackArray[stackIndex] == null) stackArray[stackIndex] = new HashMap<>(); return stackArray[stackIndex].put(key, value); } @Override public Object remove(Object key) { if (stackArray[stackIndex] == null) return null; return stackArray[stackIndex].remove(key); } @Override public void putAll(@Nonnull Map theMap) { // using Nonnull: if (theMap == null) return; if (includeContext && theMap.containsKey("context")) throw new IllegalArgumentException("Cannot push existing Map containing key 'context', reserved key"); if (stackArray[stackIndex] == null) stackArray[stackIndex] = new HashMap<>(); stackArray[stackIndex].putAll(theMap); } @Override public void clear() { if (stackArray[stackIndex] == null) return; stackArray[stackIndex].clear(); } @Override public @Nonnull Set keySet() { Set resultSet = new HashSet<>(); // resultSet.add("context"); for (int i = stackIndex; i >= 0; i--) if (stackArray[i] != null) resultSet.addAll(stackArray[i].keySet()); return Collections.unmodifiableSet(resultSet); } @Override public @Nonnull Collection values() { Set keysObserved = new HashSet<>(); List resultValues = new LinkedList<>(); for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] == null) continue; for (Map.Entry curEntry: stackArray[i].entrySet()) { String curKey = curEntry.getKey(); if (!keysObserved.contains(curKey)) { keysObserved.add(curKey); resultValues.add(curEntry.getValue()); } } } return Collections.unmodifiableCollection(resultValues); } @Override public @Nonnull Set> entrySet() { Set keysObserved = new HashSet<>(); Set> resultEntrySet = new HashSet<>(); for (int i = stackIndex; i >= 0; i--) { if (stackArray[i] == null) continue; for (Map.Entry curEntry: stackArray[i].entrySet()) { String curKey = curEntry.getKey(); if (!keysObserved.contains(curKey)) { keysObserved.add(curKey); resultEntrySet.add(curEntry); } } } return Collections.unmodifiableSet(resultEntrySet); } @Override public String toString() { StringBuilder fullMapString = new StringBuilder(); for (int i = 0; i <= stackIndex; i++) { Map curMap = stackArray[i]; if (curMap == null) continue; fullMapString.append("========== Start stack level ").append(i).append("\n"); for (Map.Entry curEntry: curMap.entrySet()) { fullMapString.append("==>["); fullMapString.append(curEntry.getKey()); fullMapString.append("]:"); if (curEntry.getValue() instanceof ContextStack) { // skip instances of ContextStack to avoid infinite recursion fullMapString.append(""); } else { fullMapString.append(curEntry.getValue()); } fullMapString.append("\n"); } fullMapString.append("========== End stack level ").append(i).append("\n"); } return fullMapString.toString(); } @Override public int hashCode() { return Arrays.deepHashCode(stackArray); } @Override public boolean equals(Object o) { return !(o == null || o.getClass() != this.getClass()) && Arrays.deepEquals(stackArray, ((ContextStack) o).stackArray); } } ================================================ FILE: framework/src/main/java/org/moqui/util/LiteStringMap.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import java.io.Externalizable; import java.io.IOException; import java.io.ObjectInput; import java.io.ObjectOutput; import java.util.*; /** Light weight String Keyed Map optimized for memory usage and garbage collection overhead. * Uses parallel key and value arrays internally and does not create an object for each Map.Entry unless entrySet() is used. * This is generally slower than HashMap unless key String objects are already interned. * With '*IString' variations of methods a call with a known already interned String can operate as fast, and for smaller Maps faster, than HashMap (such as in the EntityFacade where field names come from an interned String in a FieldInfo object). * This is most certainly not thread-safe. */ public class LiteStringMap implements Map, Externalizable, Comparable>, Cloneable { // NOTE: for over the wire compatibility do not change this unless writeExternal() and readExternal() are changed OR the non-transient fields change from only keyArray, valueArray, and lastIndex private static final long serialVersionUID = 688763341199951234L; private static final int DEFAULT_CAPACITY = 8; // NOTE: from basic profiling HashMap.get() runs in just over half the time (0.13 microseconds) of String.intern() (0.24 microseconds) over ~500k runs with OpenJDK 8 private static HashMap internedMap = new HashMap<>(); public static String internString(String orig) { String interned = internedMap.get(orig); if (interned != null) return interned; // don't even check for null until we have to if (orig == null) return null; interned = orig.intern(); internedMap.put(interned, interned); return interned; } // NOTE: key design point is to use parallel arrays with simple values in each so that no Object need be created per entry (minimize GC overhead, etc) private String[] keyArray; private V[] valueArray; private int lastIndex = -1; private transient int mapHash = 0; private transient boolean useManualIndex = false; public LiteStringMap() { init(DEFAULT_CAPACITY); } public LiteStringMap(int initialCapacity) { init(initialCapacity); } public LiteStringMap(Map cloneMap) { init(cloneMap.size()); if (cloneMap instanceof LiteStringMap && ((LiteStringMap) cloneMap).useManualIndex) useManualIndex = true; putAll(cloneMap); } public LiteStringMap(Map cloneMap, Set skipKeys) { init(cloneMap.size()); if (cloneMap instanceof LiteStringMap && ((LiteStringMap) cloneMap).useManualIndex) useManualIndex = true; putAll(cloneMap, skipKeys); } @SuppressWarnings("unchecked") private void init(int capacity) { keyArray = new String[capacity]; valueArray = (V[]) new Object[capacity]; } private void growArrays(Integer minLength) { int newLength = keyArray.length * 2; if (minLength != null && newLength < minLength) newLength = minLength; // System.out.println("=============================\n============= grow to " + newLength); keyArray = Arrays.copyOf(keyArray, newLength); valueArray = Arrays.copyOf(valueArray, newLength); } public LiteStringMap ensureCapacity(int capacity) { if (keyArray.length < capacity) { keyArray = Arrays.copyOf(keyArray, capacity); valueArray = Arrays.copyOf(valueArray, capacity); } return this; } public LiteStringMap useManualIndex() { useManualIndex = true; return this; } public int findIndex(String keyOrig) { if (keyOrig == null) return -1; return findIndexIString(internString(keyOrig)); /* safer but slower approach, needed? by String.intern() JavaDoc no, consistency guaranteed int keyLength = keyString.length(); int keyHashCode = keyString.hashCode(); // NOTE: can't use Arrays.binarySearch() as we want to maintain the insertion order and not use natural order for array elements for (int i = 0; i <= lastIndex; i++) { // all strings in keyArray should be interned, only added via put() String curKey = keyArray[i]; // first optimization is using interned String with identity compare, but don't always rely on this // next optimization comparing length() and hashCode() first to eliminate mismatches more quickly (by far the most common case) // basic premise is that key Strings will be reused frequently and will already have a hashCode calculated if (curKey == keyString || (curKey.length() == keyLength && curKey.hashCode() == keyHashCode && keyString.equals(curKey))) return i; } return -1; */ } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public int findIndexIString(String key) { for (int i = 0; i <= lastIndex; i++) { // all strings in keyArray should be interned, only added via put() if (keyArray[i] == key) return i; } return -1; } public String getKey(int index) { return keyArray[index]; } public V getValue(int index) { return (V) valueArray[index]; } @Override public int size() { return lastIndex + 1; } @Override public boolean isEmpty() { return lastIndex == -1; } @Override public boolean containsKey(Object key) { if (key == null) return false; return findIndex(key.toString()) != -1; } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public boolean containsKeyIString(String key) { return findIndexIString(key) != -1; } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public boolean containsKeyIString(String key, int index) { String idxKey = keyArray[index]; if (idxKey == null) return false; if (idxKey != key) throw new IllegalArgumentException("Index " + index + " has key " + keyArray[index] + ", cannot check contains with key " + key); return true; } @Override public boolean containsValue(Object value) { for (int i = 0; i <= lastIndex; i++) { if (valueArray[i] == null) { if (value == null) return true; } else { if (valueArray[i].equals(value)) return true; } } return false; } @Override public V get(Object key) { if (key == null) return null; int keyIndex = findIndex(key.toString()); if (keyIndex == -1) return null; return valueArray[keyIndex]; } public V getByString(String key) { int keyIndex = findIndex(key); if (keyIndex == -1) return null; return valueArray[keyIndex]; } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public V getByIString(String key) { int keyIndex = findIndexIString(key); if (keyIndex == -1) return null; return valueArray[keyIndex]; } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public V getByIString(String key, int index) { if (index >= keyArray.length) throw new ArrayIndexOutOfBoundsException("Index " + index + " invalid, internal array length " + keyArray.length + "; for key: " + key); String idxKey = keyArray[index]; if (idxKey == null) return null; if (idxKey != key) throw new IllegalArgumentException("Index " + index + " has key " + keyArray[index] + ", cannot get with key " + key); return valueArray[index]; } /* ========= Start Mutate Methods ========= */ @Override public V put(String keyOrig, V value) { if (keyOrig == null) throw new IllegalArgumentException("LiteStringMap Key may not be null"); return putByIString(internString(keyOrig), value); } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public V putByIString(String key, V value) { // if ("pseudoId".equals(key)) { System.out.println("========= put no index " + key + ": " + value); new Exception("location").printStackTrace(); } int keyIndex = findIndexIString(key); if (keyIndex == -1) { lastIndex++; if (lastIndex >= keyArray.length) growArrays(null); keyArray[lastIndex] = key; valueArray[lastIndex] = value; mapHash = 0; return null; } else { V oldValue = valueArray[keyIndex]; valueArray[keyIndex] = value; mapHash = 0; return oldValue; } } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ public V putByIString(String key, V value, int index) { // if ("pseudoId".equals(key)) { System.out.println("========= put index " + index + " key " + key + ": " + value); new Exception("location").printStackTrace(); } useManualIndex = true; if (index >= keyArray.length) growArrays(index + 1); if (index > lastIndex) lastIndex = index; if (keyArray[index] == null) { keyArray[index] = key; valueArray[index] = value; mapHash = 0; return null; } else { // identity compare for interned String if (key != keyArray[index]) throw new IllegalArgumentException("Index " + index + " already has key " + keyArray[index] + ", cannot use with key " + key); V oldValue = valueArray[index]; valueArray[index] = value; mapHash = 0; return oldValue; } } @Override public V remove(Object key) { if (key == null) return null; int keyIndex = findIndexIString(internString(key.toString())); return removeByIndex(keyIndex); } private V removeByIndex(int keyIndex) { if (keyIndex == -1) { return null; } else { V oldValue = valueArray[keyIndex]; if (useManualIndex) { // with manual indexes don't shift entries, will cause manually specified indexes to be wrong keyArray[keyIndex] = null; valueArray[keyIndex] = null; } else { // shift all later values up one position for (int i = keyIndex; i < lastIndex; i++) { keyArray[i] = keyArray[i+1]; valueArray[i] = valueArray[i+1]; } // null the last values to avoid memory leak keyArray[lastIndex] = null; valueArray[lastIndex] = null; // decrement last index lastIndex--; } // reset hash mapHash = 0; return oldValue; } } public boolean removeAllKeys(Collection collection) { if (collection == null) return false; boolean removedAny = false; for (Object obj : collection) { // keys in LiteStringMap cannot be null if (obj == null) continue; int idx = findIndex(obj.toString()); if (idx != -1) { removeByIndex(idx); removedAny = true; } } return removedAny; } public boolean removeValue(Object value) { boolean removedAny = false; for (int i = 0; i < valueArray.length; i++) { Object curVal = valueArray[i]; if (value == null) { if (curVal == null) { removeByIndex(i); removedAny = true; } } else if (value.equals(curVal)) { removeByIndex(i); removedAny = true; } } return removedAny; } public boolean removeAllValues(Collection collection) { if (collection == null) return false; boolean removedAny = false; // NOTE: could iterate over valueArray outer and collection inner but value array has no Iterator overhead so do that inner (and nice to reuse removeValue()) for (Object obj : collection) { if (removeValue(obj)) removedAny = true; } return removedAny; } @Override public void putAll(Map map) { putAll(map, null); } @SuppressWarnings("unchecked") public void putAll(Map map, Set skipKeys) { if (map == null) return; boolean initialEmpty = lastIndex == -1; if (map instanceof LiteStringMap) { LiteStringMap lsm = (LiteStringMap) map; if (useManualIndex) { this.lastIndex = lsm.lastIndex; if (keyArray.length <= lsm.lastIndex) growArrays(lsm.lastIndex); } for (int i = 0; i <= lsm.lastIndex; i++) { if (skipKeys != null && skipKeys.contains(lsm.keyArray[i])) continue; if (useManualIndex) { keyArray[i] = lsm.keyArray[i]; valueArray[i] = lsm.valueArray[i]; } else if (initialEmpty) { putNoFind(lsm.keyArray[i], lsm.valueArray[i]); } else { putByIString(lsm.keyArray[i], lsm.valueArray[i]); } } } else { for (Map.Entry entry : map.entrySet()) { String key = entry.getKey(); if (key == null) throw new IllegalArgumentException("LiteStringMap Key may not be null"); if (skipKeys != null && skipKeys.contains(key)) continue; if (initialEmpty) { putNoFind(internString(key), entry.getValue()); } else { putByIString(internString(key), entry.getValue()); } } } mapHash = 0; } /** For this method the String key must be non-null and interned (returned value from String.intern()) */ private void putNoFind(String key, V value) { lastIndex++; if (lastIndex >= keyArray.length) growArrays(null); keyArray[lastIndex] = key; valueArray[lastIndex] = value; mapHash = 0; } @Override public void clear() { lastIndex = -1; Arrays.fill(keyArray, null); Arrays.fill(valueArray, null); mapHash = 0; } /* ========= End Mutate Methods ========= */ @Override public Set keySet() { return new KeySetWrapper(this); } @Override public Collection values() { return new ValueCollectionWrapper<>(this); } @Override public Set> entrySet() { return new EntrySetWrapper<>(this); } @Override public int hashCode() { if (mapHash == 0) { // NOTE: this mimics the HashMap implementation from AbstractMap.java for the outer (add entry hash codes) and HashMap.java for the Map.Entry impl for (int i = 0; i <= lastIndex; i++) { mapHash += (keyArray[i] == null ? 0 : keyArray[i].hashCode()) ^ (valueArray[i] == null ? 0 : valueArray[i].hashCode()); } } return mapHash; } @Override public boolean equals(Object o) { if (o instanceof LiteStringMap) { LiteStringMap lsm = (LiteStringMap) o; if (lastIndex != lsm.lastIndex) return false; for (int i = 0; i <= lastIndex; i++) { // identity compare of interned String keys, if equal the value in the other LSM is conveniently at the same index if (keyArray[i] == lsm.keyArray[i]) { if (!Objects.equals(valueArray[i], lsm.valueArray[i])) return false; } else { Object value = lsm.getByIString(keyArray[i]); if (!Objects.equals(valueArray[i], value)) return false; } } return true; } else if (o instanceof Map) { Map map = (Map) o; if ((lastIndex + 1) != map.size()) return false; for (int i = 0; i <= lastIndex; i++) { Object value = map.get(keyArray[i]); if (!Objects.equals(valueArray[i], value)) return false; } return true; } else { return false; } } @Override protected Object clone() { return new LiteStringMap(this); } public LiteStringMap cloneLite() { return new LiteStringMap(this); } @Override public String toString() { StringBuilder sb = new StringBuilder(); sb.append('['); for (int i = 0; i <= lastIndex; i++) { if (i != 0) sb.append(", "); sb.append(keyArray[i]).append(":").append(valueArray[i]); } sb.append(']'); return sb.toString(); } @Override public void writeExternal(ObjectOutput out) throws IOException { int size = lastIndex + 1; out.writeInt(size); // after writing size write each key/value pair for (int i = 0; i < size; i++) { out.writeObject(keyArray[i]); out.writeObject(valueArray[i]); } } @Override @SuppressWarnings("unchecked") public void readExternal(ObjectInput in) throws IOException, ClassNotFoundException { int size = in.readInt(); if (keyArray.length < size) { keyArray = new String[size]; valueArray = (V[]) new Object[size]; } lastIndex = size - 1; mapHash = 0; // now that we know the size read each key/value pair for (int i = 0; i < size; i++) { // intern Strings, from deserialize they will not be interned String key = (String) in.readObject(); keyArray[i] = key != null ? internString(key) : null; valueArray[i] = (V) in.readObject(); } } @Override @SuppressWarnings("unchecked") public int compareTo(Map that) { int result = 0; if (that instanceof LiteStringMap) { LiteStringMap lsm = (LiteStringMap) that; result = Integer.compare(lastIndex, lsm.lastIndex); if (result != 0) return result; for (int i = 0; i <= lastIndex; i++) { Comparable thisVal = (Comparable) valueArray[i]; // identity compare of interned String keys, if equal the value in the other LSM is conveniently at the same index Comparable thatVal = keyArray[i] == lsm.keyArray[i] ? (Comparable) lsm.valueArray[i] : (Comparable) lsm.getByIString(keyArray[i]); // NOTE: nulls go earlier in the list if (thisVal == null) { result = thatVal == null ? 0 : 1; } else { result = thatVal == null ? -1 : thisVal.compareTo(thatVal); } if (result != 0) return result; } } else { result = Integer.compare(lastIndex + 1, that.size()); if (result != 0) return result; for (int i = 0; i <= lastIndex; i++) { Comparable thisVal = (Comparable) valueArray[i]; Comparable thatVal = (Comparable) that.get(keyArray[i]); // NOTE: nulls go earlier in the list if (thisVal == null) { result = thatVal == null ? 0 : 1; } else { result = thatVal == null ? -1 : thisVal.compareTo(thatVal); } if (result != 0) return result; } } return result; } /* ========== Interface Wrapper Classes ========== */ public static class KeyIterator implements Iterator { private final LiteStringMap lsm; private int curIndex = -1; KeyIterator(LiteStringMap liteStringMap) { lsm = liteStringMap; } @Override public boolean hasNext() { return lsm.lastIndex > curIndex; } @Override public String next() { curIndex++; return lsm.keyArray[curIndex]; } } public static class ValueIterator implements Iterator { private final LiteStringMap lsm; private int curIndex = -1; ValueIterator(LiteStringMap liteStringMap) { lsm = liteStringMap; } @Override public boolean hasNext() { return lsm.lastIndex > curIndex; } @Override public V next() { curIndex++; return lsm.valueArray[curIndex]; } } public static class KeySetWrapper implements Set { private final LiteStringMap lsm; KeySetWrapper(LiteStringMap liteStringMap) { lsm = liteStringMap; } @Override public int size() { return lsm.size(); } @Override public boolean isEmpty() { return lsm.isEmpty(); } @Override public boolean contains(Object o) { return lsm.containsKey(o); } @Override public Iterator iterator() { return new KeyIterator(lsm); } @Override public Object[] toArray() { return Arrays.copyOf(lsm.keyArray, lsm.lastIndex + 1); } @Override public T[] toArray(T[] ts) { int toCopy = ts.length > lsm.lastIndex ? lsm.lastIndex + 1 : ts.length; System.arraycopy(lsm.keyArray, 0, ts, 0, toCopy); return ts; } @Override public boolean containsAll(Collection collection) { if (collection == null) return false; for (Object obj : collection) if (obj == null || lsm.findIndex(obj.toString()) == -1) return false; return true; } @Override public boolean add(String s) { throw new UnsupportedOperationException("Key Set add not allowed"); } @Override public boolean remove(Object o) { if (o == null) return false; int idx = lsm.findIndex(o.toString()); if (idx == -1) { return false; } else { lsm.removeByIndex(idx); return true; } } @Override public boolean addAll(Collection collection) { throw new UnsupportedOperationException("Key Set add all not allowed"); } @Override public boolean retainAll(Collection collection) { throw new UnsupportedOperationException("Key Set retain all not allowed"); } @Override @SuppressWarnings("unchecked") public boolean removeAll(Collection collection) { return lsm.removeAllKeys(collection); } @Override public void clear() { throw new UnsupportedOperationException("Key Set clear not allowed"); } } public static class ValueCollectionWrapper implements Collection { private final LiteStringMap lsm; ValueCollectionWrapper(LiteStringMap liteStringMap) { lsm = liteStringMap; } @Override public int size() { return lsm.size(); } @Override public boolean isEmpty() { return lsm.isEmpty(); } @Override public boolean contains(Object o) { return lsm.containsValue(o); } @Override public boolean containsAll(Collection collection) { if (collection == null || collection.isEmpty()) return true; for (Object obj : collection) { if (!lsm.containsValue(obj)) return false; } return true; } @Override public Iterator iterator() { return new ValueIterator(lsm); } @Override public Object[] toArray() { return Arrays.copyOf(lsm.valueArray, lsm.lastIndex + 1); } @Override public T[] toArray(T[] ts) { int toCopy = ts.length > lsm.lastIndex ? lsm.lastIndex + 1 : ts.length; System.arraycopy(lsm.valueArray, 0, ts, 0, toCopy); return ts; } @Override public boolean add(Object s) { throw new UnsupportedOperationException("Value Collection add not allowed"); } @Override public boolean remove(Object o) { return lsm.removeValue(o); } @Override public boolean addAll(Collection collection) { throw new UnsupportedOperationException("Value Collection add all not allowed"); } @Override public boolean retainAll(Collection collection) { throw new UnsupportedOperationException("Value Collection retain all not allowed"); } @Override public boolean removeAll(Collection collection) { return lsm.removeAllValues(collection); } @Override public void clear() { throw new UnsupportedOperationException("Value Collection clear not allowed"); } } public static class EntryWrapper implements Entry { private final LiteStringMap lsm; private final String key; private int curIndex; EntryWrapper(LiteStringMap liteStringMap, int index) { lsm = liteStringMap; curIndex = index; key = lsm.keyArray[index]; } @Override public String getKey() { return key; } @Override public V getValue() { String keyCheck = lsm.keyArray[curIndex]; if (!Objects.equals(key, keyCheck)) curIndex = lsm.findIndex(key); if (curIndex == -1) return null; return lsm.valueArray[curIndex]; } @Override public V setValue(V value) { String keyCheck = lsm.keyArray[curIndex]; if (!Objects.equals(key, keyCheck)) curIndex = lsm.findIndex(key); if (curIndex == -1) return lsm.put(key, value); V oldValue = lsm.valueArray[curIndex]; lsm.valueArray[curIndex] = value; return oldValue; } } public static class EntrySetWrapper implements Set> { private final LiteStringMap lsm; EntrySetWrapper(LiteStringMap liteStringMap) { lsm = liteStringMap; } @Override public int size() { return lsm.size(); } @Override public boolean isEmpty() { return lsm.isEmpty(); } @Override public boolean contains(Object obj) { if (obj instanceof Entry) { Entry entry = (Entry) obj; Object keyObj = entry.getKey(); if (keyObj == null) return false; int idx = lsm.findIndex(keyObj.toString()); if (idx == -1) return false; Object entryValue = entry.getValue(); Object keyValue = lsm.valueArray[idx]; return Objects.equals(entryValue, keyValue); } else { return false; } } @Override public Iterator> iterator() { ArrayList> entryList = new ArrayList<>(lsm.lastIndex + 1); for (int i = 0; i <= lsm.lastIndex; i++) { if (lsm.getKey(i) == null) continue; entryList.add(new EntryWrapper(lsm, i)); } return entryList.iterator(); } @Override public V[] toArray() { throw new UnsupportedOperationException("Entry Set to array not supported"); } @Override public T[] toArray(T[] ts) { throw new UnsupportedOperationException("Entry Set copy to array not supported"); } @Override public boolean containsAll(Collection collection) { if (collection == null) return false; for (Object obj : collection) if (obj == null || lsm.findIndex(obj.toString()) == -1) return false; return true; } @Override public boolean add(Entry entry) { throw new UnsupportedOperationException("Entry Set add not allowed"); } @Override public boolean remove(Object o) { throw new UnsupportedOperationException("Entry Set remove not allowed"); } @Override public boolean addAll(Collection> collection) { throw new UnsupportedOperationException("Entry Set add all not allowed"); } @Override public boolean retainAll(Collection collection) { throw new UnsupportedOperationException("Entry Set retain all not allowed"); } @Override public boolean removeAll(Collection collection) { throw new UnsupportedOperationException("Entry Set remove all not allowed"); } @Override public void clear() { throw new UnsupportedOperationException("Entry Set clear not allowed"); } } } ================================================ FILE: framework/src/main/java/org/moqui/util/MClassLoader.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import java.io.*; import java.net.MalformedURLException; import java.net.URL; import java.security.CodeSource; import java.security.ProtectionDomain; import java.security.cert.Certificate; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; /** * A caching ClassLoader that allows addition of JAR files and class directories to the classpath at runtime. * * This loads resources from its class directories and JAR files first, then tries the parent. This is not the standard * approach, but needed for configuration in moqui/runtime and components to override other classpath resources. * * This loads classes from the parent first, then its class directories and JAR files. */ public class MClassLoader extends ClassLoader { private static final boolean checkJars = false; // rememberClassNotFound causes problems with Groovy that tries to load variations on class names, then creates them, then tries again private static final boolean rememberClassNotFound = false; private static final boolean rememberResourceNotFound = true; // don't track known: with a few tool components in place uses 20MB memory and really doesn't help start/etc time much: private static boolean trackKnown = false; private static final Map> commonJavaClassesMap = createCommonJavaClassesMap(); private static Map> createCommonJavaClassesMap() { Map> m = new HashMap<>(); m.put("java.lang.String",java.lang.String.class); m.put("String", java.lang.String.class); m.put("java.lang.CharSequence",java.lang.CharSequence.class); m.put("CharSequence", java.lang.CharSequence.class); m.put("java.sql.Timestamp", java.sql.Timestamp.class); m.put("Timestamp", java.sql.Timestamp.class); m.put("java.sql.Time", java.sql.Time.class); m.put("Time", java.sql.Time.class); m.put("java.sql.Date", java.sql.Date.class); m.put("Date", java.sql.Date.class); m.put("java.util.Locale", Locale.class); m.put("java.util.TimeZone", TimeZone.class); m.put("java.lang.Byte", java.lang.Byte.class); m.put("java.lang.Character", java.lang.Character.class); m.put("java.lang.Integer", java.lang.Integer.class); m.put("Integer", java.lang.Integer.class); m.put("java.lang.Long", java.lang.Long.class); m.put("Long", java.lang.Long.class); m.put("java.lang.Short", java.lang.Short.class); m.put("java.lang.Float", java.lang.Float.class); m.put("Float", java.lang.Float.class); m.put("java.lang.Double", java.lang.Double.class); m.put("Double", java.lang.Double.class); m.put("java.math.BigDecimal", java.math.BigDecimal.class); m.put("BigDecimal", java.math.BigDecimal.class); m.put("java.math.BigInteger", java.math.BigInteger.class); m.put("BigInteger", java.math.BigInteger.class); m.put("java.lang.Boolean", java.lang.Boolean.class); m.put("Boolean", java.lang.Boolean.class); m.put("java.lang.Object", java.lang.Object.class); m.put("Object", java.lang.Object.class); m.put("java.sql.Blob", java.sql.Blob.class); m.put("Blob", java.sql.Blob.class); m.put("java.nio.ByteBuffer", java.nio.ByteBuffer.class); m.put("java.sql.Clob", java.sql.Clob.class); m.put("Clob", java.sql.Clob.class); m.put("java.util.Date", Date.class); m.put("java.util.Collection", Collection.class); m.put("Collection", Collection.class); m.put("java.util.List", List.class); m.put("List", List.class); m.put("java.util.ArrayList", ArrayList.class); m.put("ArrayList", ArrayList.class); m.put("java.util.Map", Map.class); m.put("Map", Map.class); m.put("java.util.HashMap", HashMap.class); m.put("java.util.Set", Set.class); m.put("Set", Set.class); m.put("java.util.HashSet", HashSet.class); m.put("groovy.util.Node", groovy.util.Node.class); m.put("Node", groovy.util.Node.class); m.put("org.moqui.util.MNode", org.moqui.util.MNode.class); m.put("MNode", org.moqui.util.MNode.class); m.put(Boolean.TYPE.getName(), Boolean.TYPE); m.put(Short.TYPE.getName(), Short.TYPE); m.put(Integer.TYPE.getName(), Integer.TYPE); m.put(Long.TYPE.getName(), Long.TYPE); m.put(Float.TYPE.getName(), Float.TYPE); m.put(Double.TYPE.getName(), Double.TYPE); m.put(Byte.TYPE.getName(), Byte.TYPE); m.put(Character.TYPE.getName(), Character.TYPE); m.put("long[]", long[].class); m.put("char[]", char[].class); return m; } public static Class getCommonClass(String className) { return commonJavaClassesMap.get(className); } public static void addCommonClass(String className, Class cls) { commonJavaClassesMap.putIfAbsent(className, cls); } private final ArrayList jarFileList = new ArrayList<>(); private final Map jarLocationByJarName = new HashMap<>(); private final ArrayList classesDirectoryList = new ArrayList<>(); private final Map jarByClass = new HashMap<>(); private final HashMap knownClassFiles = new HashMap<>(); private final HashMap knownClassJarEntries = new HashMap<>(); private static class JarEntryInfo { JarEntry entry; JarFile file; URL jarLocation; JarEntryInfo(JarEntry je, JarFile jf, URL loc) { entry = je; file = jf; jarLocation = loc; } } // This Map contains either a Class or a ClassNotFoundException, cached for fast access because Groovy hits a LOT of // weird invalid class names resulting in expensive new ClassNotFoundException instances private final ConcurrentHashMap classCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap notFoundCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap resourceCache = new ConcurrentHashMap<>(); private final ConcurrentHashMap> resourceAllCache = new ConcurrentHashMap<>(); private final Set resourcesNotFound = new HashSet<>(); private ProtectionDomain pd; public MClassLoader(ClassLoader parent) { super(parent); if (parent == null) throw new IllegalArgumentException("Parent ClassLoader cannot be null"); System.out.println("Starting MClassLoader with parent " + parent.getClass().getName()); pd = getClass().getProtectionDomain(); for (Map.Entry> commonClassEntry: commonJavaClassesMap.entrySet()) classCache.put(commonClassEntry.getKey(), commonClassEntry.getValue()); } public void addJarFile(JarFile jf, URL jarLocation) { jarFileList.add(jf); jarLocationByJarName.put(jf.getName(), jarLocation); String jfName = jf.getName(); Enumeration jeEnum = jf.entries(); while (jeEnum.hasMoreElements()) { JarEntry je = jeEnum.nextElement(); if (je.isDirectory()) continue; String jeName = je.getName(); if (!jeName.endsWith(".class")) continue; String className = jeName.substring(0, jeName.length() - 6).replace('/', '.'); if (classCache.containsKey(className)) { System.out.println("Ignoring duplicate class " + className + " in jar " + jfName); continue; } if (trackKnown) knownClassJarEntries.put(className, new JarEntryInfo(je, jf, jarLocation)); /* NOTE: can't do this as classes are defined out of order, end up with NoClassDefFoundError for dependencies: Class cls = makeClass(className, jf, je); if (cls != null) classCache.put(className, cls); */ if (checkJars) { try { getParent().loadClass(className); System.out.println("Class " + className + " in jar " + jfName + " already loaded from parent ClassLoader"); } catch (ClassNotFoundException e) { /* hoping class is not found! */ } if (jarByClass.containsKey(className)) { System.out.println("Found class " + className + " in \njar " + jfName + ", already loaded from \njar " + jarByClass.get(className)); } else { jarByClass.put(className, jfName); } } } } //List getJarFileList() { return jarFileList; } //Map getClassCache() { return classCache; } //Map getResourceCache() { return resourceCache; } public void addClassesDirectory(File classesDir) { if (!classesDir.exists()) throw new IllegalArgumentException("Classes directory [" + classesDir + "] does not exist."); if (!classesDir.isDirectory()) throw new IllegalArgumentException("Classes directory [" + classesDir + "] is not a directory."); classesDirectoryList.add(classesDir); findClassFiles("", classesDir); } private void findClassFiles(String pathSoFar, File dir) { File[] children = dir.listFiles(); if (children == null) return; String pathSoFarDot = pathSoFar.concat("."); for (int i = 0; i < children.length; i++) { File child = children[i]; String fileName = child.getName(); if (child.isDirectory()) { findClassFiles(pathSoFarDot.concat(fileName), child); } else if (fileName.endsWith(".class")) { String className = pathSoFarDot.concat(fileName.substring(0, fileName.length() - 6)); if (knownClassFiles.containsKey(className)) { System.out.println("Ignoring duplicate class " + className + " at " + child.getPath()); continue; } if (trackKnown) knownClassFiles.put(className, child); /* NOTE: can't do this as classes are defined out of order, end up with NoClassDefFoundError for dependencies: Class cls = makeClass(className, child); if (cls != null) classCache.put(className, cls); */ } } } public void clearNotFoundInfo() { notFoundCache.clear(); resourcesNotFound.clear(); } /** @see java.lang.ClassLoader#getResource(String) */ @Override public URL getResource(String name) { return findResource(name); } /** @see java.lang.ClassLoader#getResources(String) */ @Override public Enumeration getResources(String name) throws IOException { return findResources(name); } /** @see java.lang.ClassLoader#findResource(java.lang.String) */ @Override protected URL findResource(String resourceName) { URL cachedUrl = resourceCache.get(resourceName); if (cachedUrl != null) return cachedUrl; if (rememberResourceNotFound && resourcesNotFound.contains(resourceName)) return null; // Groovy looks for BeanInfo and Customizer groovy resources, even for anonymous scripts and they will never exist if (rememberResourceNotFound) { if ((resourceName.endsWith("BeanInfo.groovy") || resourceName.endsWith("Customizer.groovy")) && (resourceName.startsWith("script") || resourceName.contains("_actions") || resourceName.contains("_condition"))) { resourcesNotFound.add(resourceName); return null; } } URL resourceUrl = null; int classesDirectoryListSize = classesDirectoryList.size(); for (int i = 0; i < classesDirectoryListSize; i++) { File classesDir = classesDirectoryList.get(i); File testFile = new File(classesDir.getAbsolutePath() + "/" + resourceName); try { if (testFile.exists() && testFile.isFile()) resourceUrl = testFile.toURI().toURL(); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in classes directory [" + classesDir + "]: " + e.toString()); } } if (resourceUrl == null) { int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); JarEntry jarEntry = jarFile.getJarEntry(resourceName); if (jarEntry != null) { try { String jarFileName = jarFile.getName(); if (jarFileName.contains("\\")) jarFileName = jarFileName.replace('\\', '/'); resourceUrl = new URL("jar:file:" + jarFileName + "!/" + jarEntry); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in jar [" + jarFile + "]: " + e.toString()); } } } } if (resourceUrl == null) { // NOTE: it is weird for any ClassLoader to throw exceptions for valid resource names, but // org.eclipse.jetty.webapp.WebAppClassLoader does just that as part of // org.eclipse.jetty.webapp.WebAppContext.isServerResource(WebAppContext.java:816) // As a workaround catch that exception, try the grand-parent classloader, and move on... try { resourceUrl = getParent().getResource(resourceName); } catch (Throwable t) { System.out.println("Error in findResource() in parent classloader " + getParent().getClass().getCanonicalName() + " for name [" + resourceName + "]: " + t.toString()); // t.printStackTrace(); // try grand-parent classloader if there is one ClassLoader grandParent = getParent().getParent(); if (grandParent != null) { try { resourceUrl = grandParent.getResource(resourceName); if (resourceUrl != null) System.out.println("Found " + resourceName + " in grand-parent ClassLoader " + grandParent.getClass().getCanonicalName()); } catch (Throwable t2) { System.out.println("Error in findResource() in grand-parent classloader " + grandParent.getClass().getCanonicalName() + " for name [" + resourceName + "]: " + t2.toString()); } } } } if (resourceUrl != null) { // System.out.println("finding resource " + resourceName + " got " + resourceUrl.toExternalForm()); URL existingUrl = resourceCache.putIfAbsent(resourceName, resourceUrl); if (existingUrl != null) return existingUrl; else return resourceUrl; } else { // for testing to see if resource not found cache is working, should see this once for each not found resource // System.out.println("Classpath resource not found with name " + resourceName); if (rememberResourceNotFound) resourcesNotFound.add(resourceName); return null; } } /** @see java.lang.ClassLoader#findResources(java.lang.String) */ @Override public Enumeration findResources(String resourceName) throws IOException { ArrayList cachedUrls = resourceAllCache.get(resourceName); if (cachedUrls != null) return Collections.enumeration(cachedUrls); ArrayList urlList = new ArrayList<>(); int classesDirectoryListSize = classesDirectoryList.size(); for (int i = 0; i < classesDirectoryListSize; i++) { File classesDir = classesDirectoryList.get(i); File testFile = new File(classesDir.getAbsolutePath() + "/" + resourceName); try { if (testFile.exists() && testFile.isFile()) urlList.add(testFile.toURI().toURL()); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in classes directory [" + classesDir + "]: " + e.toString()); } } int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); JarEntry jarEntry = jarFile.getJarEntry(resourceName); if (jarEntry != null) { try { String jarFileName = jarFile.getName(); if (jarFileName.contains("\\")) jarFileName = jarFileName.replace('\\', '/'); urlList.add(new URL("jar:file:" + jarFileName + "!/" + jarEntry)); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in jar [" + jarFile + "]: " + e.toString()); } } } // add all resources found in parent loader too // NOTE: it is weird for any ClassLoader to throw exceptions for valid resource names, but // org.eclipse.jetty.webapp.WebAppClassLoader does just that as part of // org.eclipse.jetty.webapp.WebAppContext.isServerResource(WebAppContext.java:816) // As a workaround catch that exception, try the grand-parent classloader, and move on... try { Enumeration superResources = getParent().getResources(resourceName); while (superResources.hasMoreElements()) urlList.add(superResources.nextElement()); } catch (Throwable t) { System.out.println("Error in findResources() in parent classloader " + getParent().getClass().getCanonicalName() + " for name [" + resourceName + "]: " + t.toString()); // t.printStackTrace(); // try grand-parent classloader if there is one ClassLoader grandParent = getParent().getParent(); if (grandParent != null) { try { Enumeration superResources = grandParent.getResources(resourceName); while (superResources.hasMoreElements()) urlList.add(superResources.nextElement()); } catch (Throwable t2) { System.out.println("Error in findResources() in grand-parent classloader " + grandParent.getClass().getCanonicalName() + " for name [" + resourceName + "]: " + t2.toString()); } } } resourceAllCache.putIfAbsent(resourceName, urlList); // System.out.println("finding all resources with name " + resourceName + " got " + urlList); return Collections.enumeration(urlList); } /** @see java.lang.ClassLoader#getResourceAsStream(String) */ @Override public InputStream getResourceAsStream(String name) { URL resourceUrl = findResource(name); if (resourceUrl == null) { // System.out.println("Classpath resource not found with name " + name); return null; } try { return resourceUrl.openStream(); } catch (IOException e) { System.out.println("Error opening stream for classpath resource " + name + ": " + e.toString()); return null; } } @Override public Class loadClass(String name) throws ClassNotFoundException { return loadClass(name, false); } @Override protected Class loadClass(String className, boolean resolve) throws ClassNotFoundException { Class cachedClass = classCache.get(className); if (cachedClass != null) return cachedClass; if (rememberClassNotFound) { ClassNotFoundException cachedExc = notFoundCache.get(className); if (cachedExc != null) throw cachedExc; } return loadClassInternal(className, resolve); } // private static final ArrayList ignoreSuffixes = new ArrayList<>(Arrays.asList("Customizer", "BeanInfo")); // private static final int ignoreSuffixesSize = ignoreSuffixes.size(); // TODO: does this need synchronized? slows it down... private Class loadClassInternal(String className, boolean resolve) throws ClassNotFoundException { /* This may not be a good idea, Groovy looks for all sorts of bogus class name but there may be a reason so not doing this or looking for other patterns: for (int i = 0; i < ignoreSuffixesSize; i++) { String ignoreSuffix = ignoreSuffixes.get(i); if (className.endsWith(ignoreSuffix)) { ClassNotFoundException cfne = new ClassNotFoundException("Ignoring Groovy style bogus class name " + className); classCache.put(className, cfne); throw cfne; } } */ Class c = null; try { // classes handled opposite of resources, try parent chain first (avoid java.lang.LinkageError) ClassLoader cl = getParent(); int depth = 0; /* When in jetty embedded mode (MoquiStart) first try * org.eclipse.jetty.ee.webapp.WebAppClassLoader * then MoquiStart.StartClassLoader. If in a servlet container, then * use the classloader parents provided by that container. */ while (cl != null && depth < 2) { try { c = cl.loadClass(className); break; } catch (ClassNotFoundException|NoClassDefFoundError e) { cl = cl.getParent(); depth++; } catch (RuntimeException e) { e.printStackTrace(); throw e; } } // now try MClassLoader if parents fail to load if (c == null) { try { if (trackKnown) { File classFile = knownClassFiles.get(className); if (classFile != null) c = makeClass(className, classFile); if (c == null) { JarEntryInfo jei = knownClassJarEntries.get(className); if (jei != null) c = makeClass(className, jei.file, jei.entry, jei.jarLocation); } } // not found in known? search through all c = findJarClass(className); } catch (Exception e) { System.out.println("Error loading class [" + className + "] from additional jars: " + e.toString()); e.printStackTrace(); } } // System.out.println("Loading class name [" + className + "] got class: " + c); if (c == null) { ClassNotFoundException cnfe = new ClassNotFoundException("Class " + className + " not found."); if (rememberClassNotFound) { // Groovy seems to look, then re-look, for funny names like: // groovy.lang.GroovyObject$java$io$org$moqui$entity$EntityListIterator // java.io.org$moqui$entity$EntityListIterator // groovy.util.org$moqui$context$ExecutionContext // org$moqui$context$ExecutionContext // Groovy does similar with *Customizer and *BeanInfo; so just don't remember any of these // In general it seems that anything with a '$' needs to be excluded if (!className.contains("$") && !className.endsWith("Customizer") && !className.endsWith("BeanInfo")) { ClassNotFoundException existingExc = notFoundCache.putIfAbsent(className, cnfe); if (existingExc != null) throw existingExc; } } throw cnfe; } else { classCache.put(className, c); } return c; } finally { if (c != null && resolve) resolveClass(c); } } private ConcurrentHashMap protectionDomainByUrl = new ConcurrentHashMap<>(); private ProtectionDomain getProtectionDomain(URL jarLocation) { ProtectionDomain curPd = protectionDomainByUrl.get(jarLocation); if (curPd != null) return curPd; CodeSource codeSource = new CodeSource(jarLocation, (Certificate[]) null); ProtectionDomain newPd = new ProtectionDomain(codeSource, null, this, null); ProtectionDomain existingPd = protectionDomainByUrl.putIfAbsent(jarLocation, newPd); return existingPd != null ? existingPd : newPd; } private Class makeClass(String className, File classFile) { try { byte[] jeBytes = getFileBytes(classFile); if (jeBytes == null) { System.out.println("Could not get bytes for " + classFile); return null; } return defineClass(className, jeBytes, 0, jeBytes.length, pd); } catch (Throwable t) { System.out.println("Error reading class file " + classFile + ": " + t.toString()); return null; } } private Class makeClass(String className, JarFile file, JarEntry entry, URL jarLocation) { try { definePackage(className, file); byte[] jeBytes = getJarEntryBytes(file, entry); if (jeBytes == null) { System.out.println("Could not get bytes for entry " + entry.getName() + " in jar" + file.getName()); return null; } else { // System.out.println("Loading class " + className + " from " + entry.getName() + " in " + file.getName()); return defineClass(className, jeBytes, 0, jeBytes.length, jarLocation != null ? getProtectionDomain(jarLocation) : pd); } } catch (Throwable t) { System.out.println("Error reading class file " + entry.getName() + " in jar" + file.getName() + ": " + t.toString()); return null; } } @SuppressWarnings("ThrowFromFinallyBlock") private byte[] getJarEntryBytes(JarFile jarFile, JarEntry je) throws IOException { DataInputStream dis = null; byte[] jeBytes = null; try { long lSize = je.getSize(); if (lSize <= 0 || lSize >= Integer.MAX_VALUE) throw new IllegalArgumentException("Size [" + lSize + "] not valid for jar entry [" + je + "]"); jeBytes = new byte[(int) lSize]; InputStream is = jarFile.getInputStream(je); dis = new DataInputStream(is); dis.readFully(jeBytes); } finally { if (dis != null) dis.close(); } return jeBytes; } @SuppressWarnings("ThrowFromFinallyBlock") private byte[] getFileBytes(File classFile) throws IOException { DataInputStream dis = null; byte[] jeBytes = null; try { long lSize = classFile.length(); if (lSize <= 0 || lSize >= Integer.MAX_VALUE) { throw new IllegalArgumentException("Size [" + lSize + "] not valid for classpath file [" + classFile + "]"); } jeBytes = new byte[(int)lSize]; InputStream is = new FileInputStream(classFile); dis = new DataInputStream(is); dis.readFully(jeBytes); } finally { if (dis != null) dis.close(); } return jeBytes; } private Class findJarClass(String className) throws IOException, ClassFormatError, ClassNotFoundException { Class cachedClass = classCache.get(className); if (cachedClass != null) return cachedClass; if (rememberClassNotFound) { ClassNotFoundException cachedExc = notFoundCache.get(className); if (cachedExc != null) throw cachedExc; } Class c = null; String classFileName = className.replace('.', '/').concat(".class"); int classesDirectoryListSize = classesDirectoryList.size(); for (int i = 0; i < classesDirectoryListSize; i++) { File classesDir = classesDirectoryList.get(i); File testFile = new File(classesDir.getAbsolutePath() + "/" + classFileName); if (testFile.exists() && testFile.isFile()) { c = makeClass(className, testFile); if (c != null) break; } } if (c == null) { int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); // System.out.println("Finding class file " + classFileName + " in jar file " + jarFile.getName()); JarEntry jarEntry = jarFile.getJarEntry(classFileName); if (jarEntry != null) { c = makeClass(className, jarFile, jarEntry, jarLocationByJarName.get(jarFile.getName())); break; } } } // down here only cache if found if (c != null) { Class existingClass = classCache.putIfAbsent(className, c); if (existingClass != null) return existingClass; else return c; } else { return null; } } private void definePackage(String className, JarFile jarFile) throws IllegalArgumentException { Manifest mf = null; try { mf = jarFile.getManifest(); } catch (IOException e) { System.out.println("Error getting manifest from " + jarFile.getName() + ": " + e.toString()); } // if no manifest use default if (mf == null) mf = new Manifest(); int dotIndex = className.lastIndexOf('.'); String packageName = dotIndex > 0 ? className.substring(0, dotIndex) : ""; // NOTE: for Java 11 changed getPackage() to getDefinedPackage(), can't do before because getDefinedPackage() doesn't exist in Java 8 if (getDefinedPackage(packageName) == null) { definePackage(packageName, mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_TITLE), mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VERSION), mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VENDOR), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_TITLE), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VENDOR), getSealURL(mf)); } } private URL getSealURL(Manifest mf) { String seal = mf.getMainAttributes().getValue(Attributes.Name.SEALED); if (seal == null) return null; try { return new URL(seal); } catch (MalformedURLException e) { return null; } } } ================================================ FILE: framework/src/main/java/org/moqui/util/MNode.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import freemarker.ext.beans.BeansWrapper; import freemarker.ext.beans.BeansWrapperBuilder; import freemarker.template.*; import groovy.lang.Closure; import groovy.util.Node; import groovy.util.NodeList; import org.moqui.BaseException; import org.moqui.resource.ResourceReference; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.xml.sax.Attributes; import org.xml.sax.InputSource; import org.xml.sax.Locator; import org.xml.sax.XMLReader; import org.xml.sax.helpers.DefaultHandler; import javax.xml.parsers.SAXParserFactory; import java.io.*; import java.nio.file.Files; import java.util.*; import java.util.concurrent.ConcurrentHashMap; import static java.nio.charset.StandardCharsets.UTF_8; /** An alternative to groovy.util.Node with methods more type safe and generally useful in Moqui. */ @SuppressWarnings("unused") public class MNode implements TemplateNodeModel, TemplateSequenceModel, TemplateHashModelEx, AdapterTemplateModel, TemplateScalarModel { protected final static Logger logger = LoggerFactory.getLogger(MNode.class); private static final Version FTL_VERSION = Configuration.VERSION_2_3_34; private final static Map parsedNodeCache = new HashMap<>(); public static void clearParsedNodeCache() { parsedNodeCache.clear(); } /* ========== Factories (XML Parsing) ========== */ public static MNode parse(ResourceReference rr) throws BaseException { if (rr == null || (rr.supportsExists() && !rr.getExists())) return null; String location = rr.getLocation(); MNode cached = parsedNodeCache.get(location); if (cached != null && cached.lastModified >= rr.getLastModified()) return cached; MNode node = parse(location, rr.openStream()); node.lastModified = rr.getLastModified(); if (node.lastModified > 0) parsedNodeCache.put(location, node); return node; } /** Parse from an InputStream and close the stream */ public static MNode parse(String location, InputStream is) throws BaseException { if (is == null) return null; try { return parse(location, new InputSource(new InputStreamReader(is, UTF_8))); } finally { try { is.close(); } catch (IOException e) { logger.error("Error closing XML stream from " + location, e); } } } public static MNode parse(File fl) throws BaseException { if (fl == null || !fl.exists()) return null; String location = fl.getPath(); MNode cached = parsedNodeCache.get(location); if (cached != null && cached.lastModified >= fl.lastModified()) return cached; BufferedReader fr = null; try { fr = Files.newBufferedReader(fl.toPath(), UTF_8); // new FileReader(fl); MNode node = parse(fl.getPath(), new InputSource(fr)); node.lastModified = fl.lastModified(); if (node.lastModified > 0) parsedNodeCache.put(location, node); return node; } catch (Exception e) { throw new BaseException("Error parsing XML file at " + fl.getPath(), e); } finally { try { if (fr != null) fr.close(); } catch (IOException e) { logger.error("Error closing XML file at " + fl.getPath(), e); } } } public static MNode parseText(String location, String text) throws BaseException { if (text == null || text.length() == 0) return null; return parse(location, new InputSource(new StringReader(text))); } public static MNode parse(String location, InputSource isrc) { try { MNodeXmlHandler xmlHandler = new MNodeXmlHandler(false, location); XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader(); reader.setContentHandler(xmlHandler); reader.parse(isrc); return xmlHandler.getRootNode(); } catch (Exception e) { throw new BaseException("Error parsing XML from " + location, e); } } public static MNode parseRootOnly(ResourceReference rr) { InputStream is = rr.openStream(); if (is == null) return null; try { return parseRootOnly(rr.getLocation(), new InputSource(is)); } finally { if (is != null) { try { is.close(); } catch (IOException e) { logger.error("Error closing XML stream from " + rr.getLocation(), e); } } } } public static MNode parseRootOnly(String location, InputSource isrc) { try { MNodeXmlHandler xmlHandler = new MNodeXmlHandler(true, location); XMLReader reader = SAXParserFactory.newInstance().newSAXParser().getXMLReader(); reader.setContentHandler(xmlHandler); reader.parse(isrc); return xmlHandler.getRootNode(); } catch (Exception e) { throw new BaseException("Error parsing XML from " + location, e); } } /* ========== Fields ========== */ private String nodeName; // NOTE: start with small capacity, optimize for memory use vs put overhead to grow as mostly used for config kept long term private final Map attributeMap = new LinkedHashMap<>(4); private MNode parentNode = null; private ArrayList childList = null; private Map> childrenByName = null; private String childText = null; private long lastModified = 0; private boolean systemExpandAttributes = false; private String fileLocation = null; /* ========== Constructors ========== */ public MNode(Node node) { nodeName = (String) node.name(); Set attrEntries = node.attributes().entrySet(); for (Object entryObj : attrEntries) if (entryObj instanceof Map.Entry) { Map.Entry entry = (Map.Entry) entryObj; if (entry.getKey() != null) attributeMap.put(entry.getKey().toString(), entry.getValue() != null ? entry.getValue().toString() : null); } for (Object childObj : node.children()) { if (childObj instanceof Node) { append((Node) childObj); } else if (childObj instanceof NodeList) { NodeList nl = (NodeList) childObj; for (Object nlEntry : nl) { if (nlEntry instanceof Node) { append((Node) nlEntry); } } } } childText = gnodeText(node); if (childText != null && childText.trim().length() == 0) childText = null; // if ("entity".equals(nodeName)) logger.info("Groovy Node:\n" + node + "\n MNode:\n" + toString()); } public MNode(String name, Map attributes, MNode parent, List children, String text) { nodeName = name; if (attributes != null) attributeMap.putAll(attributes); parentNode = parent; if (children != null && children.size() > 0) { childList = new ArrayList<>(); childList.addAll(children); } if (text != null && text.trim().length() > 0) childText = text; } public MNode(String name, Map attributes) { nodeName = name; if (attributes != null) attributeMap.putAll(attributes); } public MNode setFileLocation(String location) { fileLocation = location; return this; } public String getFileLocation() { return fileLocation; } /* ========== Get Methods ========== */ /** If name starts with an ampersand (@) then get an attribute, otherwise get a list of child nodes with the given name. */ public Object getObject(String name) { if (name != null && name.length() > 0 && name.charAt(0) == '@') { return attribute(name.substring(1)); } else { return children(name); } } /** Groovy specific method for square brace syntax */ public Object getAt(String name) { return getObject(name); } public String getName() { return nodeName; } public void setName(String name) { if (parentNode != null && parentNode.childrenByName != null) { parentNode.childrenByName.remove(name); parentNode.childrenByName.remove(nodeName); } nodeName = name; } public Map getAttributes() { return attributeMap; } public String attribute(String attrName) { String attrValue = attributeMap.get(attrName); if (systemExpandAttributes && attrValue != null && attrValue.contains("${")) { attrValue = SystemBinding.expand(attrValue); // system properties and environment variables don't generally change once initial init is done, so save expanded value attributeMap.put(attrName, attrValue); } return attrValue; } public void setSystemExpandAttributes(boolean b) { systemExpandAttributes = b; } public MNode getParent() { return parentNode; } public boolean hasAncestor(String nodeName) { if (parentNode == null) return false; if (nodeName == null || nodeName.isEmpty() || nodeName.equals(parentNode.nodeName)) return true; return parentNode.hasAncestor(nodeName); } public ArrayList getChildren() { if (childList == null) childList = new ArrayList<>(); return childList; } public ArrayList children(String name) { if (childList == null) childList = new ArrayList<>(4); if (childrenByName == null) childrenByName = new HashMap<>(4); if (name == null) return childList; ArrayList curList = childrenByName.get(name); if (curList != null) return curList; curList = new ArrayList<>(); int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (name.equals(curChild.nodeName)) curList.add(curChild); } childrenByName.put(name, curList); return curList; } public ArrayList children(String name, String... attrNamesValues) { int attrNvLength = attrNamesValues.length; if (attrNvLength % 2 != 0) throw new IllegalArgumentException("Must pass an even number of attribute name/value strings"); ArrayList fullList = children(name); ArrayList filteredList = new ArrayList<>(); int fullListSize = fullList.size(); for (int i = 0; i < fullListSize; i++) { MNode node = fullList.get(i); boolean allEqual = true; for (int j = 0; j < attrNvLength; j += 2) { String attrValue = node.attribute(attrNamesValues[j]); String argValue = attrNamesValues[j+1]; if (attrValue == null) { if (argValue != null) { allEqual = false; break; } } else { if (!attrValue.equals(argValue)) { allEqual = false; break; } } } if (allEqual) filteredList.add(node); } return filteredList; } public ArrayList children(Closure condition) { ArrayList curList = new ArrayList<>(); if (childList == null) return curList; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (condition == null || condition.call(curChild)) curList.add(curChild); } return curList; } public boolean hasChild(String name) { if (childList == null) return false; if (name == null) return false; if (childrenByName != null) { ArrayList curList = childrenByName.get(name); if (curList != null && curList.size() > 0) return true; } int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (name.equals(curChild.nodeName)) return true; } return false; } /** Get child at index, will throw an exception if index out of bounds */ public MNode child(int index) { return childList.get(index); } public Map> getChildrenByName() { Map> allByName = new HashMap<>(4); if (childList == null) return allByName; int childListSize = childList.size(); if (childListSize == 0) return allByName; if (childrenByName == null) childrenByName = new HashMap<>(4); ArrayList newChildNames = new ArrayList<>(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); String name = curChild.nodeName; ArrayList existingList = childrenByName.get(name); if (existingList != null) { if (existingList.size() > 0 && !allByName.containsKey(name)) allByName.put(name, existingList); continue; } ArrayList curList = allByName.get(name); if (curList == null) { curList = new ArrayList<>(); allByName.put(name, curList); newChildNames.add(name); } curList.add(curChild); } // since we got all children by name save them for future use int newChildNamesSize = newChildNames.size(); for (int i = 0; i < newChildNamesSize; i++) { String newChildName = newChildNames.get(i); childrenByName.put(newChildName, allByName.get(newChildName)); } childrenByName.putAll(allByName); return allByName; } /** Search all descendants for nodes matching any of the names, return a Map with a List for each name with nodes * found or empty List if no nodes found */ public Map> descendants(Set names) { Map> nodes = new HashMap<>(names.size()); for (String name : names) nodes.put(name, new ArrayList<>()); descendants(names, nodes); return nodes; } public void descendants(Set names, Map> nodes) { if (childList == null) return; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (names == null || names.contains(curChild.nodeName)) { ArrayList curList = nodes.get(curChild.nodeName); if (curList == null) { curList = new ArrayList<>(); nodes.put(curChild.nodeName, curList); } curList.add(curChild); } curChild.descendants(names, nodes); } } public ArrayList descendants(String name) { ArrayList nodes = new ArrayList<>(); descendantsInternal(name, nodes); return nodes; } private void descendantsInternal(String name, ArrayList nodes) { if (childList == null) return; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (name == null || name.equals(curChild.nodeName)) { nodes.add(curChild); } curChild.descendantsInternal(name, nodes); } } public ArrayList depthFirst(Closure condition) { ArrayList curList = new ArrayList<>(); depthFirstInternal(condition, curList); return curList; } private void depthFirstInternal(Closure condition, ArrayList curList) { if (childList == null) return; int childListSize = childList.size(); // all grand-children first for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); curChild.depthFirstInternal(condition, curList); } // then children for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (condition == null || condition.call(curChild)) curList.add(curChild); } } public ArrayList breadthFirst(Closure condition) { ArrayList curList = new ArrayList<>(); breadthFirstInternal(condition, curList); return curList; } private void breadthFirstInternal(Closure condition, ArrayList curList) { if (childList == null) return; int childListSize = childList.size(); // direct children first for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (condition == null || condition.call(curChild)) curList.add(curChild); } // then grand-children for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); curChild.breadthFirstInternal(condition, curList); } } /** Get the first child node */ public MNode first() { if (childList == null) return null; return childList.size() > 0 ? childList.get(0) : null; } /** Get the first child node with the given name */ public MNode first(String name) { if (childList == null) return null; if (name == null) return first(); ArrayList nameChildren = children(name); if (nameChildren.size() > 0) return nameChildren.get(0); return null; /* with cache in children(name) that is faster than searching every time here: int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (name.equals(curChild.nodeName)) return curChild; } return null; */ } public MNode first(String name, String... attrNamesValues) { if (childList == null) return null; if (name == null) return first(); ArrayList nameChildren = children(name, attrNamesValues); if (nameChildren.size() > 0) return nameChildren.get(0); return null; } public MNode first(Closure condition) { if (childList == null) return null; if (condition == null) return first(); int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (condition.call(curChild)) return curChild; } return null; } public int firstIndex(String name) { if (childList == null) return -1; if (name == null) return childList.size() - 1; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (name.equals(curChild.getName())) return i; } return -1; } public int firstIndex(Closure condition) { if (childList == null) return -1; if (condition == null) return childList.size() - 1; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (condition.call(curChild)) return i; } return -1; } public int firstIndex(MNode child) { if (childList == null || child == null) return -1; int childListSize = childList.size(); // first find match by identity (same object), most reliable match for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (child == curChild) return i; } // if not found find by node name and attributes for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (!child.getName().equals(curChild.getName())) continue; if (child.getAttributes().equals(curChild.getAttributes())) return i; } return -1; } public String getText() { return childText; } public MNode deepCopy(MNode parent) { MNode newNode = new MNode(nodeName, attributeMap, parent, null, childText); if (fileLocation != null) newNode.fileLocation = fileLocation; if (childList != null) { int childListSize = childList.size(); if (childListSize > 0) { newNode.childList = new ArrayList<>(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); newNode.childList.add(curChild.deepCopy(newNode)); } } } // if ("entity".equals(nodeName)) logger.info("Original MNode:\n" + this.toString() + "\n Clone MNode:\n" + newNode.toString()); return newNode; } /* ========== Child Modify Methods ========== */ public void append(MNode child) { if (childrenByName != null) childrenByName.remove(child.nodeName); if (childList == null) childList = new ArrayList<>(); childList.add(child); child.parentNode = this; } public void append(MNode child, int index) { if (childrenByName != null) childrenByName.remove(child.nodeName); if (childList == null) childList = new ArrayList<>(); if (index > childList.size()) index = childList.size(); childList.add(index, child); child.parentNode = this; } public MNode append(Node child) { MNode newNode = new MNode(child); append(newNode); return newNode; } public MNode append(String name, Map attributes, List children, String text) { MNode newNode = new MNode(name, attributes, this, children, text); append(newNode); return newNode; } public MNode append(String name, Map attributes) { MNode newNode = new MNode(name, attributes, this, null, null); append(newNode); return newNode; } /** Append nodes to end of current child nodes, optionally clone */ public void appendAll(List children, boolean clone) { for (MNode child : children) { append(clone ? child.deepCopy(this) : child); } } /** Append child nodes at given index, optionally clone */ public void appendAll(List children, int index, boolean clone) { int insertIdx = index; for (MNode child : children) { append(clone ? child.deepCopy(this) : child, insertIdx); insertIdx++; } } public MNode replace(int index, MNode child) { if (childList == null || childList.size() < index) throw new IllegalArgumentException("Index " + index + " not valid, size is " + (childList == null ? 0 : childList.size())); return childList.set(index, child); } public MNode replace(int index, String name, Map attributes) { if (childList == null || childList.size() < index) throw new IllegalArgumentException("Index " + index + " not valid, size is " + (childList == null ? 0 : childList.size())); MNode newNode = new MNode(name, attributes, this, null, null); childList.set(index, newNode); return newNode; } /** Remove the child at the given index */ public void remove(int index) { if (childList == null || childList.size() < index) throw new IllegalArgumentException("Index " + index + " not valid, size is " + (childList == null ? 0 : childList.size())); childList.remove(index); } /** Remove children matching the node/element name */ public boolean remove(String name) { if (childrenByName != null) childrenByName.remove(name); if (childList == null) return false; boolean removed = false; for (int i = 0; i < childList.size(); ) { MNode curChild = childList.get(i); if (curChild.nodeName.equals(name)) { childList.remove(i); removed = true; } else { i++; } } return removed; } /** Remove children where Closure evaluates to true */ public boolean remove(Closure condition) { if (childList == null) return false; boolean removed = false; for (int i = 0; i < childList.size(); ) { MNode curChild = childList.get(i); if (condition.call(curChild)) { if (childrenByName != null) childrenByName.remove(curChild.nodeName); childList.remove(i); removed = true; } else { i++; } } return removed; } /** Remove all children */ public boolean removeAll() { if (childList == null || childList.size() == 0) return false; childList.clear(); if (childrenByName != null) childrenByName.clear(); return true; } /** Merge a single child node with the given name from overrideNode if it has a child with that name. * * If this node has a child with the same name copies/overwrites attributes from the overrideNode's child and if * overrideNode's child has children the children of this node's child will be replaced by them. * * Otherwise appends a copy of the override child as a child of the current node. */ public void mergeSingleChild(MNode overrideNode, String childNodeName) { MNode childOverrideNode = overrideNode.first(childNodeName); if (childOverrideNode == null) return; MNode childBaseNode = first(childNodeName); if (childBaseNode != null) { childBaseNode.attributeMap.putAll(childOverrideNode.attributeMap); if (childOverrideNode.childList != null && childOverrideNode.childList.size() > 0) { if (childBaseNode.childList != null) { if (childBaseNode.childrenByName != null) childBaseNode.childrenByName.clear(); childBaseNode.childList.clear(); } else { childBaseNode.childList = new ArrayList<>(); } ArrayList conChildList = childOverrideNode.childList; int conChildListSize = conChildList.size(); for (int i = 0; i < conChildListSize; i++) { MNode grandchild = conChildList.get(i); childBaseNode.childList.add(grandchild.deepCopy(childBaseNode)); } } } else { if (childrenByName != null) childrenByName.remove(childOverrideNode.nodeName); if (childList == null) childList = new ArrayList<>(); childList.add(childOverrideNode.deepCopy(this)); } } public void mergeChildWithChildKey(MNode overrideNode, String childName, String grandchildName, String keyAttributeName, Closure grandchildMerger) { MNode overrideChildNode = overrideNode.first(childName); if (overrideChildNode == null) return; MNode baseChildNode = first(childName); if (baseChildNode != null) { baseChildNode.mergeNodeWithChildKey(overrideChildNode, grandchildName, keyAttributeName, grandchildMerger); } else { if (childrenByName != null) childrenByName.remove(overrideChildNode.nodeName); if (childList == null) childList = new ArrayList<>(); childList.add(overrideChildNode.deepCopy(this)); } } /** Merge attributes and child nodes from overrideNode into this node, matching on childNodesName and optionally the value of the * attribute in each named by keyAttributeName. * * Always copies/overwrites attributes from override child node, and merges their child nodes using childMerger or * if null the default merge of removing all children under the child of this node and appending copies of the * children of the override child node. */ public void mergeNodeWithChildKey(MNode overrideNode, String childNodesName, String keyAttributeName, Closure childMerger) { if (overrideNode == null) throw new IllegalArgumentException("No overrideNode specified in call to mergeNodeWithChildKey"); if (childNodesName == null || childNodesName.length() == 0) throw new IllegalArgumentException("No childNodesName specified in call to mergeNodeWithChildKey"); // override attributes for this node attributeMap.putAll(overrideNode.attributeMap); mergeChildrenByKey(overrideNode, childNodesName, keyAttributeName, childMerger); } public void mergeChildrenByKey(MNode overrideNode, String childNodesName, String keyAttributeName, Closure childMerger) { if (keyAttributeName == null || keyAttributeName.isEmpty()) { mergeChildrenByKeys(overrideNode, childNodesName, childMerger); } else { mergeChildrenByKeys(overrideNode, childNodesName, childMerger, keyAttributeName); } } public void mergeChildrenByKeys(MNode overrideNode, String childNodesName, Closure childMerger, String... keyAttributeNames) { if (overrideNode == null) throw new IllegalArgumentException("No overrideNode specified in call to mergeChildrenByKey"); if (childNodesName == null || childNodesName.length() == 0) throw new IllegalArgumentException("No childNodesName specified in call to mergeChildrenByKey"); if (childList == null) childList = new ArrayList<>(); ArrayList overrideChildren = overrideNode.children(childNodesName); int overrideChildrenSize = overrideChildren.size(); for (int curOc = 0; curOc < overrideChildrenSize; curOc++) { MNode childOverrideNode = overrideChildren.get(curOc); String[] keyAttributeValues = null; if (keyAttributeNames != null && keyAttributeNames.length > 0) { keyAttributeValues = new String[keyAttributeNames.length]; boolean skipChild = false; for (int ai = 0; ai < keyAttributeNames.length; ai++) { String keyValue = childOverrideNode.attribute(keyAttributeNames[ai]); // if we have a keyAttributeName but no keyValue for this child node, skip it if (keyValue == null || keyValue.length() == 0) { skipChild = true; continue; } keyAttributeValues[ai] = keyValue; } if (skipChild) continue; } MNode childBaseNode = null; int childListSize = childList.size(); for (int i = 0; i < childListSize; i++) { MNode curChild = childList.get(i); if (!curChild.getName().equals(childNodesName)) continue; if (keyAttributeNames == null || keyAttributeNames.length == 0) { childBaseNode = curChild; break; } boolean allMatch = true; for (int ai = 0; ai < keyAttributeNames.length; ai++) { String keyValue = keyAttributeValues[ai]; if (!keyValue.equals(curChild.attribute(keyAttributeNames[ai]))) allMatch = false; } if (allMatch) { childBaseNode = curChild; break; } } if (childBaseNode != null) { // merge the node attributes childBaseNode.attributeMap.putAll(childOverrideNode.attributeMap); if (childMerger != null) { childMerger.call(childBaseNode, childOverrideNode); } else { // do the default child merge: remove current nodes children and replace with a copy of the override node's children if (childBaseNode.childList != null) { if (childBaseNode.childrenByName != null) childBaseNode.childrenByName.clear(); childBaseNode.childList.clear(); } else { childBaseNode.childList = new ArrayList<>(); } ArrayList conChildList = childOverrideNode.childList; int conChildListSize = conChildList != null ? conChildList.size() : 0; for (int i = 0; i < conChildListSize; i++) { MNode grandchild = conChildList.get(i); childBaseNode.childList.add(grandchild.deepCopy(childBaseNode)); } } } else { // no matching child base node, so add a new one append(childOverrideNode.deepCopy(this)); } } } /* ========== String Methods ========== */ @Override public String toString() { StringBuilder sb = new StringBuilder(); addToSb(sb, 0); return sb.toString(); } private void addToSb(StringBuilder sb, int level) { for (int i = 0; i < level; i++) sb.append(" "); sb.append('<').append(nodeName); for (Map.Entry entry : attributeMap.entrySet()) sb.append(' ').append(entry.getKey()).append("=\"").append(entry.getValue()).append("\""); if ((childText != null && childText.length() > 0) || (childList != null && childList.size() > 0)) { sb.append(">"); if (childText != null) sb.append(""); if (childList != null && childList.size() > 0) { for (MNode child : childList) { sb.append('\n'); child.addToSb(sb, level + 1); } if (childList.size() > 1) { sb.append("\n"); for (int i = 0; i < level; i++) sb.append(" "); } } sb.append("'); } else { sb.append("/>"); } } private static String gnodeText(Object nodeObj) { if (nodeObj == null) return ""; Node theNode = null; if (nodeObj instanceof Node) { theNode = (Node) nodeObj; } else if (nodeObj instanceof NodeList) { NodeList nl = (NodeList) nodeObj; if (nl.size() > 0) theNode = (Node) nl.get(0); } if (theNode == null) return ""; List textList = theNode.localText(); if (textList != null) { if (textList.size() == 1) { return textList.get(0); } else { StringBuilder sb = new StringBuilder(); for (String txt : textList) sb.append(txt).append("\n"); return sb.toString(); } } else { return ""; } } private static class MNodeXmlHandler extends DefaultHandler { Locator locator = null; long nodesRead = 0; MNode rootNode = null; MNode curNode = null; StringBuilder curText = null; final boolean rootOnly; String fileLocation = null; private boolean stopParse = false; MNodeXmlHandler(boolean rootOnly, String fileLocation) { this.rootOnly = rootOnly; this.fileLocation = fileLocation; } MNode getRootNode() { return rootNode; } long getNodesRead() { return nodesRead; } @Override public void startElement(String ns, String localName, String qName, Attributes attributes) { if (stopParse) return; // logger.info("startElement ns [${ns}], localName [${localName}] qName [${qName}]") if (curNode == null) { curNode = new MNode(qName, null); if (rootNode == null) rootNode = curNode; } else { curNode = curNode.append(qName, null); } if (fileLocation != null) curNode.setFileLocation(fileLocation); int length = attributes.getLength(); for (int i = 0; i < length; i++) { String name = attributes.getLocalName(i); String value = attributes.getValue(i); if (name == null || name.length() == 0) name = attributes.getQName(i); curNode.attributeMap.put(name, value); } if (rootOnly) stopParse = true; } @Override public void characters(char[] chars, int offset, int length) { if (stopParse) return; if (curText == null) curText = new StringBuilder(); curText.append(chars, offset, length); } @Override public void endElement(String ns, String localName, String qName) { if (stopParse) return; if (!qName.equals(curNode.nodeName)) throw new IllegalStateException("Invalid close element " + qName + ", was expecting " + curNode.nodeName); if (curText != null) { String curString = curText.toString().trim(); if (curString.length() > 0) curNode.childText = curString; } curNode = curNode.parentNode; curText = null; } @Override public void setDocumentLocator(Locator locator) { this.locator = locator; } } /* ============================================================== */ /* ========== FreeMarker (FTL) Fields Methods, Classes ========== */ /* ============================================================== */ private static final BeansWrapper wrapper = new BeansWrapperBuilder(FTL_VERSION).build(); private static final FtlNodeListWrapper emptyNodeListWrapper = new FtlNodeListWrapper(new ArrayList<>(), null); private FtlNodeListWrapper allChildren = null; private ConcurrentHashMap ftlAttrAndChildren = null; private ConcurrentHashMap knownNullAttributes = null; public Object getAdaptedObject(Class aClass) { return this; } // TemplateHashModel methods @Override public TemplateModel get(String s) { if (s == null) return null; // first try the attribute and children caches, then if not found in either pick it apart and create what is needed ConcurrentHashMap localAttrAndChildren = ftlAttrAndChildren != null ? ftlAttrAndChildren : makeAttrAndChildrenByName(); TemplateModel attrOrChildWrapper = localAttrAndChildren.get(s); if (attrOrChildWrapper != null) return attrOrChildWrapper; if (knownNullAttributes != null && knownNullAttributes.containsKey(s)) return null; // at this point we got a null value but attributes and child nodes were pre-loaded so return null or empty list if (s.startsWith("@")) { if ("@@text".equals(s)) { // if we got this once will get it again so add @@text always, always want wrapper though may be null FtlTextWrapper textWrapper = new FtlTextWrapper(childText, this); localAttrAndChildren.putIfAbsent("@@text", textWrapper); return localAttrAndChildren.get("@@text"); // TODO: handle other special hashes? (see http://www.freemarker.org/docs/xgui_imperative_formal.html) } else { String attrName = s.substring(1, s.length()); String attrValue = attributeMap.get(attrName); if (attrValue == null) { if (knownNullAttributes == null) knownNullAttributes = new ConcurrentHashMap<>(); knownNullAttributes.put(s, Boolean.TRUE); return null; } else { FtlAttributeWrapper attrWrapper = new FtlAttributeWrapper(attrName, attrValue, this); TemplateModel existingAttr = localAttrAndChildren.putIfAbsent(s, attrWrapper); if (existingAttr != null) return existingAttr; return attrWrapper; } } } else { if (hasChild(s)) { FtlNodeListWrapper nodeListWrapper = new FtlNodeListWrapper(children(s), this); TemplateModel existingNodeList = localAttrAndChildren.putIfAbsent(s, nodeListWrapper); if (existingNodeList != null) return existingNodeList; return nodeListWrapper; } else { return emptyNodeListWrapper; } } } private synchronized ConcurrentHashMap makeAttrAndChildrenByName() { if (ftlAttrAndChildren == null) ftlAttrAndChildren = new ConcurrentHashMap<>(); return ftlAttrAndChildren; } @Override public boolean isEmpty() { return attributeMap.isEmpty() && (childList == null || childList.isEmpty()) && (childText == null || childText.length() == 0); } // TemplateHashModelEx methods @Override public TemplateCollectionModel keys() throws TemplateModelException { return new SimpleCollection(attributeMap.keySet(), wrapper); } @Override public TemplateCollectionModel values() throws TemplateModelException { return new SimpleCollection(attributeMap.values(), wrapper); } // TemplateNodeModel methods @Override public TemplateNodeModel getParentNode() { return parentNode; } @Override public TemplateSequenceModel getChildNodes() { return this; } @Override public String getNodeName() { return getName(); } @Override public String getNodeType() { return "element"; } @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */ // TemplateSequenceModel methods @Override public TemplateModel get(int i) { if (allChildren == null) return getSequenceList().get(i); return allChildren.get(i); } @Override public int size() { if (allChildren == null) return getSequenceList().size(); return allChildren.size(); } private FtlNodeListWrapper getSequenceList() { // Looks like attributes should NOT go in the FTL children list, so just use the node.children() if (allChildren == null) allChildren = (childText != null && childText.length() > 0) ? new FtlNodeListWrapper(childText, this) : new FtlNodeListWrapper(childList, this); return allChildren; } // TemplateScalarModel methods @Override public String getAsString() { return childText != null ? childText : ""; } private static class FtlAttributeWrapper implements TemplateNodeModel, TemplateSequenceModel, AdapterTemplateModel, TemplateScalarModel { protected String key; protected String value; MNode parentNode; FtlAttributeWrapper(String key, String value, MNode parentNode) { this.key = key; this.value = value; this.parentNode = parentNode; } @Override public Object getAdaptedObject(Class aClass) { return value; } // TemplateNodeModel methods @Override public TemplateNodeModel getParentNode() { return parentNode; } @Override public TemplateSequenceModel getChildNodes() { return this; } @Override public String getNodeName() { return key; } @Override public String getNodeType() { return "attribute"; } @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */ // TemplateSequenceModel methods @Override public TemplateModel get(int i) { if (i == 0) try { return wrapper.wrap(value); } catch (TemplateModelException e) { throw new BaseException("Error wrapping object for FreeMarker", e); } throw new IndexOutOfBoundsException("Attribute node only has 1 value. Tried to get index [${i}] for attribute [${key}]"); } @Override public int size() { return 1; } // TemplateScalarModel methods @Override public String getAsString() { return value; } @Override public String toString() { return value; } } private static class FtlTextWrapper implements TemplateNodeModel, TemplateSequenceModel, AdapterTemplateModel, TemplateScalarModel { protected String text; MNode parentNode; FtlTextWrapper(String text, MNode parentNode) { this.text = text; this.parentNode = parentNode; } @Override public Object getAdaptedObject(Class aClass) { return text; } // TemplateNodeModel methods @Override public TemplateNodeModel getParentNode() { return parentNode; } @Override public TemplateSequenceModel getChildNodes() { return this; } @Override public String getNodeName() { return "@text"; } @Override public String getNodeType() { return "text"; } @Override public String getNodeNamespace() { return null; } /* Namespace not supported for now. */ // TemplateSequenceModel methods @Override public TemplateModel get(int i) { if (i == 0) try { return wrapper.wrap(getAsString()); } catch (TemplateModelException e) { throw new BaseException("Error wrapping object for FreeMarker", e); } throw new IndexOutOfBoundsException("Text node only has 1 value. Tried to get index [${i}]"); } @Override public int size() { return 1; } // TemplateScalarModel methods @Override public String getAsString() { return text != null ? text : ""; } @Override public String toString() { return getAsString(); } } private static class FtlNodeListWrapper implements TemplateSequenceModel { ArrayList nodeList = new ArrayList<>(); FtlNodeListWrapper(ArrayList mnodeList, MNode parentNode) { if (mnodeList != null) nodeList.addAll(mnodeList); } FtlNodeListWrapper(String text, MNode parentNode) { nodeList.add(new FtlTextWrapper(text, parentNode)); } @Override public TemplateModel get(int i) { return nodeList.get(i); } @Override public int size() { return nodeList.size(); } @Override public String toString() { return nodeList.toString(); } } } ================================================ FILE: framework/src/main/java/org/moqui/util/ObjectUtilities.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.moqui.BaseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.beans.BeanInfo; import java.beans.IntrospectionException; import java.beans.Introspector; import java.beans.PropertyDescriptor; import java.io.*; import java.lang.reflect.Field; import java.lang.reflect.InvocationTargetException; import java.lang.reflect.Method; import java.math.BigDecimal; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.sql.Date; import java.sql.Time; import java.sql.Timestamp; import java.time.temporal.ChronoUnit; import java.time.temporal.TemporalUnit; import java.util.*; import java.util.regex.Pattern; /** * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are * stupid but necessary for certain things. */ @SuppressWarnings("unused") public class ObjectUtilities { protected static final Logger logger = LoggerFactory.getLogger(ObjectUtilities.class); public static final Map calendarFieldByUomId; public static final Map temporalUnitByUomId; static { HashMap cfm = new HashMap<>(8); cfm.put("TF_ms", Calendar.MILLISECOND); cfm.put("TF_s", Calendar.SECOND); cfm.put("TF_min", Calendar.MINUTE); cfm.put("TF_hr", Calendar.HOUR); cfm.put("TF_day", Calendar.DAY_OF_MONTH); cfm.put("TF_wk", Calendar.WEEK_OF_YEAR); cfm.put("TF_mon", Calendar.MONTH); cfm.put("TF_yr", Calendar.YEAR); calendarFieldByUomId = cfm; HashMap tum = new HashMap<>(8); tum.put("TF_ms", ChronoUnit.MILLIS); tum.put("TF_s", ChronoUnit.SECONDS); tum.put("TF_min", ChronoUnit.MINUTES); tum.put("TF_hr", ChronoUnit.HOURS); tum.put("TF_day", ChronoUnit.DAYS); tum.put("TF_wk", ChronoUnit.WEEKS); tum.put("TF_mon", ChronoUnit.MONTHS); tum.put("TF_yr", ChronoUnit.YEARS); temporalUnitByUomId = tum; } /** Populate a Map with public fields and Java Bean style properties (using java.beans.BeanInfo) */ public static Map objectToMap(Object bean) { if (bean == null) return null; Map map = new HashMap<>(); Class clazz = bean.getClass(); Field[] fields = clazz.getFields(); for (int fi = 0; fi < fields.length; fi++) { Field field = fields[fi]; try { map.put(field.getName(), field.get(bean)); } catch (IllegalAccessException e) { // do nothing, maybe log at some point if we care enough and are okay with the potential performance hit } } /* commenting for now, seems to call a bunch of undesired methods, will need some work to filter them out: try { BeanInfo beanInfo = Introspector.getBeanInfo(clazz); PropertyDescriptor[] propertyDescriptors = beanInfo.getPropertyDescriptors(); for (int pi = 0; pi < propertyDescriptors.length; pi++) { PropertyDescriptor propertyDescriptor = propertyDescriptors[pi]; Method readMethod = propertyDescriptor.getReadMethod(); if (readMethod != null) { try { map.put(propertyDescriptor.getName(), readMethod.invoke(bean)); } catch (IllegalAccessException | InvocationTargetException e) { // nothing again } } } } catch (IntrospectionException e) { // nothing again } */ // this gets picked up automatically, just remove at the end, faster than checking along the way map.remove("class"); return map; } @SuppressWarnings("unchecked") public static Object basicConvert(Object value, final String javaType) { if (value == null) return null; Class theClass = MClassLoader.getCommonClass(javaType); // only support the classes we have pre-configured if (theClass == null) return value; boolean origString = false; if (value instanceof CharSequence) { value = value.toString(); origString = true; } Class origClass = origString ? String.class : value.getClass(); if (origClass.equals(theClass)) return value; try { if (origString) { if (theClass == Boolean.class) { // for non-empty String to Boolean don't use normal not-empty rules, look for "true", "false", etc String valStr = value.toString(); return "true".equalsIgnoreCase(valStr) || "y".equalsIgnoreCase(valStr); } else if (theClass == Integer.class) { // groovy does funny things with single character strings, ie gets the int value of the single char, so do it ourselves return Integer.valueOf(value.toString()); } else if (theClass == Long.class) { return Long.valueOf(value.toString()); } else if (theClass == BigDecimal.class) { return new BigDecimal(value.toString()); } } if (theClass == Date.class && value instanceof Timestamp) { // Groovy doesn't handle this one, but easy conversion return new Date(((Timestamp) value).getTime()); } else { // let groovy do the work // logger.warn("Converted " + value + " of type " + origClass.getName() + " to " + DefaultGroovyMethods.asType(value, theClass) + " for class " + theClass.getName()); return DefaultGroovyMethods.asType(value, theClass); } } catch (Throwable t) { logger.warn("Error doing type conversion to " + javaType + " for value [" + value + "]", t); return value; } } public static boolean compareLike(Object value1, Object value2) { // nothing to be like? consider a match if (value2 == null) return true; // something to be like but nothing to compare? consider a mismatch if (value1 == null) return false; if (value1 instanceof CharSequence && value2 instanceof CharSequence) { // first escape the characters that would be interpreted as part of the regular expression int length2 = ((CharSequence) value2).length(); StringBuilder sb = new StringBuilder(length2 * 2); for (int i = 0; i < length2; i++) { char c = ((CharSequence) value2).charAt(i); if ("[](){}.*+?$^|#\\".indexOf(c) != -1) sb.append('\\'); sb.append(c); } // change the SQL wildcards to regex wildcards String regex = sb.toString().replace("_", ".").replace("%", ".*?"); // run it... Pattern pattern = Pattern.compile(regex, (Pattern.CASE_INSENSITIVE | Pattern.DOTALL)); return pattern.matcher(value1.toString()).matches(); } else { return false; } } @SuppressWarnings("unchecked") public static boolean compare(Object field, final String operator, final String value, Object toField, String format, final String type) { if (isEmpty(toField)) toField = value; // FUTURE handle type conversion with format for Date, Time, Timestamp // if (format) { } field = basicConvert(field, type); toField = basicConvert(toField, type); boolean result; if ("less".equals(operator)) { result = compareObj(field, toField) < 0; } else if ("greater".equals(operator)) { result = compareObj(field, toField) > 0; } else if ("less-equals".equals(operator)) { result = compareObj(field, toField) <= 0; } else if ("greater-equals".equals(operator)) { result = compareObj(field, toField) >= 0; } else if ("contains".equals(operator)) { result = Objects.toString(field).contains(Objects.toString(toField)); } else if ("not-contains".equals(operator)) { result = !Objects.toString(field).contains(Objects.toString(toField)); } else if ("empty".equals(operator)) { result = isEmpty(field); } else if ("not-empty".equals(operator)) { result = !isEmpty(field); } else if ("matches".equals(operator)) { result = Objects.toString(field).matches(toField.toString()); } else if ("not-matches".equals(operator)) { result = !Objects.toString(field).matches(toField.toString()); } else if ("not-equals".equals(operator)) { result = !Objects.equals(field, toField); } else { result = Objects.equals(field, toField); } if (logger.isTraceEnabled()) logger.trace("Compare result [" + result + "] for field [" + field + "] operator [" + operator + "] value [" + value + "] toField [" + toField + "] type [" + type + "]"); return result; } @SuppressWarnings("unchecked") public static int compareObj(Object field1, Object field2) { if (field1 == null) { if (field2 == null) return 0; else return 1; } Comparable comp1 = makeComparable(field1); Comparable comp2 = makeComparable(field2); return comp1.compareTo(comp2); } public static Comparable makeComparable(final Object obj) { if (obj == null) return null; if (obj instanceof Comparable) return (Comparable) obj; else throw new IllegalArgumentException("Object of type " + obj.getClass().getName() + " is not Comparable, cannot compare"); } public static int countChars(String s, boolean countDigits, boolean countLetters, boolean countOthers) { // this seems like it should be part of some standard Java API, but I haven't found it // (can use Pattern/Matcher, but that is even uglier and probably a lot slower) int count = 0; for (char c : s.toCharArray()) { if (Character.isDigit(c)) { if (countDigits) count++; } else if (Character.isLetter(c)) { if (countLetters) count++; } else { if (countOthers) count++; } } return count; } public static int countChars(String s, char cMatch) { int count = 0; for (char c : s.toCharArray()) if (c == cMatch) count++; return count; } public static String getStreamText(InputStream is) { return getStreamText(is, StandardCharsets.UTF_8); } public static String getStreamText(InputStream is, Charset charset) { if (is == null) return null; Reader r = null; try { r = new InputStreamReader(new BufferedInputStream(is), charset); StringBuilder sb = new StringBuilder(); char[] buf = new char[4096]; int i; while ((i = r.read(buf, 0, 4096)) > 0) sb.append(buf, 0, i); return sb.toString(); } catch (IOException e) { throw new BaseException("Error getting stream text", e); } finally { try { if (r != null) r.close(); } catch (IOException e) { logger.warn("Error in close after reading text from stream", e); } } } public static int copyStream(InputStream is, OutputStream os) { byte[] buffer = new byte[4096]; int totalLen = 0; try { int len = is.read(buffer); while (len != -1) { totalLen += len; os.write(buffer, 0, len); len = is.read(buffer); if (Thread.interrupted()) break; } return totalLen; } catch (IOException e) { throw new BaseException("Error copying stream", e); } } public static String toPlainString(Object obj) { if (obj == null) return ""; // Common case, check first if (obj instanceof CharSequence) return obj.toString(); Class objClass = obj.getClass(); // BigDecimal toString() uses scientific notation, annoying, so use toPlainString() if (objClass == BigDecimal.class) return ((BigDecimal) obj).toPlainString(); // handle the special case of timestamps used for primary keys, make sure we avoid TZ, etc problems if (objClass == Timestamp.class) return Long.toString(((Timestamp) obj).getTime()); if (objClass == Date.class) return Long.toString(((Date) obj).getTime()); if (objClass == Time.class) return Long.toString(((Time) obj).getTime()); if (obj instanceof Collection) { Collection col = (Collection) obj; StringBuilder sb = new StringBuilder(); for (Object entry : col) { if (entry == null) continue; if (sb.length() > 0) sb.append(","); sb.append(entry); } return sb.toString(); } // no special case? do a simple toString() return obj.toString(); } /** Like the Groovy empty except doesn't consider empty 0 value numbers, false Boolean, etc; only null values, * 0 length String (actually CharSequence to include GString, etc), and 0 size Collection/Map are considered empty. */ public static boolean isEmpty(Object obj) { if (obj == null) return true; /* faster not to do this Class objClass = obj.getClass(); // some common direct classes if (objClass == String.class) return ((String) obj).length() == 0; if (objClass == GString.class) return ((GString) obj).length() == 0; if (objClass == ArrayList.class) return ((ArrayList) obj).size() == 0; if (objClass == HashMap.class) return ((HashMap) obj).size() == 0; if (objClass == LinkedHashMap.class) return ((HashMap) obj).size() == 0; // hopefully less common sub-classes */ if (obj instanceof CharSequence) return ((CharSequence) obj).length() == 0; if (obj instanceof Collection) return ((Collection) obj).size() == 0; return obj instanceof Map && ((Map) obj).size() == 0; } public static Class getClass(String javaType) { Class theClass = MClassLoader.getCommonClass(javaType); if (theClass == null) { try { theClass = Thread.currentThread().getContextClassLoader().loadClass(javaType); } catch (ClassNotFoundException e) { /* ignore */ } } return theClass; } public static boolean isInstanceOf(Object theObjectInQuestion, String javaType) { Class theClass = MClassLoader.getCommonClass(javaType); if (theClass == null) { try { theClass = Thread.currentThread().getContextClassLoader().loadClass(javaType); } catch (ClassNotFoundException e) { /* ignore */ } } if (theClass == null) throw new IllegalArgumentException("Cannot find class for type: " + javaType); return theClass.isInstance(theObjectInQuestion); } public static Number addNumbers(Number a, Number b) { if (a == null) return b; if (b == null) return a; Class aClass = a.getClass(); Class bClass = b.getClass(); // handle BigDecimal as a special case, most common case if (aClass == BigDecimal.class) { if (bClass == BigDecimal.class) return ((BigDecimal) a).add((BigDecimal) b); else return ((BigDecimal) a).add(new BigDecimal(b.toString())); } if (bClass == BigDecimal.class) { if (aClass == BigDecimal.class) return ((BigDecimal) b).add((BigDecimal) a); else return ((BigDecimal) b).add(new BigDecimal(a.toString())); } // handle other numbers in descending order of most to least precision if (aClass == Double.class || bClass == Double.class) { return a.doubleValue() + b.doubleValue(); } else if (aClass == Float.class || bClass == Float.class) { return a.floatValue() + b.floatValue(); } else if (aClass == Long.class || bClass == Long.class) { return a.longValue() + b.longValue(); } else { return a.intValue() + b.intValue(); } } public static int getCalendarFieldFromUomId(final String uomId) { Integer calField = calendarFieldByUomId.get(uomId); if (calField == null) throw new IllegalArgumentException("No equivalent Calendar field found for UOM ID " + uomId); return calField; } public static TemporalUnit getTemporalUnitFromUomId(final String uomId) { TemporalUnit temporalUnit = temporalUnitByUomId.get(uomId); if (temporalUnit == null) throw new IllegalArgumentException("No equivalent Temporal Unit found for UOM ID " + uomId); return temporalUnit; } } ================================================ FILE: framework/src/main/java/org/moqui/util/RestClient.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import groovy.json.JsonBuilder; import groovy.json.JsonSlurperClassic; import java.io.InputStream; import java.io.UnsupportedEncodingException; import java.net.URI; import java.net.URISyntaxException; import java.net.URLEncoder; import java.nio.charset.Charset; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Base64; import java.util.EnumSet; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.Set; import java.util.concurrent.CompletableFuture; import java.util.concurrent.ExecutionException; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; import java.util.concurrent.TimeoutException; import java.util.concurrent.locks.ReentrantLock; import org.eclipse.jetty.client.CompletableResponseListener; import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.HttpClientTransport; import org.eclipse.jetty.client.HttpResponseException; import org.eclipse.jetty.client.InputStreamRequestContent; import org.eclipse.jetty.client.MultiPartRequestContent; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.Response; import org.eclipse.jetty.client.Result; import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.client.ValidatingConnectionPool; import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.client.transport.HttpClientTransportOverHTTP; import org.eclipse.jetty.http.HttpCookieStore; import org.eclipse.jetty.http.HttpField; import org.eclipse.jetty.http.HttpFields; import org.eclipse.jetty.http.HttpHeader; import org.eclipse.jetty.http.MultiPart; import org.eclipse.jetty.io.ClientConnector; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.eclipse.jetty.util.thread.QueuedThreadPool; import org.eclipse.jetty.util.thread.ScheduledExecutorScheduler; import org.eclipse.jetty.util.thread.Scheduler; import org.moqui.BaseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @SuppressWarnings("unused") public class RestClient { public enum Method { GET, PATCH, PUT, POST, DELETE, OPTIONS, HEAD } public static final Method GET = Method.GET, PATCH = Method.PATCH, PUT = Method.PUT, POST = Method.POST, DELETE = Method.DELETE, OPTIONS = Method.OPTIONS, HEAD = Method.HEAD; public static final String[] METHOD_ARRAY = { "GET", "PATCH", "PUT", "POST", "DELETE", "OPTIONS", "HEAD" }; public static final Set METHOD_SET = new HashSet<>(Arrays.asList(METHOD_ARRAY)); // NOTE: there is no constant on HttpServletResponse for 429; see RFC 6585 for details public static final int TOO_MANY = 429; // NOTE: DELETE doesn't normally support a body, but some APIs use it private static final EnumSet BODY_METHODS = EnumSet.of(Method.GET, Method.PATCH, Method.POST, Method.PUT, Method.DELETE); private static final Logger logger = LoggerFactory.getLogger(RestClient.class); // Default RequestFactory (avoid new per request) private static final ReentrantLock defaultReqFacLock = new ReentrantLock(); private static RequestFactory defaultRequestFactoryInternal = null; public static RequestFactory getDefaultRequestFactory() { if (defaultRequestFactoryInternal != null) return defaultRequestFactoryInternal; defaultReqFacLock.lock(); try { defaultRequestFactoryInternal = new SimpleRequestFactory(); return defaultRequestFactoryInternal; } finally { defaultReqFacLock.unlock(); } } // TODO: consider creating a RequestFactory in ECFI, init and destroy along with ECFI public static void setDefaultRequestFactory(RequestFactory newRequestFactory) { defaultReqFacLock.lock(); try { RequestFactory tempRf = defaultRequestFactoryInternal; defaultRequestFactoryInternal = newRequestFactory; if (tempRf != null) tempRf.destroy(); } finally { defaultReqFacLock.unlock(); } } public static void destroyDefaultRequestFactory() { defaultReqFacLock.lock(); try { if (defaultRequestFactoryInternal != null) { defaultRequestFactoryInternal.destroy(); defaultRequestFactoryInternal = null; } } finally { defaultReqFacLock.unlock(); } } // ========== Instance Fields ========== private String uriString = null; private Method method = Method.GET; private String contentType = "application/json"; private String acceptContentType = null; private Charset charset = StandardCharsets.UTF_8; private String bodyText = null; private MultiPartRequestContent multiPart = null; private List headerList = new LinkedList<>(); private List bodyParameterList = new LinkedList<>(); private String username = null; private String password = null; private float initialWaitSeconds = 2.0F; private int maxRetries = 0; private int maxResponseSize = 4 * 1024 * 1024; private int timeoutSeconds = 30; private boolean timeoutRetry = false; private RequestFactory overrideRequestFactory = null; private boolean isolate = false; public RestClient() { } /** Full URL String including protocol, host, path, parameters, etc */ public RestClient uri(String location) { uriString = location; return this; } /** URL object including protocol, host, path, parameters, etc */ public RestClient uri(URI uri) { this.uriString = uri.toASCIIString(); return this; } public UriBuilder uri() { return new UriBuilder(this); } public URI getUri() { return URI.create(uriString); } public String getUriString() { return uriString; } /** Sets the HTTP request method, defaults to 'GET'; must be in the METHODS array */ public RestClient method(String method) { if (method == null || method.isEmpty()) { this.method = Method.GET; return this; } method = method.toUpperCase(); try { this.method = Method.valueOf(method); } catch (Exception e) { throw new IllegalArgumentException("Method " + method + " not valid"); } return this; } public RestClient method(Method method) { this.method = method; return this; } public Method getMethod() { return method; } /** Defaults to 'application/json', could also be 'text/xml', etc */ public RestClient contentType(String contentType) { this.contentType = contentType; return this; } public RestClient acceptContentType(String acceptContentType) { this.acceptContentType = acceptContentType; return this; } /** The MIME character encoding for the body sent and response. Defaults to UTF-8. Must be a valid * charset in the java.nio.charset.Charset class. */ public RestClient encoding(String characterEncoding) { this.charset = Charset.forName(characterEncoding); return this; } public RestClient addHeaders(Map headers) { for (Map.Entry entry : headers.entrySet()) headerList.add(new KeyValueString(entry.getKey(), entry.getValue())); return this; } public RestClient addHeader(String name, String value) { headerList.add(new KeyValueString(name, value)); return this; } public RestClient basicAuth(String username, String password) { this.username = username; this.password = password; return this; } /** Set the body text to use */ public RestClient text(String bodyText) { if (!BODY_METHODS.contains(method)) throw new IllegalStateException("Cannot use body text with method " + method); this.bodyText = bodyText; return this; } /** Set the body text as JSON from an Object */ public RestClient jsonObject(Object bodyJsonObject) { if (bodyJsonObject == null) { bodyText = null; return this; } if (bodyJsonObject instanceof CharSequence) { return text(bodyJsonObject.toString()); } JsonBuilder jb = new JsonBuilder(); if (bodyJsonObject instanceof Map) { jb.call((Map) bodyJsonObject); } else if (bodyJsonObject instanceof List) { jb.call((List) bodyJsonObject); } else { jb.call((Object) bodyJsonObject); } return text(jb.toString()); } /** Set the body text as XML from a MNode */ public RestClient xmlNode(MNode bodyXmlNode) { if (bodyXmlNode == null) { bodyText = null; return this; } return text(bodyXmlNode.toString()); } public String getBodyText() { return bodyText; } /** Add fields to put in body form parameters */ public RestClient addBodyParameters(Map formFields) { for (Map.Entry entry : formFields.entrySet()) bodyParameterList.add(new KeyValueString(entry.getKey(), entry.getValue())); return this; } /** Add a field to put in body form parameters */ public RestClient addBodyParameter(String name, String value) { bodyParameterList.add(new KeyValueString(name, value)); return this; } /** Add a field part to a multi part request **/ public RestClient addFieldPart(String field, String value) { if (method != Method.POST) throw new IllegalStateException("Can only use multipart body with POST method, not supported for method " + method + "; if you need a different effective request method try using the X-HTTP-Method-Override header"); if (multiPart == null) multiPart = new MultiPartRequestContent(); multiPart.addPart(new MultiPart.ContentSourcePart(field, null, null, new StringRequestContent(value))); return this; } /** Add a String file part to a multi part request **/ public RestClient addFilePart(String name, String fileName, String stringContent) { return addFilePart(name, fileName, new StringRequestContent(stringContent), null); } /** Add a InputStream file part to a multi part request **/ public RestClient addFilePart(String name, String fileName, InputStream streamContent) { return addFilePart(name, fileName, new InputStreamRequestContent(streamContent), null); } /** Add file part using Jetty ContentProvider. * WARNING: This uses Jetty HTTP Client API objects and may change over time, do not use if alternative will work. */ public RestClient addFilePart(String name, String fileName, Request.Content content, HttpFields fields) { if (method != Method.POST) throw new IllegalStateException("Can only use multipart body with POST method, not supported for method " + method + "; if you need a different effective request method try using the X-HTTP-Method-Override header"); if (multiPart == null) multiPart = new MultiPartRequestContent(); multiPart.addPart(new MultiPart.ContentSourcePart(name, fileName, fields, content)); return this; } /** If a velocity limit (429) response is received then retry up to maxRetries with * exponential back off (initialWaitSeconds^i) sleep time in between requests. */ public RestClient retry(float initialWaitSeconds, int maxRetries) { this.initialWaitSeconds = initialWaitSeconds; this.maxRetries = maxRetries; return this; } /** Same as retry(int, int) with defaults of 2 for initialWaitSeconds and 5 for maxRetries * (2, 4, 8, 16, 32 seconds; up to total 62 seconds wait time and 6 HTTP requests) */ public RestClient retry() { return retry(2.0F, 5); } /** Set a maximum response size, defaults to 4MB (4 * 1024 * 1024) */ public RestClient maxResponseSize(int maxSize) { this.maxResponseSize = maxSize; return this; } /** Set a full response timeout in seconds, defaults to 30 */ public RestClient timeout(int seconds) { this.timeoutSeconds = seconds; return this; } /** Set to true if retry should also be done on timeout; must call retry() to set retry parameters otherwise defaults to 1 retry with 2.0 initial wait time. */ public RestClient timeoutRetry(boolean tr) { this.timeoutRetry = tr; if (maxRetries == 0) maxRetries = 1; return this; } /** Use a specific RequestFactory for pooling, keep alive, etc */ public RestClient withRequestFactory(RequestFactory requestFactory) { overrideRequestFactory = requestFactory; return this; } /** If true isolate the request from all other requests by using a new HttpClient instance per request (no cookies, keep alive, etc; each request isolated from others) */ public RestClient isolate(boolean isolate) { this.isolate = isolate; return this; } /** Do the HTTP request and get the response */ public RestResponse call() { float curWaitSeconds = initialWaitSeconds; if (curWaitSeconds == 0) curWaitSeconds = 1; RestResponse curResponse = null; for (int i = 0; i <= maxRetries; i++) { try { // do the request curResponse = callInternal(); } catch (TimeoutException e) { // if set to do so retry on timeout if (timeoutRetry && i < maxRetries) { try { Thread.sleep(Math.round(curWaitSeconds * 1000)); } catch (InterruptedException ie) { logger.warn("RestClient timeout retry sleep interrupted, returning most recent response", ie); return curResponse; } curWaitSeconds = curWaitSeconds * initialWaitSeconds; continue; } else { throw new BaseException("Timeout error calling REST request", e); } } if (curResponse.statusCode == TOO_MANY && i < maxRetries) { try { Thread.sleep(Math.round(curWaitSeconds * 1000)); } catch (InterruptedException e) { logger.warn("RestClient velocity retry sleep interrupted, returning most recent response", e); return curResponse; } curWaitSeconds = curWaitSeconds * initialWaitSeconds; } else { break; } } return curResponse; } protected RestResponse callInternal() throws TimeoutException { if (uriString == null || uriString.isEmpty()) throw new IllegalStateException("No URI set in RestClient"); RequestFactory tempFactory = this.isolate ? new SimpleRequestFactory() : null; try { Request request = makeRequest(tempFactory != null ? tempFactory : (overrideRequestFactory != null ? overrideRequestFactory : getDefaultRequestFactory())); if (timeoutSeconds < 2) timeoutSeconds = 2; request.idleTimeout(timeoutSeconds-1, TimeUnit.SECONDS); CompletableResponseListener listener = new CompletableResponseListener(request, maxResponseSize); try { CompletableFuture future = listener.send(); ContentResponse response = future.get(timeoutSeconds, TimeUnit.SECONDS); return new RestResponse(this, response); } catch (TimeoutException e) { logger.warn("RestClient request timed out after " + timeoutSeconds + "s to " + request.getURI()); // abort request to make sure it gets closed and cleaned up request.abort(e); throw e; } } catch (Exception e) { throw new BaseException("Error calling HTTP request to " + uriString, e); } finally { if (tempFactory != null) tempFactory.destroy(); } } protected Request makeRequest(RequestFactory requestFactory) { final Request request = requestFactory.makeRequest(uriString); request.method(method.name()); // set charset on request? // add headers and parameters for (KeyValueString nvp : headerList) request.headers(headers -> headers.put(nvp.key, nvp.value)); for (KeyValueString nvp : bodyParameterList) request.param(nvp.key, nvp.value); // authc if (username != null && !username.isEmpty()) { String unPwString = username + ':' + password; String basicAuthStr = "Basic " + Base64.getEncoder().encodeToString(unPwString.getBytes()); request.headers(headers -> headers.put(HttpHeader.AUTHORIZATION, basicAuthStr)); // using basic Authorization header instead, too many issues with this: httpClient.getAuthenticationStore().addAuthentication(new BasicAuthentication(uri, BasicAuthentication.ANY_REALM, username, password)); } if (multiPart != null) { multiPart.close(); if (method == Method.POST) { // HttpClient will send the correct headers when it's a multi-part content type (ie set content type to multipart/form-data, etc) request.body(multiPart); } else { throw new IllegalStateException("Can only use multipart body with POST method, not supported for method " + method + "; if you need a different effective request method try using the X-HTTP-Method-Override header"); } } else if (bodyText != null && !bodyText.isEmpty()) { request.body(new StringRequestContent(contentType, bodyText, charset)); // not needed, set by call to request.content() with passed contentType: request.header(HttpHeader.CONTENT_TYPE, contentType); } request.accept(acceptContentType != null && !acceptContentType.isEmpty() ? acceptContentType : contentType); if (logger.isTraceEnabled()) logger.trace("RestClient request " + request.getMethod() + " " + request.getURI() + " Headers: " + request.getHeaders()); return request; } /** Call in background */ public Future callFuture() { if (uriString == null || uriString.isEmpty()) throw new IllegalStateException("No URI set in RestClient"); return new RestClientFuture(this); } public static class RestResponse { private RestClient rci; protected ContentResponse response; protected byte[] bytes = null; private Map> headers = new LinkedHashMap<>(); private int statusCode; private String reasonPhrase, contentType, encoding; RestResponse(RestClient rci, ContentResponse response) { this.rci = rci; this.response = response; statusCode = response.getStatus(); reasonPhrase = response.getReason(); contentType = response.getMediaType(); encoding = response.getEncoding(); if (encoding == null || encoding.isEmpty()) encoding = "UTF-8"; // get headers for (HttpField hdr : response.getHeaders()) { String name = hdr.getName(); ArrayList curList = headers.get(name); if (curList == null) { curList = new ArrayList<>(); headers.put(name, curList); } curList.addAll(Arrays.asList(hdr.getValues())); } // get the response body bytes = response.getContent(); } /** If status code is not in the 200 range throw an exception with details; call this first for easy error * handling or skip it to handle manually or allow errors */ public RestResponse checkError() { if (statusCode < 200 || statusCode >= 300) { logger.info("Error " + statusCode + " (" + reasonPhrase + ") in response to " + rci.method + " to " + rci.uriString + ", response text:\n" + text()); throw new HttpResponseException("Error " + statusCode + " (" + reasonPhrase + ") in response to " + rci.method + " to " + rci.uriString, response); } return this; } public RestClient getClient() { return rci; } public int getStatusCode() { return statusCode; } public String getReasonPhrase() { return reasonPhrase; } public String getContentType() { return contentType; } public String getEncoding() { return encoding; } /** Get the plain text of the response */ public String text() { try { if ("UTF-8".equals(encoding)) { return toStringCleanBom(bytes); } else { return new String(bytes, encoding); } } catch (UnsupportedEncodingException e) { throw new BaseException("Error decoding REST response", e); } } /** Parse the response as JSON and return an Object */ public Object jsonObject() { try { return new JsonSlurperClassic().parseText(text()); } catch (Throwable t) { throw new BaseException("Error parsing JSON response from request to " + rci.uriString, t); } } /** Parse the response as XML and return a MNode */ public MNode xmlNode() { return MNode.parseText(rci.uriString, text()); } /** Get bytes from a binary response */ public byte[] bytes() { return bytes; } // FUTURE: handle stream response, but in a way that avoids requiring an explicit close for other methods public Map> headers() { return headers; } public String headerFirst(String name) { List valueList = headers.get(name); return valueList != null && valueList.size() > 0 ? valueList.get(0) : null; } static String toStringCleanBom(byte[] bytes) throws UnsupportedEncodingException { // NOTE: this only supports UTF-8 for now! if (bytes == null || bytes.length == 0) return ""; // UTF-8 BOM = 239, 187, 191 if (bytes[0] == (byte) 239) { return new String(bytes, 3, bytes.length - 3, StandardCharsets.UTF_8); } else { return new String(bytes, StandardCharsets.UTF_8); } } } public static class UriBuilder { private RestClient rci; private String protocol = "http"; private String host = null; private int port = 80; private StringBuilder path = new StringBuilder(); private Map parameters = null; private String fragment = null; UriBuilder(RestClient rci) { this.rci = rci; } public UriBuilder protocol(String protocol) { if (protocol == null || protocol.isEmpty()) throw new IllegalArgumentException("Empty protocol not allowed"); this.protocol = protocol; return this; } public UriBuilder host(String host) { if (host == null || host.isEmpty()) throw new IllegalArgumentException("Empty host not allowed"); this.host = host; return this; } public UriBuilder port(int port) { if (port <= 0) throw new IllegalArgumentException("Invalid port " + port); this.port = port; return this; } public UriBuilder path(String pathEl) { if (pathEl == null || pathEl.isEmpty()) return this; if (!pathEl.startsWith("/")) path.append("/"); path.append(pathEl); int lastIndex = path.length() - 1; if ('/' == path.charAt(lastIndex)) path.deleteCharAt(lastIndex); return this; } public UriBuilder parameter(String name, String value) { if (parameters == null) parameters = new LinkedHashMap<>(); parameters.put(name, value); return this; } public UriBuilder parameters(Map parms) { if (parms == null) return this; if (parameters == null) { parameters = new LinkedHashMap<>(parms); } else { parameters.putAll(parms); } return this; } public UriBuilder fragment(String fragment) { this.fragment = fragment; return this; } public RestClient build() throws URISyntaxException, UnsupportedEncodingException { if (host == null || host.isEmpty()) throw new IllegalArgumentException("No host specified, call the host() method before build()"); StringBuilder uriSb = new StringBuilder(); uriSb.append(protocol).append("://").append(host).append(':').append(port); if (path.length() == 0) path.append("/"); uriSb.append(path); String query = parametersMapToString(parameters); if (query != null && query.length() > 0) uriSb.append('?').append(query); return rci.uri(uriSb.toString()); } } public static String parametersMapToString(Map parameters) throws UnsupportedEncodingException { if (parameters == null || parameters.size() == 0) return null; StringBuilder query = new StringBuilder(); for (Map.Entry parm : parameters.entrySet()) { if (query.length() > 0) query.append("&"); Object valueObj = parm.getValue(); if (valueObj == null) continue; String valueStr = ObjectUtilities.toPlainString(valueObj); query.append(URLEncoder.encode(parm.getKey(), "UTF-8")) .append("=").append(URLEncoder.encode(valueStr, "UTF-8")); } return query.toString(); } private static class KeyValueString { KeyValueString(String key, String value) { this.key = key; this.value = value; } public String key; public String value; } public static class RetryListener implements Response.CompleteListener { RestClientFuture rcf; RetryListener(RestClientFuture rcf) { this.rcf = rcf; } @Override public void onComplete(Result result) { if (result.getResponse().getStatus() == TOO_MANY && rcf.retryCount < rcf.rci.maxRetries && !rcf.cancelled) { // lock before new request to make sure not in the middle of get() rcf.retryLock.lock(); try { try { Thread.sleep(Math.round(rcf.curWaitSeconds * 1000)); } catch (InterruptedException e) { logger.warn("RestClientFuture retry sleep interrupted, returning most recent response", e); return; } // update wait time and count rcf.curWaitSeconds = rcf.curWaitSeconds * rcf.rci.initialWaitSeconds; rcf.retryCount++; // do a new request, still in the background rcf.newRequest(); } finally { rcf.retryLock.unlock(); } } } } public static class RestClientFuture implements Future { RestClient rci; RequestFactory tempRequestFactory = null; CompletableResponseListener listener; CompletableFuture future; volatile float curWaitSeconds; volatile int retryCount = 0; volatile boolean cancelled = false; ReentrantLock retryLock = new ReentrantLock(); ContentResponse lastResponse = null; RestClientFuture(RestClient rci) { this.rci = rci; curWaitSeconds = rci.initialWaitSeconds; if (curWaitSeconds == 0) curWaitSeconds = 1; // start the initial request newRequest(); } void newRequest() { if (tempRequestFactory != null) tempRequestFactory.destroy(); tempRequestFactory = rci.isolate ? new SimpleRequestFactory() : null; // NOTE: RestClientFuture methods call httpClient.stop() so not handled here try { Request request = rci.makeRequest(tempRequestFactory != null ? tempRequestFactory : (rci.overrideRequestFactory != null ? rci.overrideRequestFactory : getDefaultRequestFactory())); // use a CompleteListener to retry in background request.onComplete(new RetryListener(this)); listener = new CompletableResponseListener(request, rci.maxResponseSize); future = listener.send(); } catch (Exception e) { throw new BaseException("Error calling REST request to " + rci.uriString, e); } } @Override public boolean isCancelled() { return cancelled || (future != null && future.isCancelled()); } @Override public boolean isDone() { return retryCount >= rci.maxRetries && (future != null && future.isDone()); } @Override public boolean cancel(boolean mayInterruptIfRunning) { retryLock.lock(); try { try { cancelled = true; return future != null && future.cancel(mayInterruptIfRunning); } finally { if (tempRequestFactory != null) { tempRequestFactory.destroy(); tempRequestFactory = null; } } } finally { retryLock.unlock(); } } @Override public RestResponse get() throws InterruptedException, ExecutionException { try { return get(rci.timeoutSeconds, TimeUnit.SECONDS); } catch (TimeoutException e) { throw new BaseException("Timeout error calling REST request", e); } } @Override public RestResponse get(long timeout, TimeUnit unit) throws InterruptedException, ExecutionException, TimeoutException { do { // lock before new request to make sure not in the middle of retry retryLock.lock(); try { try { lastResponse = future.get(timeout, unit); if (lastResponse.getStatus() != TOO_MANY) break; } finally { if (tempRequestFactory != null) { tempRequestFactory.destroy(); tempRequestFactory = null; } } } finally { retryLock.unlock(); } } while (!cancelled && retryCount < rci.maxRetries); return new RestResponse(rci, lastResponse); } } public interface RequestFactory { Request makeRequest(String uriString); void destroy(); } /** The default RequestFactory, uses mostly Jetty HttpClient defaults and retains HttpClient instance between requests. */ public static class SimpleRequestFactory implements RequestFactory { private final HttpClient httpClient; public SimpleRequestFactory() { this(true, false); } public SimpleRequestFactory(boolean trustAll, boolean disableCookieManagement) { SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true); sslContextFactory.setEndpointIdentificationAlgorithm(null); ClientConnector clientConnector = new ClientConnector(); clientConnector.setSslContextFactory(sslContextFactory); httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector)); if (disableCookieManagement) httpClient.setHttpCookieStore(new HttpCookieStore.Empty()); // use a default idle timeout of 15 seconds, should be lower than server idle timeouts which will vary by server but 30 seconds seems to be common httpClient.setIdleTimeout(15000); try { httpClient.start(); } catch (Exception e) { throw new BaseException("Error starting HTTP client", e); } } @Override public Request makeRequest(String uriString) { return httpClient.newRequest(uriString); } HttpClient getHttpClient() { return httpClient; } @Override public void destroy() { if (httpClient != null && httpClient.isRunning()) { try { httpClient.stop(); } catch (Exception e) { logger.error("Error stopping SimpleRequestFactory HttpClient", e); } } } } /** RequestFactory with explicit pooling parameters and options specific to the Jetty HttpClient */ public static class PooledRequestFactory implements RequestFactory { private HttpClient httpClient; private final String shortName; private int poolSize = 64; private int queueSize = 1024; private long validationTimeoutMillis = 1000; private SslContextFactory.Client sslContextFactory = null; private HttpClientTransport transport = null; private QueuedThreadPool executor = null; private Scheduler scheduler = null; /** The required shortName is used as a prefix for thread names and should be distinct. */ public PooledRequestFactory(String shortName) { this.shortName = shortName; } /** Note that if a transport is specified it must include the SslContextFactory.Client so this is ignored. */ public PooledRequestFactory with(SslContextFactory.Client sslcf) { sslContextFactory = sslcf; return this; } public PooledRequestFactory with(HttpClientTransport transport) { this.transport = transport; return this; } public PooledRequestFactory with(QueuedThreadPool executor) { this.executor = executor; return this; } public PooledRequestFactory with(Scheduler scheduler) { this.scheduler = scheduler; return this; } /** Size of the HTTP connection pool per destination (scheme + host + port) */ public PooledRequestFactory poolSize(int size) { poolSize = size; return this; } /** Size of the HTTP request queue per destination (scheme + host + port) */ public PooledRequestFactory queueSize(int size) { queueSize = size; return this; } /** Quarantine timeout for connection validation, see ValidatingConnectionPool javadoc for details */ public PooledRequestFactory validationTimeout(long millis) { validationTimeoutMillis = millis; return this; } public PooledRequestFactory init() { if (transport == null) { if (sslContextFactory == null) { sslContextFactory = new SslContextFactory.Client(true); sslContextFactory.setEndpointIdentificationAlgorithm(null); } ClientConnector clientConnector = new ClientConnector(); clientConnector.setSslContextFactory(sslContextFactory); transport = new HttpClientTransportDynamic(clientConnector); } if (executor == null) { executor = new QueuedThreadPool(); executor.setName(shortName + "-queue"); } if (scheduler == null) scheduler = new ScheduledExecutorScheduler(shortName + "-scheduler", false); transport.setConnectionPoolFactory(destination -> new ValidatingConnectionPool(destination, destination.getHttpClient().getMaxConnectionsPerDestination(), destination.getHttpClient().getScheduler(), validationTimeoutMillis)); httpClient = new HttpClient(transport); httpClient.setExecutor(executor); httpClient.setScheduler(scheduler); httpClient.setMaxConnectionsPerDestination(poolSize); httpClient.setMaxRequestsQueuedPerDestination(queueSize); try { httpClient.start(); } catch (Exception e) { throw new BaseException("Error starting HTTP client for " + shortName, e); } return this; } @Override public Request makeRequest(String uriString) { return httpClient.newRequest(uriString); } public HttpClient getHttpClient() { return httpClient; } @Override public void destroy() { if (httpClient != null && httpClient.isRunning()) { try { httpClient.stop(); } catch (Exception e) { logger.error("Error stopping PooledRequestFactory HttpClient for " + shortName, e); } } } } } ================================================ FILE: framework/src/main/java/org/moqui/util/SimpleTopic.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; /** A simple topic publish interface. Listeners (subscribers) should be handled directly on the topic implementation. */ public interface SimpleTopic { void publish(E message); } ================================================ FILE: framework/src/main/java/org/moqui/util/StringUtilities.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import org.apache.commons.codec.binary.*; import org.codehaus.groovy.runtime.DefaultGroovyMethods; import org.moqui.BaseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import org.w3c.dom.Element; import javax.swing.text.MaskFormatter; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.math.RoundingMode; import java.net.URI; import java.net.URISyntaxException; import java.net.URLDecoder; import java.net.URLEncoder; import java.security.SecureRandom; import java.text.ParseException; import java.util.*; import java.util.regex.Pattern; /** * These are utilities that should exist elsewhere, but I can't find a good simple library for them, and they are * stupid but necessary for certain things. */ @SuppressWarnings("unused") public class StringUtilities { protected static final Logger logger = LoggerFactory.getLogger(StringUtilities.class); public static final Map xmlEntityMap; static { HashMap map = new HashMap<>(5); map.put("apos", "\'"); map.put("quot", "\""); map.put("amp", "&"); map.put("lt", "<"); map.put("gt", ">"); xmlEntityMap = map; } private static final String[] SCALES = new String[]{"", "thousand", "million", "billion", "trillion", "quadrillion", "quintillion", "sextillion"}; private static final String[] SUBTWENTY = new String[]{"", "one", "two", "three", "four", "five", "six", "seven", "eight", "nine", "ten", "eleven", "twelve", "thirteen", "fourteen", "fifteen", "sixteen", "seventeen", "eighteen", "nineteen"}; private static final String[] DECADES = new String[]{"", "ten", "twenty", "thirty", "forty", "fifty", "sixty", "seventy", "eighty", "ninety"}; private static final String NEG_NAME = "negative"; public static String elementValue(Element element) { if (element == null) return null; element.normalize(); org.w3c.dom.Node textNode = element.getFirstChild(); if (textNode == null) return null; StringBuilder value = new StringBuilder(); if (textNode.getNodeType() == org.w3c.dom.Node.CDATA_SECTION_NODE || textNode.getNodeType() == org.w3c.dom.Node.TEXT_NODE) value.append(textNode.getNodeValue()); while ((textNode = textNode.getNextSibling()) != null) { if (textNode.getNodeType() == org.w3c.dom.Node.CDATA_SECTION_NODE || textNode.getNodeType() == org.w3c.dom.Node.TEXT_NODE) value.append(textNode.getNodeValue()); } return value.toString(); } public static String encodeForXmlAttribute(String original) { return encodeForXmlAttribute(original, false); } public static String encodeForXmlAttribute(String original, boolean addZeroWidthSpaces) { if (original == null) return ""; StringBuilder newValue = new StringBuilder(original); for (int i = 0; i < newValue.length(); i++) { char curChar = newValue.charAt(i); switch (curChar) { case '\'': newValue.replace(i, i + 1, "'"); i += 5; break; case '"': newValue.replace(i, i + 1, """); i += 5; break; case '&': newValue.replace(i, i + 1, "&"); i += 4; break; case '<': newValue.replace(i, i + 1, "<"); i += 3; break; case '>': newValue.replace(i, i + 1, ">"); i += 3; break; case 0x5: newValue.replace(i, i + 1, "..."); i += 2; break; case 0x12: newValue.replace(i, i + 1, "'"); i += 5; break; case 0x13: newValue.replace(i, i + 1, """); i += 5; break; case 0x14: newValue.replace(i, i + 1, """); i += 5; break; case 0x16: newValue.replace(i, i + 1, "-"); break; case 0x17: newValue.replace(i, i + 1, "-"); break; case 0x19: newValue.replace(i, i + 1, "tm"); i++; break; default: if (DefaultGroovyMethods.compareTo(curChar, 0x20) < 0 && curChar != 0x9 && curChar != 0xA && curChar != 0xD) { // the only valid values < 0x20 are 0x9 (tab), 0xA (newline), 0xD (carriage return) newValue.deleteCharAt(i); i--; } else if (DefaultGroovyMethods.compareTo(curChar, 0x7F) > 0) { // Replace each char which is out of the ASCII range with a XML entity String s = "&#" + ((int) curChar) + ";"; newValue.replace(i, i + 1, s); i += s.length() - 1; } else if (addZeroWidthSpaces) { newValue.insert(i, "​"); i += 7; } } } return newValue.toString(); } /** See if contains only characters allowed by URLDecoder, if so doesn't need to be encoded or is already encoded */ public static boolean isUrlDecoderSafe(String text) { // see https://docs.oracle.com/javase/8/docs/api/index.html?java/net/URLEncoder.html // letters, digits, and: "-", "_", ".", and "*" // allow '%' for strings already encoded // '+' is treated as space, so allow but means we can't detect if already encoded vs doesn't need to be encoded if (text == null) return true; // NOTE: expect mostly shorter strings to charAt() faster than text.toCharArray() and chars[i]; more memory efficient too int textLen = text.length(); for (int i = 0; i < textLen; i++) { char ch = text.charAt(i); if (Character.isLetterOrDigit(ch)) continue; if (ch == '.' || ch == '_' || ch == '-' || ch == '*' || ch == '+') continue; if (ch == '%') { if (i + 2 < textLen) { char ch1 = text.charAt(i + 1); char ch2 = text.charAt(i + 2); if (isHexChar(ch1) && isHexChar(ch2)) { i += 2; continue; } } return false; } return false; } return true; } public static String urlEncodeIfNeeded(String text) { if (isUrlDecoderSafe(text)) return text; try { return URLEncoder.encode(text, "UTF-8"); } catch (UnsupportedEncodingException e) { // should never happen with hard coded encoding return text; } } public static boolean isUrlSafeRfc3986(String text) { if (text == null) return true; // RFC 3986 URL path chars: a-z A-Z 0-9 . _ - + ~ ! $ & ' ( ) * , ; = : @ char[] chars = text.toCharArray(); for (int i = 0; i < chars.length; i++) { char ch = chars[i]; if (Character.isLetterOrDigit(ch)) continue; if (ch == '.' || ch == '_' || ch == '-' || ch == '+' || ch == '~' || ch == '!' || ch == '$' || ch == '&' || ch == '\'' || ch == '(' || ch == ')' || ch == '*' || ch == ',' || ch == ';' || ch == '=' || ch == ':' || ch == '@') continue; return false; } return true; } public static ArrayList pathStringToList(String path, int skipSegments) { ArrayList pathList = new ArrayList<>(); if (path == null || path.isEmpty()) return pathList; if (path.charAt(0) == '/') path = path.substring(1); String[] pathArray = path.split("/"); for (int i = skipSegments; i < pathArray.length; i++) { String pathSegment = pathArray[i]; if (pathSegment == null || pathSegment.isEmpty()) continue; try { pathSegment = URLDecoder.decode(pathSegment, "UTF-8"); } catch (Exception e) { if (logger.isTraceEnabled()) logger.trace("Error decoding screen path segment ${pathSegment}", e); } pathList.add(pathSegment); } return pathList; } public static String camelCaseToPretty(String camelCase) { if (camelCase == null || camelCase.length() == 0) return ""; StringBuilder prettyName = new StringBuilder(); String lastPart = null; for (String part : camelCase.split("(?=[A-Z0-9\\.#])")) { if (part.length() == 0) continue; char firstChar = part.charAt(0); if (firstChar == '.' || firstChar == '#') { if (part.length() == 1) continue; part = part.substring(1); firstChar = part.charAt(0); } if (Character.isLowerCase(firstChar)) part = Character.toUpperCase(firstChar) + part.substring(1); if (part.equalsIgnoreCase("id")) part = "ID"; if (part.equals(lastPart)) continue; lastPart = part; if (prettyName.length() > 0) prettyName.append(" "); prettyName.append(part); } return prettyName.toString(); } public static String prettyToCamelCase(String pretty, boolean firstUpper) { if (pretty == null || pretty.length() == 0) return ""; StringBuilder camelCase = new StringBuilder(); char[] prettyChars = pretty.toCharArray(); boolean upperNext = firstUpper; for (int i = 0; i < prettyChars.length; i++) { char curChar = prettyChars[i]; if (Character.isLetterOrDigit(curChar)) { curChar = upperNext ? Character.toUpperCase(curChar) : Character.toLowerCase(curChar); camelCase.append(curChar); upperNext = false; } else { upperNext = true; } } return camelCase.toString(); } public static String removeNonAlphaNumeric(String origString) { if (origString == null || origString.isEmpty()) return origString; int origLength = origString.length(); char[] orig = origString.toCharArray(); StringBuilder remBuffer = new StringBuilder(); int replIdx = 0; for (int i = 0; i < origLength; i++) { char ochr = orig[i]; if (Character.isLetterOrDigit(ochr)) { remBuffer.append(ochr); } } return remBuffer.toString(); } public static String replaceNonAlphaNumeric(String origString, char chr) { if (origString == null || origString.isEmpty()) return origString; int origLength = origString.length(); char[] orig = origString.toCharArray(); char[] repl = new char[origLength]; int replIdx = 0; for (int i = 0; i < origLength; i++) { char ochr = orig[i]; if (Character.isLetterOrDigit(ochr)) { repl[replIdx++] = ochr; } else { if (replIdx == 0 || repl[replIdx-1] != chr) { repl[replIdx++] = chr; } } } return new String(repl, 0, replIdx); } public static boolean isAlphaNumeric(String str, String allowedChars) { if (str == null) return true; char[] strChars = str.toCharArray(); for (int i = 0; i < strChars.length; i++) { char c = strChars[i]; if (!Character.isLetterOrDigit(c) && (allowedChars == null || allowedChars.indexOf(c) == -1)) return false; } return true; } public static String findFirstNumber(String orig) { if (orig == null || orig.isEmpty()) return orig; int origLength = orig.length(); StringBuilder numBuffer = new StringBuilder(); for (int i = 0; i < origLength; i++) { char curChar = orig.charAt(i); if (Character.isDigit(curChar)) { numBuffer.append(curChar); } else if (numBuffer.length() > 0 && (curChar == '.' || curChar == ',')) { numBuffer.append(curChar); } else if (numBuffer.length() > 0) { // if we have any numbers and find something else we're done break; } } if (numBuffer.length() == 0) return null; return numBuffer.toString(); } public static String decodeFromXml(String original) { if (original == null || original.isEmpty()) return original; int pos = original.indexOf("&"); if (pos == -1) return original; StringBuilder newValue = new StringBuilder(original); while (pos < newValue.length() && pos >= 0) { int scIndex = newValue.indexOf(";", pos + 1); if (scIndex == -1) break; String entityName = newValue.substring(pos + 1, scIndex); String replaceChar; if (entityName.charAt(0) == '#') { String decStr = entityName.substring(1); int decInt = Integer.valueOf(decStr); replaceChar = new String(Character.toChars(decInt)); } else { replaceChar = xmlEntityMap.get(entityName); } // logger.warn("========= pos=${pos}, entityName=${entityName}, replaceChar=${replaceChar}") if (replaceChar != null) newValue.replace(pos, scIndex + 1, replaceChar); pos = newValue.indexOf("&", pos + 1); } return newValue.toString(); } public static String cleanStringForJavaName(String original) { if (original == null || original.isEmpty()) return original; char[] origChars = original.toCharArray(); char[] cleanChars = new char[origChars.length]; boolean isIdentifierStart = true; for (int i = 0; i < origChars.length; i++) { char curChar = origChars[i]; // remove dots too, get down to simple class name to work best with Groovy class compiling and loading // if (curChar == '.') { cleanChars[i] = '.'; isIdentifierStart = true; continue; } // also don't allow $ as groovy blows up on it with class compile/load if (curChar != '$' && (isIdentifierStart ? Character.isJavaIdentifierStart(curChar) : Character.isJavaIdentifierPart(curChar))) { cleanChars[i] = curChar; } else { cleanChars[i] = '_'; } isIdentifierStart = false; } // logger.warn("cleaned " + original + " to " + new String(cleanChars)); return new String(cleanChars); } public static String getExpressionClassName(String expression) { String hashCode = Integer.toHexString(expression.hashCode()); int hashLength = hashCode.length(); int exprLength = expression.length(); int copyChars = exprLength < 30 ? exprLength : 30; int length = hashLength + copyChars + 1; char[] cnChars = new char[length]; cnChars[0] = 'S'; for (int i = 0; i < hashLength; i++) cnChars[i + 1] = hashCode.charAt(i); for (int i = 0; i < copyChars; i++) { char exprChar = expression.charAt(i); if (exprChar == '$' || !Character.isJavaIdentifierPart(exprChar)) exprChar = '_'; cnChars[i + hashLength + 1] = exprChar; } return new String(cnChars); } public static String encodeAsciiFilename(String filename) { try { URI uri = new URI(null, null, filename, null); return uri.toASCIIString(); } catch (URISyntaxException e) { logger.warn("Error encoding ASCII filename: " + e.toString()); return filename; } } public static String toStringCleanBom(byte[] bytes) { // NOTE: this only supports UTF-8 for now! if (bytes == null || bytes.length == 0) return ""; try { // UTF-8 BOM = 239, 187, 191 if (bytes[0] == (byte) 239) { return new String(bytes, 3, bytes.length - 3, "UTF-8"); } else { return new String(bytes, "UTF-8"); } } catch (UnsupportedEncodingException e) { throw new BaseException("Error converting bytes to String", e); } } public static String escapeElasticQueryString(CharSequence queryString) { if (queryString == null || queryString.length() == 0) return ""; int length = queryString.length(); StringBuilder sb = new StringBuilder(length * 2); for (int i = 0; i < length; i++) { char c = queryString.charAt(i); if ("+-=&|><=\"^-]*"); public static Set elasticSearchWords = new HashSet<>(Arrays.asList("AND", "OR", "NOT")); public static String elasticQueryAutoWildcard(CharSequence query, boolean allFieldPrefix) { // TODO: would be nice to somehow parse the query string, matching parentheses and quotes, and add *: for the field if none for each term if (query == null) return "*"; String queryString = query.toString().trim(); int length = queryString.length(); if (length == 0) return "*"; StringBuilder sb = new StringBuilder(length * 2); String[] querySplit = queryString.split(" "); for (int i = 0; i < querySplit.length; i++) { String term = querySplit[i].trim(); if (term.length() == 0) continue; boolean isEsWord = elasticSearchWords.contains(term); boolean noEsChars = !isEsWord && elasticSearchChars.matcher(term).matches(); if (sb.length() > 0) sb.append(' '); if (!isEsWord && allFieldPrefix && noEsChars) sb.append("*:"); sb.append(term); if (!isEsWord && noEsChars) sb.append('*'); } return sb.toString(); /* based on old code: if (term) { termSb.append((term.split(' ') as List).collect({ it.matches(/[^*:\\?_~\/\.\[\]\{\}+?*><="^-]* /) ? (it + '*') : it }).join(' ')) } else { termSb.append('*') } */ } public static String paddedNumber(long number, Integer desiredLength) { StringBuilder outStrBfr = new StringBuilder(Long.toString(number)); if (desiredLength == null) return outStrBfr.toString(); while (desiredLength > outStrBfr.length()) outStrBfr.insert(0, "0"); return outStrBfr.toString(); } public static String paddedString(String input, Integer desiredLength, Character padChar, boolean rightPad) { if (!DefaultGroovyMethods.asBoolean(padChar)) padChar = ' '; if (input == null) input = ""; StringBuilder outStrBfr = new StringBuilder(input); if (desiredLength == null) return outStrBfr.toString(); while (desiredLength > outStrBfr.length()) if (rightPad) outStrBfr.append(padChar); else outStrBfr.insert(0, padChar); return outStrBfr.toString(); } public static String paddedString(String input, Integer desiredLength, boolean rightPad) { return paddedString(input, desiredLength, ' ', rightPad); } public static MaskFormatter masker(String mask, String placeholder) throws ParseException { if (mask == null || mask.isEmpty()) return null; MaskFormatter formatter = new MaskFormatter(mask); formatter.setValueContainsLiteralCharacters(false); if (placeholder != null && !placeholder.isEmpty()) { if (placeholder.length() == 1) formatter.setPlaceholderCharacter(placeholder.charAt(0)); else formatter.setPlaceholder(placeholder); } return formatter; } public static String getRandomString(int length) { return getRandomString(length, null); } public static String getRandomString(int length, BaseNCodec baseNCodec) { if (baseNCodec == null) baseNCodec = org.apache.commons.codec.binary.Base64.builder() .setUrlSafe(true) .get(); SecureRandom sr = new SecureRandom(); byte[] randomBytes = new byte[length]; sr.nextBytes(randomBytes); String randomStr = baseNCodec.encodeToString(randomBytes); if (randomStr.length() > length) randomStr = randomStr.substring(0, length); return randomStr; } public static ArrayList getYearList(int years) { ArrayList yearList = new ArrayList<>(years); int startYear = Calendar.getInstance().get(Calendar.YEAR); for (int i = 0; i < years; i++) yearList.add(Integer.toString(startYear + i)); return yearList; } /** Convert any value from 0 to 999 inclusive, to a string. */ private static String tripleAsWords(int value, boolean useAnd) { if (value < 0 || value >= 1000) throw new IllegalArgumentException("Illegal triple-value " + value); if (value < SUBTWENTY.length) return SUBTWENTY[value]; int subhun = value % 100; int hun = value / 100; StringBuilder sb = new StringBuilder(50); if (hun > 0) sb.append(SUBTWENTY[hun]).append(" hundred"); if (subhun > 0) { if (hun > 0) sb.append(useAnd ? " and " : " "); if (subhun < SUBTWENTY.length) { sb.append(" ").append(SUBTWENTY[subhun]); } else { int tens = subhun / 10; int units = subhun % 10; if (tens > 0) sb.append(DECADES[tens]); if (units > 0) sb.append(" ").append(SUBTWENTY[units]); } } return sb.toString(); } /** Convert any long input value to a text representation * @param value The value to convert * @param useAnd true if you want to use the word 'and' in the text (eleven thousand and thirteen) */ public static String numberToWords(long value, boolean useAnd) { if (value == 0L) return SUBTWENTY[0]; // break the value down in to sets of three digits (thousands) Integer[] thous = new Integer[SCALES.length]; boolean neg = value < 0; // do not make negative numbers positive, to handle Long.MIN_VALUE int scale = 0; while (value != 0) { // use abs to convert thousand-groups to positive, if needed. thous[scale] = Math.abs((int) (value % 1000)); value = value / 1000; scale++; } StringBuilder sb = new StringBuilder(scale * 40); if (neg) sb.append(NEG_NAME).append(" "); boolean first = true; while ((scale = --scale) > 0) { if (!first) sb.append(", "); first = false; if (thous[scale] > 0) sb.append(tripleAsWords(thous[scale], useAnd)).append(" ").append(SCALES[scale]); } if (!first && thous[0] != 0) { if (useAnd) sb.append(" and "); else sb.append(" "); } sb.append(tripleAsWords(thous[0], useAnd)); sb.setCharAt(0, Character.toUpperCase(sb.charAt(0))); return sb.toString(); } public static String numberToWordsWithDecimal(BigDecimal value) { final String integerText = numberToWords(value.longValue(), false); String decimalText = value.setScale(2, RoundingMode.HALF_UP).toPlainString(); decimalText = decimalText.substring(decimalText.indexOf(".") + 1); return integerText + " and " + decimalText + "/100"; } public static String removeChar(String orig, char ch) { if (orig == null) return null; char[] origChars = orig.toCharArray(); int origLength = origChars.length; // NOTE: this seems to run pretty slow, plain replace might be faster, but avoiding its use anyway (in ServiceFacadeImpl for SECA rules) char[] newChars = new char[origLength]; int lastPos = 0; for (int i = 0; i < origLength; i++) { char curChar = origChars[i]; if (curChar != ch) { newChars[lastPos] = curChar; lastPos++; } } if (lastPos == origLength) return orig; return new String(newChars, 0, lastPos); } // Lookup table for CRC16 based on irreducible polynomial: 1 + x^2 + x^15 + x^16 private static final int[] crc16Table = { 0x0000, 0xC0C1, 0xC181, 0x0140, 0xC301, 0x03C0, 0x0280, 0xC241, 0xC601, 0x06C0, 0x0780, 0xC741, 0x0500, 0xC5C1, 0xC481, 0x0440, 0xCC01, 0x0CC0, 0x0D80, 0xCD41, 0x0F00, 0xCFC1, 0xCE81, 0x0E40, 0x0A00, 0xCAC1, 0xCB81, 0x0B40, 0xC901, 0x09C0, 0x0880, 0xC841, 0xD801, 0x18C0, 0x1980, 0xD941, 0x1B00, 0xDBC1, 0xDA81, 0x1A40, 0x1E00, 0xDEC1, 0xDF81, 0x1F40, 0xDD01, 0x1DC0, 0x1C80, 0xDC41, 0x1400, 0xD4C1, 0xD581, 0x1540, 0xD701, 0x17C0, 0x1680, 0xD641, 0xD201, 0x12C0, 0x1380, 0xD341, 0x1100, 0xD1C1, 0xD081, 0x1040, 0xF001, 0x30C0, 0x3180, 0xF141, 0x3300, 0xF3C1, 0xF281, 0x3240, 0x3600, 0xF6C1, 0xF781, 0x3740, 0xF501, 0x35C0, 0x3480, 0xF441, 0x3C00, 0xFCC1, 0xFD81, 0x3D40, 0xFF01, 0x3FC0, 0x3E80, 0xFE41, 0xFA01, 0x3AC0, 0x3B80, 0xFB41, 0x3900, 0xF9C1, 0xF881, 0x3840, 0x2800, 0xE8C1, 0xE981, 0x2940, 0xEB01, 0x2BC0, 0x2A80, 0xEA41, 0xEE01, 0x2EC0, 0x2F80, 0xEF41, 0x2D00, 0xEDC1, 0xEC81, 0x2C40, 0xE401, 0x24C0, 0x2580, 0xE541, 0x2700, 0xE7C1, 0xE681, 0x2640, 0x2200, 0xE2C1, 0xE381, 0x2340, 0xE101, 0x21C0, 0x2080, 0xE041, 0xA001, 0x60C0, 0x6180, 0xA141, 0x6300, 0xA3C1, 0xA281, 0x6240, 0x6600, 0xA6C1, 0xA781, 0x6740, 0xA501, 0x65C0, 0x6480, 0xA441, 0x6C00, 0xACC1, 0xAD81, 0x6D40, 0xAF01, 0x6FC0, 0x6E80, 0xAE41, 0xAA01, 0x6AC0, 0x6B80, 0xAB41, 0x6900, 0xA9C1, 0xA881, 0x6840, 0x7800, 0xB8C1, 0xB981, 0x7940, 0xBB01, 0x7BC0, 0x7A80, 0xBA41, 0xBE01, 0x7EC0, 0x7F80, 0xBF41, 0x7D00, 0xBDC1, 0xBC81, 0x7C40, 0xB401, 0x74C0, 0x7580, 0xB541, 0x7700, 0xB7C1, 0xB681, 0x7640, 0x7200, 0xB2C1, 0xB381, 0x7340, 0xB101, 0x71C0, 0x7080, 0xB041, 0x5000, 0x90C1, 0x9181, 0x5140, 0x9301, 0x53C0, 0x5280, 0x9241, 0x9601, 0x56C0, 0x5780, 0x9741, 0x5500, 0x95C1, 0x9481, 0x5440, 0x9C01, 0x5CC0, 0x5D80, 0x9D41, 0x5F00, 0x9FC1, 0x9E81, 0x5E40, 0x5A00, 0x9AC1, 0x9B81, 0x5B40, 0x9901, 0x59C0, 0x5880, 0x9841, 0x8801, 0x48C0, 0x4980, 0x8941, 0x4B00, 0x8BC1, 0x8A81, 0x4A40, 0x4E00, 0x8EC1, 0x8F81, 0x4F40, 0x8D01, 0x4DC0, 0x4C80, 0x8C41, 0x4400, 0x84C1, 0x8581, 0x4540, 0x8701, 0x47C0, 0x4680, 0x8641, 0x8201, 0x42C0, 0x4380, 0x8341, 0x4100, 0x81C1, 0x8081, 0x4040, }; public static int calculateCrc16(String input) { byte[] bytes = input.getBytes(); int crc = 0x0000; for (byte b : bytes) crc = (crc >>> 8) ^ crc16Table[(crc ^ b) & 0xff]; return crc; } public static boolean isHexChar(char c) { switch (c) { case '0': case '1': case '2': case '3': case '4': case '5': case '6': case '7': case '8': case '9': case 'a': case 'b': case 'c': case 'd': case 'e': case 'f': case 'A': case 'B': case 'C': case 'D': case 'E': case 'F': return true; default: return false; } } } ================================================ FILE: framework/src/main/java/org/moqui/util/SystemBinding.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import groovy.lang.Binding; import groovy.lang.GroovyClassLoader; import groovy.lang.Script; import org.codehaus.groovy.runtime.InvokerHelper; import org.slf4j.Logger; import org.slf4j.LoggerFactory; /** Simple class for evaluating expressions to get System properties and environment variables by string expansion */ public class SystemBinding extends Binding { private final static Logger logger = LoggerFactory.getLogger(SystemBinding.class); private final static boolean isTraceEnabled = logger.isTraceEnabled(); private SystemBinding() { super(); } public static String getPropOrEnv(String name) { // start with System properties String value = System.getProperty(name); if (value != null && !value.isEmpty()) return value; // try environment variables value = System.getenv(name); if (value != null && !value.isEmpty()) return value; // no luck? try replacing underscores with dots (dots used for map access in Groovy so need workaround) String dotName = null; if (name.contains("_")) { dotName = name.replace('_', '.'); value = System.getProperty(dotName); if (value != null && !value.isEmpty()) return value; value = System.getenv(dotName); if (value != null && !value.isEmpty()) return value; } if (isTraceEnabled) logger.trace("No '" + name + (dotName != null ? "' (or '" + dotName + "')" : "'") + " system property or environment variable found, using empty string"); return ""; } @Override public Object getVariable(String name) { // NOTE: this code is part of the original Groovy groovy.lang.Binding.getVariable() method and leaving it out // is the reason to override this method: //if (result == null && !variables.containsKey(name)) { // throw new MissingPropertyException(name, this.getClass()); //} return getPropOrEnv(name); } @Override public void setVariable(String name, Object value) { throw new UnsupportedOperationException("Cannot set a variable with SystemBinding, use System.setProperty()"); // super.setVariable(name, value); } @Override public boolean hasVariable(String name) { // always treat it like the variable exists and is null to change the behavior for variable scope and // declaration, easier in simple scripts return true; } private static SystemBinding defaultBinding = new SystemBinding(); public static String expand(String value) { if (value == null || value.length() == 0) return ""; if (!value.contains("${")) return value; String expression = "\"\"\"" + value + "\"\"\""; Class groovyClass = new GroovyClassLoader().parseClass(expression); Script script = InvokerHelper.createScript(groovyClass, defaultBinding); Object result = script.run(); if (result == null) return ""; // should never happen, always at least empty String return result.toString(); } } ================================================ FILE: framework/src/main/java/org/moqui/util/WebUtilities.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ package org.moqui.util; import jakarta.servlet.ServletContext; import jakarta.servlet.ServletRequest; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpSession; import java.io.ByteArrayOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.ObjectOutputStream; import java.io.UnsupportedEncodingException; import java.math.BigDecimal; import java.net.URLDecoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.Arrays; import java.util.Collection; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.Hashtable; import java.util.Iterator; import java.util.LinkedHashMap; import java.util.LinkedList; import java.util.List; import java.util.Map; import java.util.RandomAccess; import java.util.Set; import javax.annotation.Nonnull; import org.apache.commons.fileupload2.core.FileItem; import org.eclipse.jetty.client.ContentResponse; import org.eclipse.jetty.client.HttpClient; import org.eclipse.jetty.client.Request; import org.eclipse.jetty.client.StringRequestContent; import org.eclipse.jetty.client.transport.HttpClientTransportDynamic; import org.eclipse.jetty.io.ClientConnector; import org.eclipse.jetty.util.ssl.SslContextFactory; import org.moqui.BaseException; import org.slf4j.Logger; import org.slf4j.LoggerFactory; public class WebUtilities { private static final Logger logger = LoggerFactory.getLogger(WebUtilities.class); public static Map getPathInfoParameterMap(String pathInfoStr) { if (pathInfoStr == null || pathInfoStr.length() == 0) return null; Map paramMap = null; // add in all path info parameters /~name1=value1/~name2=value2/ String[] pathElements = pathInfoStr.split("/"); for (int i = 0; i < pathElements.length; i++) { String element = pathElements[i]; int equalsIndex = element.indexOf("="); if (element.length() > 0 && element.charAt(0) == '~' && equalsIndex > 0) { try { String name = URLDecoder.decode(element.substring(1, equalsIndex), "UTF-8"); String value = URLDecoder.decode(element.substring(equalsIndex + 1), "UTF-8"); // NOTE: currently ignoring existing values, likely won't be any: Object curValue = paramMap.get(name) if (paramMap == null) paramMap = new HashMap<>(); paramMap.put(name, value); } catch (UnsupportedEncodingException e) { logger.error("Error decoding path parameter", e); } } } return paramMap; } public static Object canonicalizeValue(Object orig) { Object canVal = orig; List lst = null; if (orig instanceof List) { lst = (List) orig; } else if (orig instanceof String[]) { lst = Arrays.asList((String[]) orig); } else if (orig instanceof Object[]) { lst = Arrays.asList((Object[]) orig); } if (lst != null) { if (lst.size() == 1) { canVal = lst.get(0); } else if (lst.size() > 1) { List newList = new ArrayList<>(lst.size()); canVal = newList; for (Object obj : lst) { if (obj instanceof CharSequence) { try { newList.add(URLDecoder.decode(obj.toString(), "UTF-8")); } catch (UnsupportedEncodingException e) { logger.warn("Error decoding string " + obj, e); } } else { newList.add(obj); } } } } // catch strings or lists with a single string in them unwrapped above try { if (canVal instanceof CharSequence) canVal = URLDecoder.decode(canVal.toString(), "UTF-8"); } catch (UnsupportedEncodingException e) { logger.warn("Error decoding string " + canVal, e); } return canVal; } public static Map simplifyRequestParameters(HttpServletRequest request, boolean bodyOnly) { Set urlParms = null; if (bodyOnly) { urlParms = new HashSet<>(); String query = request.getQueryString(); if (query != null && !query.isEmpty()) { for (String nameValuePair : query.split("&")) { int eqIdx = nameValuePair.indexOf("="); if (eqIdx < 0) urlParms.add(nameValuePair); else urlParms.add(nameValuePair.substring(0, eqIdx)); } } } Map reqParmOrigMap = request.getParameterMap(); Map reqParmMap = new LinkedHashMap<>(); for (Map.Entry entry : reqParmOrigMap.entrySet()) { String key = entry.getKey(); if (bodyOnly && urlParms.contains(key)) continue; String[] valArray = entry.getValue(); if (valArray == null) { reqParmMap.put(key, null); } else { int valLength = valArray.length; if (valLength == 0) { reqParmMap.put(key, null); } else if (valLength == 1) { String singleVal = valArray[0]; // change   (\u00a0) to null, used as a placeholder when empty string doesn't work if ("\u00a0".equals(singleVal)) { reqParmMap.put(key, null); } else { reqParmMap.put(key, singleVal); } } else { reqParmMap.put(key, Arrays.asList(valArray)); } } } return reqParmMap; } /** Sort of like JSON but output in JS syntax for HTML attributes like in a Vue Template */ public static String encodeHtmlJsSafe(CharSequence original) { if (original == null) return ""; StringBuilder newValue = new StringBuilder(original); for (int i = 0; i < newValue.length(); i++) { char curChar = newValue.charAt(i); switch (curChar) { case '\'': newValue.replace(i, i + 1, "\\'"); i += 1; break; case '"': newValue.replace(i, i + 1, """); i += 5; break; case '&': newValue.replace(i, i + 1, "&"); i += 4; break; case '<': newValue.replace(i, i + 1, "<"); i += 3; break; case '>': newValue.replace(i, i + 1, ">"); i += 3; break; case '\n': newValue.replace(i, i + 1, "\\n"); i += 1; break; case '\r': newValue.replace(i, i + 1, "\\r"); i += 1; break; case 0x5: newValue.replace(i, i + 1, "..."); i += 2; break; case 0x12: newValue.replace(i, i + 1, "'"); i += 5; break; case 0x13: newValue.replace(i, i + 1, """); i += 5; break; case 0x14: newValue.replace(i, i + 1, """); i += 5; break; case 0x16: newValue.replace(i, i + 1, "-"); break; case 0x17: newValue.replace(i, i + 1, "-"); break; case 0x19: newValue.replace(i, i + 1, "tm"); i++; break; } } return newValue.toString(); } public static String encodeHtmlJsSafeObject(Object value) { if (value == null) { return "null"; } else if (value instanceof Collection) { return encodeHtmlJsSafeCollection((Collection) value); } else if (value instanceof Map) { return encodeHtmlJsSafeMap((Map) value); } else if (value instanceof Number) { if (value instanceof BigDecimal) return ((BigDecimal) value).toPlainString(); else return value.toString(); } else if (value instanceof Boolean) { Boolean boolVal = (Boolean) value; return boolVal ? "true" : "false"; } else { return "'" + encodeHtmlJsSafe(value.toString()) + "'"; } } public static String encodeHtmlJsSafeMap(Map fieldValues) { if (fieldValues == null) return "null"; StringBuilder out = new StringBuilder().append("{"); boolean isFirst = true; for (Object entryObj : fieldValues.entrySet()) { Map.Entry entry = (Map.Entry) entryObj; Object key = entry.getKey(); if (key == null) continue; if (isFirst) { isFirst = false; } else { out.append(","); } out.append("'").append(encodeHtmlJsSafe(key.toString())).append("':"); Object value = entry.getValue(); out.append(encodeHtmlJsSafeObject(value)); } out.append("}"); return out.toString(); } public static String encodeHtmlJsSafeCollection(Collection value) { if (value == null) return "null"; StringBuilder out = new StringBuilder(); out.append("["); if (value instanceof RandomAccess) { List curList = (List) value; int curListSize = curList.size(); for (int vi = 0; vi < curListSize; vi++) { Object listVal = curList.get(vi); out.append(encodeHtmlJsSafeObject(listVal)); if ((vi + 1) < curListSize) out.append(","); } } else { Iterator colIter = value.iterator(); while (colIter.hasNext()) { Object colVal = colIter.next(); out.append(encodeHtmlJsSafeObject(colVal)); if (colIter.hasNext()) out.append(","); } } out.append("]"); return out.toString(); } // for backward compatibility: public static String fieldValuesEncodeHtmlJsSafe(Map fieldValues) { return encodeHtmlJsSafeMap(fieldValues); } public static String encodeHtml(String original) { if (original == null) return ""; StringBuilder newValue = new StringBuilder(original); for (int i = 0; i < newValue.length(); i++) { char curChar = newValue.charAt(i); switch (curChar) { case '\'': newValue.replace(i, i + 1, "'"); i += 4; break; case '"': newValue.replace(i, i + 1, """); i += 5; break; case '&': newValue.replace(i, i + 1, "&"); i += 4; break; case '<': newValue.replace(i, i + 1, "<"); i += 3; break; case '>': newValue.replace(i, i + 1, ">"); i += 3; break; case 0x5: newValue.replace(i, i + 1, "..."); i += 2; break; case 0x12: newValue.replace(i, i + 1, "'"); i += 5; break; case 0x13: newValue.replace(i, i + 1, """); i += 5; break; case 0x14: newValue.replace(i, i + 1, """); i += 5; break; case 0x16: newValue.replace(i, i + 1, "-"); break; case 0x17: newValue.replace(i, i + 1, "-"); break; case 0x19: newValue.replace(i, i + 1, "tm"); i++; break; } } return newValue.toString(); } /** Pattern may have a plain number, '*' for wildcard, or a '-' separated number range for each dot separated segment; * may also have multiple comma-separated patterns */ public static boolean ip4Matches(String patternString, String address) { if (patternString == null || patternString.isEmpty()) return true; if (address == null || address.isEmpty()) return false; String[] patterns = patternString.split(","); boolean anyMatches = false; for (int pi = 0; pi < patterns.length; pi++) { String pattern = patterns[pi].trim(); if (pattern.isEmpty()) continue; if (pattern.equals("*.*.*.*") || pattern.equals("*")) { anyMatches = true; break; } String[] patternArray = pattern.split("\\."); String[] addressArray = address.split("\\."); boolean allMatch = true; for (int i = 0; i < patternArray.length; i++) { String curPattern = patternArray[i]; String curAddress = addressArray[i]; if (curPattern.equals("*") || curPattern.equals(curAddress)) continue; if (curPattern.contains("-")) { byte min = Byte.parseByte(curPattern.split("-")[0]); byte max = Byte.parseByte(curPattern.split("-")[1]); byte ip = Byte.parseByte(curAddress); if (ip < min || ip > max) { allMatch = false; break; } } else { allMatch = false; break; } } if (allMatch) { anyMatches = true; break; } } return anyMatches; } public static byte[] windowsPex = {(byte) 0x4d, (byte) 0x5a}; public static byte[] linuxElf = {(byte) 0x7f, (byte) 0x45, (byte) 0x4c, (byte) 0x46}; public static byte[] javaClass = {(byte) 0xca, (byte) 0xfe, (byte) 0xba, (byte) 0xbe}; public static byte[] macOs = {(byte) 0xfe, (byte) 0xed, (byte) 0xfa, (byte) 0xce}; public static byte[][] allOsExecutables = {windowsPex, linuxElf, javaClass, macOs}; /** Looks for byte patterns for Windows Portable Executable (4d5a), Linux ELF (7f454c46), Java class (cafebabe), macOS (feedface) */ public static boolean isExecutable(FileItem item) throws IOException { InputStream is = item.getInputStream(); byte[] bytes = new byte[4]; is.read(bytes, 0, 4); is.close(); return isExecutable(bytes); } /** Looks for byte patterns for Windows Portable Executable (4d5a), Linux ELF (7f454c46), Java class (cafebabe), macOS (feedface) */ public static boolean isExecutable(byte[] bytes) { boolean foundPattern = false; for (int i = 0; i < allOsExecutables.length; i++) { byte[] execPattern = allOsExecutables[i]; boolean execMatches = true; for (int j = 0; j < execPattern.length; j++) { if (bytes[j] != execPattern[j]) { execMatches = false; break; } } if (execMatches) { foundPattern = true; break; } } return foundPattern; } public static String simpleHttpStringRequest(String location, String requestBody, String contentType) { if (contentType == null || contentType.isEmpty()) contentType = "text/plain"; String resultString = ""; SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true); ClientConnector clientConnector = new ClientConnector(); clientConnector.setSslContextFactory(sslContextFactory); HttpClient httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector)); try { httpClient.start(); Request request = httpClient.POST(location); if (requestBody != null && !requestBody.isEmpty()) request.body(new StringRequestContent(contentType, requestBody, StandardCharsets.UTF_8)); ContentResponse response = request.send(); resultString = StringUtilities.toStringCleanBom(response.getContent()); } catch (Exception e) { throw new BaseException("Error in http client request", e); } finally { try { httpClient.stop(); } catch (Exception e) { logger.error("Error stopping http client", e); } } return resultString; } public static String simpleHttpMapRequest(String location, Map requestMap) { String resultString = ""; SslContextFactory.Client sslContextFactory = new SslContextFactory.Client(true); ClientConnector clientConnector = new ClientConnector(); clientConnector.setSslContextFactory(sslContextFactory); HttpClient httpClient = new HttpClient(new HttpClientTransportDynamic(clientConnector)); try { httpClient.start(); Request request = httpClient.POST(location); if (requestMap != null) for (Object entryObj : requestMap.entrySet()) { Map.Entry requestEntry = (Map.Entry) entryObj; request.param(requestEntry.getKey() != null ? requestEntry.getKey().toString() : null, requestEntry.getValue() != null ? requestEntry.getValue().toString() : null); } ContentResponse response = request.send(); resultString = StringUtilities.toStringCleanBom(response.getContent()); } catch (Exception e) { throw new BaseException("Error in http client request", e); } finally { try { httpClient.stop(); } catch (Exception e) { logger.error("Error stopping http client", e); } } return resultString; } public static class SimpleEntry implements Map.Entry { protected String key; protected Object value; SimpleEntry(String key, Object value) { this.key = key; this.value = value; } public String getKey() { return key; } public Object getValue() { return value; } public Object setValue(Object v) { Object orig = value; value = v; return orig; } } public static Enumeration emptyStringEnum = new Enumeration() { @Override public boolean hasMoreElements() { return false; } @Override public String nextElement() { return null; } }; public static boolean testSerialization(String name, Object value) { // return true; /* for testing purposes only, don't enable by default: */ // logger.warn("Test ser " + name + "(" + (value != null ? value.getClass().getName() : "") + ":" + (value != null && value.getClass().getClassLoader() != null ? value.getClass().getClassLoader().getClass().getName() : "") + ")" + " value: " + value); if (value == null) return true; try { ObjectOutputStream out = new ObjectOutputStream(new ByteArrayOutputStream()); out.writeObject(value); out.close(); return true; } catch (IOException e) { logger.warn("Tried to set session attribute [" + name + "] with non-serializable value of type " + value.getClass().getName(), e); return false; } } public interface AttributeContainer { Enumeration getAttributeNames(); Object getAttribute(String name); void setAttribute(String name, Object value); void removeAttribute(String name); default List getAttributeNameList() { List nameList = new LinkedList<>(); Enumeration attrNames = getAttributeNames(); while (attrNames.hasMoreElements()) nameList.add(attrNames.nextElement()); return nameList; } } public static class ServletRequestContainer implements AttributeContainer { ServletRequest req; public ServletRequestContainer(ServletRequest request) { req = request; } @Override public Enumeration getAttributeNames() { return req.getAttributeNames(); } @Override public Object getAttribute(String name) { return req.getAttribute(name); } @Override public void setAttribute(String name, Object value) { if (!testSerialization(name, value)) return; req.setAttribute(name, value); } @Override public void removeAttribute(String name) { req.removeAttribute(name); } } public static class HttpSessionContainer implements AttributeContainer { HttpSession ses; public HttpSessionContainer(HttpSession session) { ses = session; } @Override public Enumeration getAttributeNames() { try { return ses.getAttributeNames(); } catch (IllegalStateException e) { logger.warn("Tried getAttributeNames() on invalidated session " + ses.getId() + ": " + e.toString()); return emptyStringEnum; } } @Override public Object getAttribute(String name) { try { return ses.getAttribute(name); } catch (IllegalStateException e) { logger.warn("Tried getAttribute(" + name + ") on invalidated session " + ses.getId(), BaseException.filterStackTrace(e)); return null; } } @Override public void setAttribute(String name, Object value) { if (!testSerialization(name, value)) return; try { ses.setAttribute(name, value); } catch (IllegalStateException e) { logger.warn("Tried setAttribute(" + name + ", " + value + ") on invalidated session " + ses.getId(), BaseException.filterStackTrace(e)); } } @Override public void removeAttribute(String name) { try { ses.removeAttribute(name); } catch (IllegalStateException e) { logger.warn("Tried removeAttribute(" + name + ") on invalidated session " + ses.getId() + ": " + e.toString()); } } } public static class ServletContextContainer implements AttributeContainer { ServletContext scxt; public ServletContextContainer(ServletContext servletContext) { scxt = servletContext; } @Override public Enumeration getAttributeNames() { return scxt.getAttributeNames(); } @Override public Object getAttribute(String name) { return scxt.getAttribute(name); } @Override public void setAttribute(String name, Object value) { scxt.setAttribute(name, value); } @Override public void removeAttribute(String name) { scxt.removeAttribute(name); } } static final Set keysToIgnore = new HashSet<>(Arrays.asList("javax.servlet.context.tempdir", "org.apache.catalina.jsp_classpath", "org.apache.commons.fileupload.servlet.FileCleanerCleanup.FileCleaningTracker")); public static class AttributeContainerMap implements Map { private AttributeContainer cont; public AttributeContainerMap(AttributeContainer container) { cont = container; } public int size() { return cont.getAttributeNameList().size(); } public boolean isEmpty() { return !cont.getAttributeNames().hasMoreElements(); } public boolean containsKey(Object o) { if (keysToIgnore.contains(o)) return false; Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (name.equals(o)) return true; } return false; } public boolean containsValue(Object o) { Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (keysToIgnore.contains(o)) continue; Object attrValue = cont.getAttribute(name); if (attrValue == null) { if (o == null) return true; } else { if (attrValue.equals(o)) return true; } } return false; } public Object get(Object o) { return cont.getAttribute((String) o); } public Object put(String s, Object o) { Object orig = cont.getAttribute(s); cont.setAttribute(s, o); return orig; } public Object remove(Object o) { Object orig = cont.getAttribute((String) o); cont.removeAttribute((String) o); return orig; } public void putAll(Map map) { // if (map == null) return; for (Entry entry : map.entrySet()) { cont.setAttribute((String) entry.getKey(), entry.getValue()); } } public void clear() { Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (!keysToIgnore.contains(name)) cont.removeAttribute(name); } } public @Nonnull Set keySet() { Set ks = new HashSet<>(); Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (!keysToIgnore.contains(name)) ks.add(name); } return ks; } public @Nonnull Collection values() { List values = new LinkedList<>(); Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (!keysToIgnore.contains(name)) values.add(cont.getAttribute(name)); } return values; } public @Nonnull Set> entrySet() { Set> es = new HashSet<>(); Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (!keysToIgnore.contains(name)) es.add(new SimpleEntry(name, cont.getAttribute(name))); } return es; } @Override public String toString() { StringBuilder sb = new StringBuilder("["); Enumeration attrNames = cont.getAttributeNames(); while (attrNames.hasMoreElements()) { String name = attrNames.nextElement(); if (sb.length() > 1) sb.append(", "); sb.append(name).append(":").append(cont.getAttribute(name)); } sb.append("]"); return sb.toString(); } } @SuppressWarnings("unchecked") public static class CanonicalizeMap implements Map { Map mp; boolean supportsNull = true; public CanonicalizeMap(Map map) { mp = map; if (mp instanceof Hashtable) supportsNull = false; } public int size() { return mp.size(); } public boolean isEmpty() { return mp.isEmpty(); } public boolean containsKey(Object o) { return !(o == null && !supportsNull) && mp.containsKey(o); } public boolean containsValue(Object o) { return mp.containsValue(o); } public Object get(Object o) { return (o == null && !supportsNull) ? null : canonicalizeValue(mp.get(o)); } public Object put(String k, Object v) { return canonicalizeValue(mp.put(k, v)); } public Object remove(Object o) { return (o == null && !supportsNull) ? null : canonicalizeValue(mp.remove(o)); } public void putAll(Map map) { mp.putAll(map); } public void clear() { mp.clear(); } public @Nonnull Set keySet() { return mp.keySet(); } public @Nonnull Collection values() { List values = new ArrayList<>(mp.size()); for (Object orig : mp.values()) values.add(canonicalizeValue(orig)); return values; } public @Nonnull Set> entrySet() { Set> es = new HashSet<>(); for (Object entryObj : mp.entrySet()) { Entry entry = (Entry) entryObj; es.add(new CanonicalizeEntry(entry.getKey().toString(), entry.getValue())); } return es; } } private static class CanonicalizeEntry implements Map.Entry { protected String key; protected Object value; CanonicalizeEntry(String key, Object value) { this.key = key; this.value = value; } // CanonicalizeEntry(Map.Entry entry) { this.key = entry.getKey(); this.value = entry.getValue(); } public String getKey() { return key; } public Object getValue() { return canonicalizeValue(value); } public Object setValue(Object v) { Object orig = value; value = v; return orig; } } } ================================================ FILE: framework/src/main/resources/META-INF/jakarta.mime.types ================================================ # This file maps Internet media types to unique file extension(s). # Although created for httpd, this file is used by many software systems # and has been placed in the public domain for unlimited redisribution. # # The table below contains both registered and (common) unregistered types. # A type that has no unique extension can be ignored -- they are listed # here to guide configurations toward known types and to make it easier to # identify "new" types. File extensions are also commonly used to indicate # content languages and encodings, so choose them carefully. # # Internet media types should be registered as described in RFC 4288. # The registry is at . # # MIME type Extensions # application/3gpp-ims+xml # application/activemessage application/andrew-inset ez # application/applefile application/applixware aw application/atom+xml atom application/atomcat+xml atomcat # application/atomicmail application/atomsvc+xml atomsvc # application/auth-policy+xml # application/batch-smtp # application/beep+xml # application/cals-1840 application/ccxml+xml ccxml # application/cea-2018+xml # application/cellml+xml # application/cnrp+xml # application/commonground # application/conference-info+xml # application/cpl+xml # application/csta+xml # application/cstadata+xml application/cu-seeme cu # application/cybercash application/davmount+xml davmount # application/dca-rft # application/dec-dx # application/dialog-info+xml # application/dicom # application/dns application/dssc+der dssc application/dssc+xml xdssc # application/dvcs application/ecmascript ecma # application/edi-consent # application/edi-x12 # application/edifact application/emma+xml emma # application/epp+xml application/epub+zip epub # application/eshop # application/example # application/fastinfoset # application/fastsoap # application/fits application/font-tdpfr pfr # application/h224 # application/held+xml # application/http application/hyperstudio stk # application/ibe-key-request+xml # application/ibe-pkg-reply+xml # application/ibe-pp-data # application/iges # application/im-iscomposing+xml # application/index # application/index.cmd # application/index.obj # application/index.response # application/index.vnd # application/iotp application/ipfix ipfix # application/ipp # application/isup application/java-archive jar application/java-serialized-object ser application/java-vm class application/javascript js application/json json # application/kpml-request+xml # application/kpml-response+xml application/lost+xml lostxml application/mac-binhex40 hqx application/mac-compactpro cpt # application/macwriteii application/marc mrc application/mathematica ma nb mb application/mathml+xml mathml # application/mbms-associated-procedure-description+xml # application/mbms-deregister+xml # application/mbms-envelope+xml # application/mbms-msk+xml # application/mbms-msk-response+xml # application/mbms-protection-description+xml # application/mbms-reception-report+xml # application/mbms-register+xml # application/mbms-register-response+xml # application/mbms-user-service-description+xml application/mbox mbox # application/media_control+xml application/mediaservercontrol+xml mscml # application/mikey # application/moss-keys # application/moss-signature # application/mosskey-data # application/mosskey-request application/mp4 mp4s # application/mpeg4-generic # application/mpeg4-iod # application/mpeg4-iod-xmt application/msword doc dot application/mxf mxf # application/nasdata # application/news-checkgroups # application/news-groupinfo # application/news-transmission # application/nss # application/ocsp-request # application/ocsp-response application/octet-stream bin dms lha lrf lzh so iso dmg dist distz pkg bpk dump elc deploy application/oda oda application/oebps-package+xml opf application/ogg ogx application/onenote onetoc onetoc2 onetmp onepkg # application/parityfec application/patch-ops-error+xml xer application/pdf pdf application/pgp-encrypted pgp # application/pgp-keys application/pgp-signature asc sig application/pics-rules prf # application/pidf+xml # application/pidf-diff+xml application/pkcs10 p10 application/pkcs7-mime p7m p7c application/pkcs7-signature p7s application/pkix-cert cer application/pkix-crl crl application/pkix-pkipath pkipath application/pkixcmp pki application/pls+xml pls # application/poc-settings+xml application/postscript ai eps ps # application/prs.alvestrand.titrax-sheet application/prs.cww cww # application/prs.nprend # application/prs.plucker # application/qsig application/rdf+xml rdf application/reginfo+xml rif application/relax-ng-compact-syntax rnc # application/remote-printing application/resource-lists+xml rl application/resource-lists-diff+xml rld # application/riscos # application/rlmi+xml application/rls-services+xml rs application/rsd+xml rsd application/rss+xml rss application/rtf rtf # application/rtx # application/samlassertion+xml # application/samlmetadata+xml application/sbml+xml sbml application/scvp-cv-request scq application/scvp-cv-response scs application/scvp-vp-request spq application/scvp-vp-response spp application/sdp sdp # application/set-payment application/set-payment-initiation setpay # application/set-registration application/set-registration-initiation setreg # application/sgml # application/sgml-open-catalog application/shf+xml shf # application/sieve # application/simple-filter+xml # application/simple-message-summary # application/simplesymbolcontainer # application/slate # application/smil application/smil+xml smi smil # application/soap+fastinfoset # application/soap+xml application/sparql-query rq application/sparql-results+xml srx # application/spirits-event+xml application/srgs gram application/srgs+xml grxml application/ssml+xml ssml # application/timestamp-query # application/timestamp-reply # application/tve-trigger # application/ulpfec # application/vemmi # application/vividence.scriptfile # application/vnd.3gpp.bsf+xml application/vnd.3gpp.pic-bw-large plb application/vnd.3gpp.pic-bw-small psb application/vnd.3gpp.pic-bw-var pvb # application/vnd.3gpp.sms # application/vnd.3gpp2.bcmcsinfo+xml # application/vnd.3gpp2.sms application/vnd.3gpp2.tcap tcap application/vnd.3m.post-it-notes pwn application/vnd.accpac.simply.aso aso application/vnd.accpac.simply.imp imp application/vnd.acucobol acu application/vnd.acucorp atc acutc application/vnd.adobe.air-application-installer-package+zip air # application/vnd.adobe.partial-upload application/vnd.adobe.xdp+xml xdp application/vnd.adobe.xfdf xfdf # application/vnd.aether.imp application/vnd.airzip.filesecure.azf azf application/vnd.airzip.filesecure.azs azs application/vnd.amazon.ebook azw application/vnd.americandynamics.acc acc application/vnd.amiga.ami ami application/vnd.android.package-archive apk application/vnd.anser-web-certificate-issue-initiation cii application/vnd.anser-web-funds-transfer-initiation fti application/vnd.antix.game-component atx application/vnd.apple.installer+xml mpkg application/vnd.apple.mpegurl m3u8 # application/vnd.arastra.swi application/vnd.aristanetworks.swi swi application/vnd.audiograph aep # application/vnd.autopackage # application/vnd.avistar+xml application/vnd.blueice.multipass mpm # application/vnd.bluetooth.ep.oob application/vnd.bmi bmi application/vnd.businessobjects rep # application/vnd.cab-jscript # application/vnd.canon-cpdl # application/vnd.canon-lips # application/vnd.cendio.thinlinc.clientconf application/vnd.chemdraw+xml cdxml application/vnd.chipnuts.karaoke-mmd mmd application/vnd.cinderella cdy # application/vnd.cirpack.isdn-ext application/vnd.claymore cla application/vnd.cloanto.rp9 rp9 application/vnd.clonk.c4group c4g c4d c4f c4p c4u # application/vnd.commerce-battelle application/vnd.commonspace csp application/vnd.contact.cmsg cdbcmsg application/vnd.cosmocaller cmc application/vnd.crick.clicker clkx application/vnd.crick.clicker.keyboard clkk application/vnd.crick.clicker.palette clkp application/vnd.crick.clicker.template clkt application/vnd.crick.clicker.wordbank clkw application/vnd.criticaltools.wbs+xml wbs application/vnd.ctc-posml pml # application/vnd.ctct.ws+xml # application/vnd.cups-pdf # application/vnd.cups-postscript application/vnd.cups-ppd ppd # application/vnd.cups-raster # application/vnd.cups-raw application/vnd.curl.car car application/vnd.curl.pcurl pcurl # application/vnd.cybank application/vnd.data-vision.rdz rdz application/vnd.denovo.fcselayout-link fe_launch # application/vnd.dir-bi.plate-dl-nosuffix application/vnd.dna dna application/vnd.dolby.mlp mlp # application/vnd.dolby.mobile.1 # application/vnd.dolby.mobile.2 application/vnd.dpgraph dpg application/vnd.dreamfactory dfac # application/vnd.dvb.esgcontainer # application/vnd.dvb.ipdcdftnotifaccess # application/vnd.dvb.ipdcesgaccess # application/vnd.dvb.ipdcroaming # application/vnd.dvb.iptv.alfec-base # application/vnd.dvb.iptv.alfec-enhancement # application/vnd.dvb.notif-aggregate-root+xml # application/vnd.dvb.notif-container+xml # application/vnd.dvb.notif-generic+xml # application/vnd.dvb.notif-ia-msglist+xml # application/vnd.dvb.notif-ia-registration-request+xml # application/vnd.dvb.notif-ia-registration-response+xml # application/vnd.dvb.notif-init+xml # application/vnd.dxr application/vnd.dynageo geo # application/vnd.ecdis-update application/vnd.ecowin.chart mag # application/vnd.ecowin.filerequest # application/vnd.ecowin.fileupdate # application/vnd.ecowin.series # application/vnd.ecowin.seriesrequest # application/vnd.ecowin.seriesupdate # application/vnd.emclient.accessrequest+xml application/vnd.enliven nml application/vnd.epson.esf esf application/vnd.epson.msf msf application/vnd.epson.quickanime qam application/vnd.epson.salt slt application/vnd.epson.ssf ssf # application/vnd.ericsson.quickcall application/vnd.eszigno3+xml es3 et3 # application/vnd.etsi.aoc+xml # application/vnd.etsi.cug+xml # application/vnd.etsi.iptvcommand+xml # application/vnd.etsi.iptvdiscovery+xml # application/vnd.etsi.iptvprofile+xml # application/vnd.etsi.iptvsad-bc+xml # application/vnd.etsi.iptvsad-cod+xml # application/vnd.etsi.iptvsad-npvr+xml # application/vnd.etsi.iptvueprofile+xml # application/vnd.etsi.mcid+xml # application/vnd.etsi.sci+xml # application/vnd.etsi.simservs+xml # application/vnd.etsi.tsl+xml # application/vnd.etsi.tsl.der # application/vnd.eudora.data application/vnd.ezpix-album ez2 application/vnd.ezpix-package ez3 # application/vnd.f-secure.mobile application/vnd.fdf fdf application/vnd.fdsn.mseed mseed application/vnd.fdsn.seed seed dataless # application/vnd.ffsns # application/vnd.fints application/vnd.flographit gph application/vnd.fluxtime.clip ftc # application/vnd.font-fontforge-sfd application/vnd.framemaker fm frame maker book application/vnd.frogans.fnc fnc application/vnd.frogans.ltf ltf application/vnd.fsc.weblaunch fsc application/vnd.fujitsu.oasys oas application/vnd.fujitsu.oasys2 oa2 application/vnd.fujitsu.oasys3 oa3 application/vnd.fujitsu.oasysgp fg5 application/vnd.fujitsu.oasysprs bh2 # application/vnd.fujixerox.art-ex # application/vnd.fujixerox.art4 # application/vnd.fujixerox.hbpl application/vnd.fujixerox.ddd ddd application/vnd.fujixerox.docuworks xdw application/vnd.fujixerox.docuworks.binder xbd # application/vnd.fut-misnet application/vnd.fuzzysheet fzs application/vnd.genomatix.tuxedo txd # application/vnd.geocube+xml application/vnd.geogebra.file ggb application/vnd.geogebra.tool ggt application/vnd.geometry-explorer gex gre application/vnd.geonext gxt application/vnd.geoplan g2w application/vnd.geospace g3w # application/vnd.globalplatform.card-content-mgt # application/vnd.globalplatform.card-content-mgt-response application/vnd.gmx gmx application/vnd.google-earth.kml+xml kml application/vnd.google-earth.kmz kmz application/vnd.grafeq gqf gqs # application/vnd.gridmp application/vnd.groove-account gac application/vnd.groove-help ghf application/vnd.groove-identity-message gim application/vnd.groove-injector grv application/vnd.groove-tool-message gtm application/vnd.groove-tool-template tpl application/vnd.groove-vcard vcg application/vnd.handheld-entertainment+xml zmm application/vnd.hbci hbci # application/vnd.hcl-bireports application/vnd.hhe.lesson-player les application/vnd.hp-hpgl hpgl application/vnd.hp-hpid hpid application/vnd.hp-hps hps application/vnd.hp-jlyt jlt application/vnd.hp-pcl pcl application/vnd.hp-pclxl pclxl # application/vnd.httphone application/vnd.hydrostatix.sof-data sfd-hdstx application/vnd.hzn-3d-crossword x3d # application/vnd.ibm.afplinedata # application/vnd.ibm.electronic-media application/vnd.ibm.minipay mpy application/vnd.ibm.modcap afp listafp list3820 application/vnd.ibm.rights-management irm application/vnd.ibm.secure-container sc application/vnd.iccprofile icc icm application/vnd.igloader igl application/vnd.immervision-ivp ivp application/vnd.immervision-ivu ivu # application/vnd.informedcontrol.rms+xml # application/vnd.informix-visionary application/vnd.intercon.formnet xpw xpx # application/vnd.intertrust.digibox # application/vnd.intertrust.nncp application/vnd.intu.qbo qbo application/vnd.intu.qfx qfx # application/vnd.iptc.g2.conceptitem+xml # application/vnd.iptc.g2.knowledgeitem+xml # application/vnd.iptc.g2.newsitem+xml # application/vnd.iptc.g2.packageitem+xml application/vnd.ipunplugged.rcprofile rcprofile application/vnd.irepository.package+xml irp application/vnd.is-xpr xpr application/vnd.jam jam # application/vnd.japannet-directory-service # application/vnd.japannet-jpnstore-wakeup # application/vnd.japannet-payment-wakeup # application/vnd.japannet-registration # application/vnd.japannet-registration-wakeup # application/vnd.japannet-setstore-wakeup # application/vnd.japannet-verification # application/vnd.japannet-verification-wakeup application/vnd.jcp.javame.midlet-rms rms application/vnd.jisp jisp application/vnd.joost.joda-archive joda application/vnd.kahootz ktz ktr application/vnd.kde.karbon karbon application/vnd.kde.kchart chrt application/vnd.kde.kformula kfo application/vnd.kde.kivio flw application/vnd.kde.kontour kon application/vnd.kde.kpresenter kpr kpt application/vnd.kde.kspread ksp application/vnd.kde.kword kwd kwt application/vnd.kenameaapp htke application/vnd.kidspiration kia application/vnd.kinar kne knp application/vnd.koan skp skd skt skm application/vnd.kodak-descriptor sse # application/vnd.liberty-request+xml application/vnd.llamagraphics.life-balance.desktop lbd application/vnd.llamagraphics.life-balance.exchange+xml lbe application/vnd.lotus-1-2-3 123 application/vnd.lotus-approach apr application/vnd.lotus-freelance pre application/vnd.lotus-notes nsf application/vnd.lotus-organizer org application/vnd.lotus-screencam scm application/vnd.lotus-wordpro lwp application/vnd.macports.portpkg portpkg # application/vnd.marlin.drm.actiontoken+xml # application/vnd.marlin.drm.conftoken+xml # application/vnd.marlin.drm.license+xml # application/vnd.marlin.drm.mdcf application/vnd.mcd mcd application/vnd.medcalcdata mc1 application/vnd.mediastation.cdkey cdkey # application/vnd.meridian-slingshot application/vnd.mfer mwf application/vnd.mfmp mfm application/vnd.micrografx.flo flo application/vnd.micrografx.igx igx application/vnd.mif mif # application/vnd.minisoft-hp3000-save # application/vnd.mitsubishi.misty-guard.trustweb application/vnd.mobius.daf daf application/vnd.mobius.dis dis application/vnd.mobius.mbk mbk application/vnd.mobius.mqy mqy application/vnd.mobius.msl msl application/vnd.mobius.plc plc application/vnd.mobius.txf txf application/vnd.mophun.application mpn application/vnd.mophun.certificate mpc # application/vnd.motorola.flexsuite # application/vnd.motorola.flexsuite.adsi # application/vnd.motorola.flexsuite.fis # application/vnd.motorola.flexsuite.gotap # application/vnd.motorola.flexsuite.kmr # application/vnd.motorola.flexsuite.ttc # application/vnd.motorola.flexsuite.wem # application/vnd.motorola.iprm application/vnd.mozilla.xul+xml xul application/vnd.ms-artgalry cil # application/vnd.ms-asf application/vnd.ms-cab-compressed cab application/vnd.ms-excel xls xlm xla xlc xlt xlw application/vnd.ms-excel.addin.macroenabled.12 xlam application/vnd.ms-excel.sheet.binary.macroenabled.12 xlsb application/vnd.ms-excel.sheet.macroenabled.12 xlsm application/vnd.ms-excel.template.macroenabled.12 xltm application/vnd.ms-fontobject eot application/vnd.ms-htmlhelp chm application/vnd.ms-ims ims application/vnd.ms-lrm lrm application/vnd.ms-pki.seccat cat application/vnd.ms-pki.stl stl # application/vnd.ms-playready.initiator+xml application/vnd.ms-powerpoint ppt pps pot application/vnd.ms-powerpoint.addin.macroenabled.12 ppam application/vnd.ms-powerpoint.presentation.macroenabled.12 pptm application/vnd.ms-powerpoint.slide.macroenabled.12 sldm application/vnd.ms-powerpoint.slideshow.macroenabled.12 ppsm application/vnd.ms-powerpoint.template.macroenabled.12 potm application/vnd.ms-project mpp mpt # application/vnd.ms-tnef # application/vnd.ms-wmdrm.lic-chlg-req # application/vnd.ms-wmdrm.lic-resp # application/vnd.ms-wmdrm.meter-chlg-req # application/vnd.ms-wmdrm.meter-resp application/vnd.ms-word.document.macroenabled.12 docm application/vnd.ms-word.template.macroenabled.12 dotm application/vnd.ms-works wps wks wcm wdb application/vnd.ms-wpl wpl application/vnd.ms-xpsdocument xps application/vnd.mseq mseq # application/vnd.msign # application/vnd.multiad.creator # application/vnd.multiad.creator.cif # application/vnd.music-niff application/vnd.musician mus application/vnd.muvee.style msty # application/vnd.ncd.control # application/vnd.ncd.reference # application/vnd.nervana # application/vnd.netfpx application/vnd.neurolanguage.nlu nlu application/vnd.noblenet-directory nnd application/vnd.noblenet-sealer nns application/vnd.noblenet-web nnw # application/vnd.nokia.catalogs # application/vnd.nokia.conml+wbxml # application/vnd.nokia.conml+xml # application/vnd.nokia.isds-radio-presets # application/vnd.nokia.iptv.config+xml # application/vnd.nokia.landmark+wbxml # application/vnd.nokia.landmark+xml # application/vnd.nokia.landmarkcollection+xml # application/vnd.nokia.n-gage.ac+xml application/vnd.nokia.n-gage.data ngdat application/vnd.nokia.n-gage.symbian.install n-gage # application/vnd.nokia.ncd # application/vnd.nokia.pcd+wbxml # application/vnd.nokia.pcd+xml application/vnd.nokia.radio-preset rpst application/vnd.nokia.radio-presets rpss application/vnd.novadigm.edm edm application/vnd.novadigm.edx edx application/vnd.novadigm.ext ext # application/vnd.ntt-local.file-transfer application/vnd.oasis.opendocument.chart odc application/vnd.oasis.opendocument.chart-template otc application/vnd.oasis.opendocument.database odb application/vnd.oasis.opendocument.formula odf application/vnd.oasis.opendocument.formula-template odft application/vnd.oasis.opendocument.graphics odg application/vnd.oasis.opendocument.graphics-template otg application/vnd.oasis.opendocument.image odi application/vnd.oasis.opendocument.image-template oti application/vnd.oasis.opendocument.presentation odp application/vnd.oasis.opendocument.presentation-template otp application/vnd.oasis.opendocument.spreadsheet ods application/vnd.oasis.opendocument.spreadsheet-template ots application/vnd.oasis.opendocument.text odt application/vnd.oasis.opendocument.text-master otm application/vnd.oasis.opendocument.text-template ott application/vnd.oasis.opendocument.text-web oth # application/vnd.obn application/vnd.olpc-sugar xo # application/vnd.oma-scws-config # application/vnd.oma-scws-http-request # application/vnd.oma-scws-http-response # application/vnd.oma.bcast.associated-procedure-parameter+xml # application/vnd.oma.bcast.drm-trigger+xml # application/vnd.oma.bcast.imd+xml # application/vnd.oma.bcast.ltkm # application/vnd.oma.bcast.notification+xml # application/vnd.oma.bcast.provisioningtrigger # application/vnd.oma.bcast.sgboot # application/vnd.oma.bcast.sgdd+xml # application/vnd.oma.bcast.sgdu # application/vnd.oma.bcast.simple-symbol-container # application/vnd.oma.bcast.smartcard-trigger+xml # application/vnd.oma.bcast.sprov+xml # application/vnd.oma.bcast.stkm # application/vnd.oma.dcd # application/vnd.oma.dcdc application/vnd.oma.dd2+xml dd2 # application/vnd.oma.drm.risd+xml # application/vnd.oma.group-usage-list+xml # application/vnd.oma.poc.detailed-progress-report+xml # application/vnd.oma.poc.final-report+xml # application/vnd.oma.poc.groups+xml # application/vnd.oma.poc.invocation-descriptor+xml # application/vnd.oma.poc.optimized-progress-report+xml # application/vnd.oma.push # application/vnd.oma.scidm.messages+xml # application/vnd.oma.xcap-directory+xml # application/vnd.omads-email+xml # application/vnd.omads-file+xml # application/vnd.omads-folder+xml # application/vnd.omaloc-supl-init application/vnd.openofficeorg.extension oxt # application/vnd.openxmlformats-officedocument.custom-properties+xml # application/vnd.openxmlformats-officedocument.customxmlproperties+xml # application/vnd.openxmlformats-officedocument.drawing+xml # application/vnd.openxmlformats-officedocument.drawingml.chart+xml # application/vnd.openxmlformats-officedocument.drawingml.chartshapes+xml # application/vnd.openxmlformats-officedocument.drawingml.diagramcolors+xml # application/vnd.openxmlformats-officedocument.drawingml.diagramdata+xml # application/vnd.openxmlformats-officedocument.drawingml.diagramlayout+xml # application/vnd.openxmlformats-officedocument.drawingml.diagramstyle+xml # application/vnd.openxmlformats-officedocument.extended-properties+xml # application/vnd.openxmlformats-officedocument.presentationml.commentauthors+xml # application/vnd.openxmlformats-officedocument.presentationml.comments+xml # application/vnd.openxmlformats-officedocument.presentationml.handoutmaster+xml # application/vnd.openxmlformats-officedocument.presentationml.notesmaster+xml # application/vnd.openxmlformats-officedocument.presentationml.notesslide+xml application/vnd.openxmlformats-officedocument.presentationml.presentation pptx # application/vnd.openxmlformats-officedocument.presentationml.presentation.main+xml # application/vnd.openxmlformats-officedocument.presentationml.presprops+xml application/vnd.openxmlformats-officedocument.presentationml.slide sldx # application/vnd.openxmlformats-officedocument.presentationml.slide+xml # application/vnd.openxmlformats-officedocument.presentationml.slidelayout+xml # application/vnd.openxmlformats-officedocument.presentationml.slidemaster+xml application/vnd.openxmlformats-officedocument.presentationml.slideshow ppsx # application/vnd.openxmlformats-officedocument.presentationml.slideshow.main+xml # application/vnd.openxmlformats-officedocument.presentationml.slideupdateinfo+xml # application/vnd.openxmlformats-officedocument.presentationml.tablestyles+xml # application/vnd.openxmlformats-officedocument.presentationml.tags+xml application/vnd.openxmlformats-officedocument.presentationml.template potx # application/vnd.openxmlformats-officedocument.presentationml.template.main+xml # application/vnd.openxmlformats-officedocument.presentationml.viewprops+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.calcchain+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.chartsheet+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.comments+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.connections+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.dialogsheet+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.externallink+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcachedefinition+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.pivotcacherecords+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.pivottable+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.querytable+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.revisionheaders+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.revisionlog+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.sharedstrings+xml application/vnd.openxmlformats-officedocument.spreadsheetml.sheet xlsx # application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.sheetmetadata+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.styles+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.table+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.tablesinglecells+xml application/vnd.openxmlformats-officedocument.spreadsheetml.template xltx # application/vnd.openxmlformats-officedocument.spreadsheetml.template.main+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.usernames+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.volatiledependencies+xml # application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml # application/vnd.openxmlformats-officedocument.theme+xml # application/vnd.openxmlformats-officedocument.themeoverride+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.comments+xml application/vnd.openxmlformats-officedocument.wordprocessingml.document docx # application/vnd.openxmlformats-officedocument.wordprocessingml.document.glossary+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.document.main+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.endnotes+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.fonttable+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.footer+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.footnotes+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.numbering+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.settings+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.styles+xml application/vnd.openxmlformats-officedocument.wordprocessingml.template dotx # application/vnd.openxmlformats-officedocument.wordprocessingml.template.main+xml # application/vnd.openxmlformats-officedocument.wordprocessingml.websettings+xml # application/vnd.openxmlformats-package.core-properties+xml # application/vnd.openxmlformats-package.digital-signature-xmlsignature+xml # application/vnd.osa.netdeploy # application/vnd.osgi.bundle application/vnd.osgi.dp dp # application/vnd.otps.ct-kip+xml application/vnd.palm pdb pqa oprc # application/vnd.paos.xml application/vnd.pawaafile paw application/vnd.pg.format str application/vnd.pg.osasli ei6 # application/vnd.piaccess.application-licence application/vnd.picsel efif application/vnd.pmi.widget wg # application/vnd.poc.group-advertisement+xml application/vnd.pocketlearn plf application/vnd.powerbuilder6 pbd # application/vnd.powerbuilder6-s # application/vnd.powerbuilder7 # application/vnd.powerbuilder7-s # application/vnd.powerbuilder75 # application/vnd.powerbuilder75-s # application/vnd.preminet application/vnd.previewsystems.box box application/vnd.proteus.magazine mgz application/vnd.publishare-delta-tree qps application/vnd.pvi.ptid1 ptid # application/vnd.pwg-multiplexed # application/vnd.pwg-xhtml-print+xml # application/vnd.qualcomm.brew-app-res application/vnd.quark.quarkxpress qxd qxt qwd qwt qxl qxb # application/vnd.radisys.moml+xml # application/vnd.radisys.msml+xml # application/vnd.radisys.msml-audit+xml # application/vnd.radisys.msml-audit-conf+xml # application/vnd.radisys.msml-audit-conn+xml # application/vnd.radisys.msml-audit-dialog+xml # application/vnd.radisys.msml-audit-stream+xml # application/vnd.radisys.msml-conf+xml # application/vnd.radisys.msml-dialog+xml # application/vnd.radisys.msml-dialog-base+xml # application/vnd.radisys.msml-dialog-fax-detect+xml # application/vnd.radisys.msml-dialog-fax-sendrecv+xml # application/vnd.radisys.msml-dialog-group+xml # application/vnd.radisys.msml-dialog-speech+xml # application/vnd.radisys.msml-dialog-transform+xml # application/vnd.rapid application/vnd.realvnc.bed bed application/vnd.recordare.musicxml mxl application/vnd.recordare.musicxml+xml musicxml # application/vnd.renlearn.rlprint application/vnd.rim.cod cod application/vnd.rn-realmedia rm application/vnd.route66.link66+xml link66 # application/vnd.ruckus.download # application/vnd.s3sms application/vnd.sailingtracker.track st # application/vnd.sbm.cid # application/vnd.sbm.mid2 # application/vnd.scribus # application/vnd.sealed.3df # application/vnd.sealed.csf # application/vnd.sealed.doc # application/vnd.sealed.eml # application/vnd.sealed.mht # application/vnd.sealed.net # application/vnd.sealed.ppt # application/vnd.sealed.tiff # application/vnd.sealed.xls # application/vnd.sealedmedia.softseal.html # application/vnd.sealedmedia.softseal.pdf application/vnd.seemail see application/vnd.sema sema application/vnd.semd semd application/vnd.semf semf application/vnd.shana.informed.formdata ifm application/vnd.shana.informed.formtemplate itp application/vnd.shana.informed.interchange iif application/vnd.shana.informed.package ipk application/vnd.simtech-mindmapper twd twds application/vnd.smaf mmf # application/vnd.smart.notebook application/vnd.smart.teacher teacher # application/vnd.software602.filler.form+xml # application/vnd.software602.filler.form-xml-zip application/vnd.solent.sdkm+xml sdkm sdkd application/vnd.spotfire.dxp dxp application/vnd.spotfire.sfs sfs # application/vnd.sss-cod # application/vnd.sss-dtf # application/vnd.sss-ntf application/vnd.stardivision.calc sdc application/vnd.stardivision.draw sda application/vnd.stardivision.impress sdd application/vnd.stardivision.math smf application/vnd.stardivision.writer sdw application/vnd.stardivision.writer vor application/vnd.stardivision.writer-global sgl # application/vnd.street-stream application/vnd.sun.xml.calc sxc application/vnd.sun.xml.calc.template stc application/vnd.sun.xml.draw sxd application/vnd.sun.xml.draw.template std application/vnd.sun.xml.impress sxi application/vnd.sun.xml.impress.template sti application/vnd.sun.xml.math sxm application/vnd.sun.xml.writer sxw application/vnd.sun.xml.writer.global sxg application/vnd.sun.xml.writer.template stw # application/vnd.sun.wadl+xml application/vnd.sus-calendar sus susp application/vnd.svd svd # application/vnd.swiftview-ics application/vnd.symbian.install sis sisx application/vnd.syncml+xml xsm application/vnd.syncml.dm+wbxml bdm application/vnd.syncml.dm+xml xdm # application/vnd.syncml.dm.notification # application/vnd.syncml.ds.notification application/vnd.tao.intent-module-archive tao application/vnd.tmobile-livetv tmo application/vnd.trid.tpt tpt application/vnd.triscape.mxs mxs application/vnd.trueapp tra # application/vnd.truedoc application/vnd.ufdl ufd ufdl application/vnd.uiq.theme utz application/vnd.umajin umj application/vnd.unity unityweb application/vnd.uoml+xml uoml # application/vnd.uplanet.alert # application/vnd.uplanet.alert-wbxml # application/vnd.uplanet.bearer-choice # application/vnd.uplanet.bearer-choice-wbxml # application/vnd.uplanet.cacheop # application/vnd.uplanet.cacheop-wbxml # application/vnd.uplanet.channel # application/vnd.uplanet.channel-wbxml # application/vnd.uplanet.list # application/vnd.uplanet.list-wbxml # application/vnd.uplanet.listcmd # application/vnd.uplanet.listcmd-wbxml # application/vnd.uplanet.signal application/vnd.vcx vcx # application/vnd.vd-study # application/vnd.vectorworks # application/vnd.vidsoft.vidconference application/vnd.visio vsd vst vss vsw application/vnd.visionary vis # application/vnd.vividence.scriptfile application/vnd.vsf vsf # application/vnd.wap.sic # application/vnd.wap.slc application/vnd.wap.wbxml wbxml application/vnd.wap.wmlc wmlc application/vnd.wap.wmlscriptc wmlsc application/vnd.webturbo wtb # application/vnd.wfa.wsc # application/vnd.wmc # application/vnd.wmf.bootstrap # application/vnd.wolfram.mathematica # application/vnd.wolfram.mathematica.package application/vnd.wolfram.player nbp application/vnd.wordperfect wpd application/vnd.wqd wqd # application/vnd.wrq-hp3000-labelled application/vnd.wt.stf stf # application/vnd.wv.csp+wbxml # application/vnd.wv.csp+xml # application/vnd.wv.ssp+xml application/vnd.xara xar application/vnd.xfdl xfdl # application/vnd.xfdl.webform # application/vnd.xmi+xml # application/vnd.xmpie.cpkg # application/vnd.xmpie.dpkg # application/vnd.xmpie.plan # application/vnd.xmpie.ppkg # application/vnd.xmpie.xlim application/vnd.yamaha.hv-dic hvd application/vnd.yamaha.hv-script hvs application/vnd.yamaha.hv-voice hvp application/vnd.yamaha.openscoreformat osf application/vnd.yamaha.openscoreformat.osfpvg+xml osfpvg application/vnd.yamaha.smaf-audio saf application/vnd.yamaha.smaf-phrase spf application/vnd.yellowriver-custom-menu cmp application/vnd.zul zir zirz application/vnd.zzazz.deck+xml zaz application/voicexml+xml vxml # application/watcherinfo+xml # application/whoispp-query # application/whoispp-response application/winhlp hlp # application/wita # application/wordperfect5.1 application/wsdl+xml wsdl application/wspolicy+xml wspolicy application/x-abiword abw application/x-ace-compressed ace application/x-authorware-bin aab x32 u32 vox application/x-authorware-map aam application/x-authorware-seg aas application/x-bcpio bcpio application/x-bittorrent torrent application/x-bzip bz application/x-bzip2 bz2 boz application/x-cdlink vcd application/x-chat chat application/x-chess-pgn pgn # application/x-compress application/x-cpio cpio application/x-csh csh application/x-debian-package deb udeb application/x-director dir dcr dxr cst cct cxt w3d fgd swa application/x-doom wad application/x-dtbncx+xml ncx application/x-dtbook+xml dtb application/x-dtbresource+xml res application/x-dvi dvi application/x-font-bdf bdf # application/x-font-dos # application/x-font-framemaker application/x-font-ghostscript gsf # application/x-font-libgrx application/x-font-linux-psf psf application/x-font-otf otf application/x-font-pcf pcf application/x-font-snf snf # application/x-font-speedo # application/x-font-sunos-news application/x-font-ttf ttf ttc application/x-font-type1 pfa pfb pfm afm # application/x-font-vfont application/x-futuresplash spl application/x-gnumeric gnumeric application/x-gtar gtar # application/x-gzip application/x-hdf hdf application/x-java-jnlp-file jnlp application/x-latex latex application/x-mobipocket-ebook prc mobi application/x-ms-application application application/x-ms-wmd wmd application/x-ms-wmz wmz application/x-ms-xbap xbap application/x-msaccess mdb application/x-msbinder obd application/x-mscardfile crd application/x-msclip clp application/x-msdownload exe dll com bat msi application/x-msmediaview mvb m13 m14 application/x-msmetafile wmf application/x-msmoney mny application/x-mspublisher pub application/x-msschedule scd application/x-msterminal trm application/x-mswrite wri application/x-netcdf nc cdf application/x-pkcs12 p12 pfx application/x-pkcs7-certificates p7b spc application/x-pkcs7-certreqresp p7r application/x-rar-compressed rar application/x-sh sh application/x-shar shar application/x-shockwave-flash swf application/x-silverlight-app xap application/x-stuffit sit application/x-stuffitx sitx application/x-sv4cpio sv4cpio application/x-sv4crc sv4crc application/x-tar tar application/x-tcl tcl application/x-tex tex application/x-tex-tfm tfm application/x-texinfo texinfo texi application/x-ustar ustar application/x-wais-source src application/x-x509-ca-cert der crt application/x-xfig fig application/x-xpinstall xpi # application/x400-bp # application/xcap-att+xml # application/xcap-caps+xml # application/xcap-el+xml # application/xcap-error+xml # application/xcap-ns+xml # application/xcon-conference-info-diff+xml # application/xcon-conference-info+xml application/xenc+xml xenc application/xhtml+xml xhtml xht # application/xhtml-voice+xml # NOTE: using text/xml instead: application/xml xml xsl application/xml-dtd dtd # application/xml-external-parsed-entity # application/xmpp+xml application/xop+xml xop application/xslt+xml xslt application/xspf+xml xspf application/xv+xml mxml xhvml xvml xvm application/zip zip # audio/32kadpcm # audio/3gpp # audio/3gpp2 # audio/ac3 audio/adpcm adp # audio/amr # audio/amr-wb # audio/amr-wb+ # audio/asc # audio/atrac-advanced-lossless # audio/atrac-x # audio/atrac3 audio/basic au snd # audio/bv16 # audio/bv32 # audio/clearmode # audio/cn # audio/dat12 # audio/dls # audio/dsr-es201108 # audio/dsr-es202050 # audio/dsr-es202211 # audio/dsr-es202212 # audio/dvi4 # audio/eac3 # audio/evrc # audio/evrc-qcp # audio/evrc0 # audio/evrc1 # audio/evrcb # audio/evrcb0 # audio/evrcb1 # audio/evrcwb # audio/evrcwb0 # audio/evrcwb1 # audio/example # audio/g719 # audio/g722 # audio/g7221 # audio/g723 # audio/g726-16 # audio/g726-24 # audio/g726-32 # audio/g726-40 # audio/g728 # audio/g729 # audio/g7291 # audio/g729d # audio/g729e # audio/gsm # audio/gsm-efr # audio/ilbc # audio/l16 # audio/l20 # audio/l24 # audio/l8 # audio/lpc audio/midi mid midi kar rmi # audio/mobile-xmf audio/mp4 mp4a # audio/mp4a-latm # audio/mpa # audio/mpa-robust audio/mpeg mpga mp2 mp2a mp3 m2a m3a # audio/mpeg4-generic audio/ogg oga ogg spx # audio/parityfec # audio/pcma # audio/pcma-wb # audio/pcmu-wb # audio/pcmu # audio/prs.sid # audio/qcelp # audio/red # audio/rtp-enc-aescm128 # audio/rtp-midi # audio/rtx # audio/smv # audio/smv0 # audio/smv-qcp # audio/sp-midi # audio/speex # audio/t140c # audio/t38 # audio/telephone-event # audio/tone # audio/uemclip # audio/ulpfec # audio/vdvi # audio/vmr-wb # audio/vnd.3gpp.iufp # audio/vnd.4sb # audio/vnd.audiokoz # audio/vnd.celp # audio/vnd.cisco.nse # audio/vnd.cmles.radio-events # audio/vnd.cns.anp1 # audio/vnd.cns.inf1 audio/vnd.digital-winds eol # audio/vnd.dlna.adts # audio/vnd.dolby.heaac.1 # audio/vnd.dolby.heaac.2 # audio/vnd.dolby.mlp # audio/vnd.dolby.mps # audio/vnd.dolby.pl2 # audio/vnd.dolby.pl2x # audio/vnd.dolby.pl2z # audio/vnd.dolby.pulse.1 audio/vnd.dra dra audio/vnd.dts dts audio/vnd.dts.hd dtshd # audio/vnd.everad.plj # audio/vnd.hns.audio audio/vnd.lucent.voice lvp audio/vnd.ms-playready.media.pya pya # audio/vnd.nokia.mobile-xmf # audio/vnd.nortel.vbk audio/vnd.nuera.ecelp4800 ecelp4800 audio/vnd.nuera.ecelp7470 ecelp7470 audio/vnd.nuera.ecelp9600 ecelp9600 # audio/vnd.octel.sbc # audio/vnd.qcelp # audio/vnd.rhetorex.32kadpcm # audio/vnd.sealedmedia.softseal.mpeg # audio/vnd.vmx.cvsd # audio/vorbis # audio/vorbis-config audio/x-aac aac audio/x-aiff aif aiff aifc audio/x-mpegurl m3u audio/x-ms-wax wax audio/x-ms-wma wma audio/x-pn-realaudio ram ra audio/x-pn-realaudio-plugin rmp audio/x-wav wav chemical/x-cdx cdx chemical/x-cif cif chemical/x-cmdf cmdf chemical/x-cml cml chemical/x-csml csml # chemical/x-pdb chemical/x-xyz xyz image/bmp bmp image/cgm cgm # image/example # image/fits image/g3fax g3 image/gif gif image/ief ief # image/jp2 image/jpeg jpeg jpg jpe # image/jpm # image/jpx # image/naplps image/png png image/prs.btif btif # image/prs.pti image/svg+xml svg svgz # image/t38 image/tiff tiff tif # image/tiff-fx image/vnd.adobe.photoshop psd # image/vnd.cns.inf2 image/vnd.djvu djvu djv image/vnd.dwg dwg image/vnd.dxf dxf image/vnd.fastbidsheet fbs image/vnd.fpx fpx image/vnd.fst fst image/vnd.fujixerox.edmics-mmr mmr image/vnd.fujixerox.edmics-rlc rlc # image/vnd.globalgraphics.pgb # image/vnd.microsoft.icon # image/vnd.mix image/vnd.ms-modi mdi image/vnd.net-fpx npx # image/vnd.radiance # image/vnd.sealed.png # image/vnd.sealedmedia.softseal.gif # image/vnd.sealedmedia.softseal.jpg # image/vnd.svf image/vnd.wap.wbmp wbmp image/vnd.xiff xif image/x-cmu-raster ras image/x-cmx cmx image/x-freehand fh fhc fh4 fh5 fh7 image/x-icon ico image/x-pcx pcx image/x-pict pic pct image/x-portable-anymap pnm image/x-portable-bitmap pbm image/x-portable-graymap pgm image/x-portable-pixmap ppm image/x-rgb rgb image/x-xbitmap xbm image/x-xpixmap xpm image/x-xwindowdump xwd # message/cpim # message/delivery-status # message/disposition-notification # message/example # message/external-body # message/global # message/global-delivery-status # message/global-disposition-notification # message/global-headers # message/http # message/imdn+xml # message/news # message/partial message/rfc822 eml mime # message/s-http # message/sip # message/sipfrag # message/tracking-status # message/vnd.si.simp # model/example model/iges igs iges model/mesh msh mesh silo model/vnd.dwf dwf # model/vnd.flatland.3dml model/vnd.gdl gdl # model/vnd.gs-gdl # model/vnd.gs.gdl model/vnd.gtw gtw # model/vnd.moml+xml model/vnd.mts mts # model/vnd.parasolid.transmit.binary # model/vnd.parasolid.transmit.text model/vnd.vtu vtu model/vrml wrl vrml # multipart/alternative # multipart/appledouble # multipart/byteranges # multipart/digest # multipart/encrypted # multipart/example # multipart/form-data # multipart/header-set # multipart/mixed # multipart/parallel # multipart/related # multipart/report # multipart/signed # multipart/voice-message text/calendar ics ifb text/css css text/csv csv # text/directory # text/dns # text/ecmascript # text/enriched # text/example text/html html htm # text/javascript # text/parityfec text/plain txt text conf def list log in ini cwiki gstring mediawiki textile tracwiki twiki # text/prs.fallenstein.rst text/prs.lines.tag dsc # text/vnd.radisys.msml-basic-layout # text/red # text/rfc822-headers text/richtext rtx # text/rtf # text/rtp-enc-aescm128 # text/rtx text/sgml sgml sgm # text/t140 text/tab-separated-values tsv text/troff t tr roff man me ms # text/ulpfec text/uri-list uri uris urls # text/vnd.abc text/vnd.curl curl text/vnd.curl.dcurl dcurl text/vnd.curl.scurl scurl text/vnd.curl.mcurl mcurl # text/vnd.dmclientscript # text/vnd.esmertec.theme-descriptor text/vnd.fly fly text/vnd.fmi.flexstor flx text/vnd.graphviz gv text/vnd.in3d.3dml 3dml text/vnd.in3d.spot spot # text/vnd.iptc.newsml # text/vnd.iptc.nitf # text/vnd.latex-z # text/vnd.motorola.reflex # text/vnd.ms-mediapackage # text/vnd.net2phone.commcenter.command # text/vnd.si.uricatalogue text/vnd.sun.j2me.app-descriptor jad # text/vnd.trolltech.linguist # text/vnd.wap.si # text/vnd.wap.sl text/vnd.wap.wml wml text/vnd.wap.wmlscript wmls text/x-asm s asm text/x-c c cc cxx cpp h hh dic text/x-fortran f for f77 f90 text/x-freemarker ftl text/x-markdown md markdown text/x-pascal p pas text/x-java-source java text/x-java-properties properties text/x-typescript ts text/x-setext etx text/x-uuencode uu text/x-vcalendar vcs text/x-vcard vcf text/xml xml xsd xsl # text/xml-external-parsed-entity video/3gpp 3gp # video/3gpp-tt video/3gpp2 3g2 # video/bmpeg # video/bt656 # video/celb # video/dv # video/example video/h261 h261 video/h263 h263 # video/h263-1998 # video/h263-2000 video/h264 h264 video/jpeg jpgv # video/jpeg2000 video/jpm jpm jpgm video/mj2 mj2 mjp2 # video/mp1s # video/mp2p # video/mp2t video/mp4 mp4 mp4v mpg4 # video/mp4v-es video/mpeg mpeg mpg mpe m1v m2v # video/mpeg4-generic # video/mpv # video/nv video/ogg ogv # video/parityfec # video/pointer video/quicktime qt mov # video/raw # video/rtp-enc-aescm128 # video/rtx # video/smpte292m # video/ulpfec # video/vc1 # video/vnd.cctv # video/vnd.dlna.mpeg-tts video/vnd.fvt fvt # video/vnd.hns.video # video/vnd.iptvforum.1dparityfec-1010 # video/vnd.iptvforum.1dparityfec-2005 # video/vnd.iptvforum.2dparityfec-1010 # video/vnd.iptvforum.2dparityfec-2005 # video/vnd.iptvforum.ttsavc # video/vnd.iptvforum.ttsmpeg2 # video/vnd.motorola.video # video/vnd.motorola.videop video/vnd.mpegurl mxu m4u video/vnd.ms-playready.media.pyv pyv # video/vnd.nokia.interleaved-multimedia # video/vnd.nokia.videovoip # video/vnd.objectvideo # video/vnd.sealed.mpeg1 # video/vnd.sealed.mpeg4 # video/vnd.sealed.swf # video/vnd.sealedmedia.softseal.mov video/vnd.vivo viv video/x-f4v f4v video/x-fli fli video/x-flv flv video/x-m4v m4v video/x-ms-asf asf asx video/x-ms-wm wm video/x-ms-wmv wmv video/x-ms-wmx wmx video/x-ms-wvx wvx video/x-msvideo avi video/x-sgi-movie movie x-conference/x-cooltalk ice ================================================ FILE: framework/src/main/resources/META-INF/services/org.moqui.context.ExecutionContextFactory ================================================ # Implementation of the org.moqui.context.ExecutionContextFactory interface: org.moqui.impl.context.ExecutionContextFactoryImpl ================================================ FILE: framework/src/main/resources/MoquiDefaultConf.xml ================================================ /* REQUEST /elastic/* /kibana/* /* /fop/* /elastic/* /kibana/* ================================================ FILE: framework/src/main/resources/bitronix-default-config.properties ================================================ # For configuration options see: # https://github.com/bitronix/btm/wiki/Transaction-manager-configuration # https://github.com/bitronix/btm/blob/master/btm-docs/src/main/asciidoc/Configuration2x.adoc # Leave this commented to use IP address as server ID #bitronix.tm.serverId=server-id bitronix.tm.2pc.async=false bitronix.tm.2pc.warnAboutZeroResourceTransactions=false bitronix.tm.2pc.debugZeroResourceTransactions=false bitronix.tm.allowMultipleLrc=true bitronix.tm.journal.disk.logPart1Filename=${moqui.runtime}/txlog/btm1.tlog bitronix.tm.journal.disk.logPart2Filename=${moqui.runtime}/txlog/btm2.tlog #bitronix.tm.journal.disk.forcedWriteEnabled=true #bitronix.tm.journal.disk.forceBatchingEnabled=true #bitronix.tm.journal.disk.skipCorruptedLogs=false # maxLogSize is in MB #bitronix.tm.journal.disk.maxLogSize=2 #bitronix.tm.journal.disk.filterLogStatus=false # these timer parameters are all in seconds bitronix.tm.timer.defaultTransactionTimeout=60 #bitronix.tm.timer.transactionRetryInterval=10 bitronix.tm.timer.gracefulShutdownInterval=60 bitronix.tm.timer.backgroundRecoveryIntervalSeconds=60 # this one is in minutes - this appears to be an old setting, see bitronix.tm.timer.backgroundRecoveryIntervalSeconds above #bitronix.tm.timer.backgroundRecoveryInterval=0 # resources configuration file #bitronix.tm.resource.configuration= ================================================ FILE: framework/src/main/resources/cache.ccf ================================================ # Apache Commons JCS Configuration # See: https://commons.apache.org/proper/commons-jcs/BasicJCSConfiguration.html # DEFAULT CACHE REGION jcs.default= jcs.default.cacheattributes=org.apache.commons.jcs.engine.CompositeCacheAttributes jcs.default.cacheattributes.UseDisk=true jcs.default.cacheattributes.UseLateral=true jcs.default.cacheattributes.UseRemote=true jcs.default.cacheattributes.MemoryCacheName=org.apache.commons.jcs.engine.memory.lru.LRUMemoryCache jcs.default.cacheattributes.UseMemoryShrinker=false jcs.default.cacheattributes.MaxMemoryIdleTime=3600 jcs.default.cacheattributes.ShrinkerInterval=60 jcs.default.elementattributes=org.apache.commons.jcs.engine.ElementAttributes jcs.default.elementattributes.IsEternal=false jcs.default.elementattributes.MaxLife=700 jcs.default.elementattributes.IsSpool=true jcs.default.elementattributes.IsRemote=true jcs.default.elementattributes.IsLateral=true ================================================ FILE: framework/src/main/resources/log4j2.xml ================================================ moqui_logs info info info true ================================================ FILE: framework/src/main/resources/org/moqui/impl/pollEmailServer.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ /* JavaMail API Documentation at: https://java.net/projects/javamail/pages/Home For JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html */ import jakarta.mail.FetchProfile import jakarta.mail.Flags import jakarta.mail.Folder import jakarta.mail.Message import jakarta.mail.Session import jakarta.mail.Store import jakarta.mail.internet.MimeMessage import jakarta.mail.search.FlagTerm import jakarta.mail.search.SearchTerm import org.slf4j.Logger import org.slf4j.LoggerFactory import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl Logger logger = LoggerFactory.getLogger("org.moqui.impl.pollEmailServer") ExecutionContextImpl ec = context.ec EntityValue emailServer = ec.entity.find("moqui.basic.email.EmailServer").condition("emailServerId", emailServerId).one() if (!emailServer) { ec.message.addError(ec.resource.expand('No EmailServer found for ID [${emailServerId}]','')); return } if (!emailServer.storeHost) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no storeHost','')) } if (!emailServer.mailUsername) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no mailUsername','')) } if (!emailServer.mailPassword) { ec.message.addError(ec.resource.expand('EmailServer [${emailServerId}] has no mailPassword','')) } if (ec.message.hasError()) return String host = emailServer.storeHost String user = emailServer.mailUsername String password = emailServer.mailPassword String protocol = emailServer.storeProtocol ?: "imaps" int port = (emailServer.storePort ?: "993") as int String storeFolder = emailServer.storeFolder ?: "INBOX" // def urlName = new URLName(protocol, host, port as int, "", user, password) Session session = Session.getInstance(System.getProperties()) logger.info("Polling Email from ${user}@${host}:${port}/${storeFolder}, properties ${session.getProperties()}") Store store = session.getStore(protocol) if (!store.isConnected()) store.connect(host, port, user, password) // open the folder Folder folder = store.getFolder(storeFolder) if (folder == null || !folder.exists()) { ec.message.addError(ec.resource.expand('No ${storeFolder} folder found','')); return } // get message count folder.open(Folder.READ_WRITE) int totalMessages = folder.getMessageCount() // close and return if no messages if (totalMessages == 0) { folder.close(false); return } // get messages not deleted (and optionally not seen) Flags searchFlags = new Flags(Flags.Flag.DELETED) if (emailServer.storeSkipSeen == "Y") searchFlags.add(Flags.Flag.SEEN) SearchTerm searchTerm = new FlagTerm(searchFlags, false) Message[] messages = folder.search(searchTerm) FetchProfile profile = new FetchProfile() profile.add(FetchProfile.Item.ENVELOPE) profile.add(FetchProfile.Item.FLAGS) profile.add("X-Mailer") folder.fetch(messages, profile) logger.info("Found ${totalMessages} messages (${messages.size()} filtered) at ${user}@${host}:${port}/${storeFolder}") for (Message message in messages) { if (emailServer.storeSkipSeen == "Y" && message.isSet(Flags.Flag.SEEN)) continue // NOTE: should we check size? long messageSize = message.getSize() if (message instanceof MimeMessage) { // use copy constructor to have it download the full message, may fix BODYSTRUCTURE issue from some email servers (see details in issue #97) MimeMessage fullMessage = new MimeMessage(message) ec.service.runEmecaRules(fullMessage, emailServerId) // mark seen if setup to do so if (emailServer.storeMarkSeen == "Y") message.setFlag(Flags.Flag.SEEN, true) // delete the message if setup to do so if (emailServer.storeDelete == "Y") message.setFlag(Flags.Flag.DELETED, true) } else { logger.warn("Doing nothing with non-MimeMessage message: ${message}") } } // expunge and close the folder folder.close(true) ================================================ FILE: framework/src/main/resources/org/moqui/impl/sendEmailMessage.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ /* JavaMail API Documentation at: https://java.net/projects/javamail/pages/Home For JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html */ import org.apache.commons.mail2.jakarta.DefaultAuthenticator import org.apache.commons.mail2.jakarta.HtmlEmail import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import org.slf4j.Logger import org.slf4j.LoggerFactory Logger logger = LoggerFactory.getLogger("org.moqui.impl.sendEmailMessage") ExecutionContextImpl ec = context.ec try { EntityValue emailMessage = ec.entity.find("moqui.basic.email.EmailMessage").condition("emailMessageId", emailMessageId).one() if (emailMessage == null) { ec.message.addError(ec.resource.expand('No EmailMessage record found for ID ${emailMessageId}','')); return } String statusId = emailMessage.statusId if (statusId == 'ES_DRAFT') ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} is in Draft status','')) if (statusId == 'ES_CANCELLED') ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} is Cancelled','')) String bodyHtml = emailMessage.body String bodyText = emailMessage.bodyText String fromAddress = emailMessage.fromAddress String toAddresses = emailMessage.toAddresses String ccAddresses = emailMessage.ccAddresses String bccAddresses = emailMessage.bccAddresses if (!bodyHtml && !bodyText) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no body','')) if (!fromAddress) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no from address','')) if (!toAddresses) ec.message.addError(ec.resource.expand('Email Message ${emailMessageId} has no to address','')) if (ec.message.hasError()) return EntityValue emailTemplate = (EntityValue) emailMessage.template EntityValue emailServer = (EntityValue) emailMessage.server if (emailServer == null) { ec.message.addError(ec.resource.expand('No Email Server record found for Email Message ${emailMessageId}','')); return } if (!emailServer.smtpHost) { logger.warn("SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email message ${emailMessageId}") // logger.warn("SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email:\nbodyHtml:\n${bodyHtml}\nbodyText:\n${bodyText}") return } String host = emailServer.smtpHost int port = (emailServer.smtpPort ?: "25") as int HtmlEmail email = new HtmlEmail() email.setCharset("utf-8") email.setHostName(host) email.setSmtpPort(port) if (emailServer.mailUsername) { email.setAuthenticator(new DefaultAuthenticator((String) emailServer.mailUsername, (String) emailServer.mailPassword)) // logger.info("Set user=${emailServer.mailUsername}, password=${emailServer.mailPassword}") } if (emailServer.smtpStartTls == "Y") { email.setStartTLSEnabled(true) // email.setStartTLSRequired(true) } if (emailServer.smtpSsl == "Y") { email.setSSLOnConnect(true) email.setSslSmtpPort(port as String) // email.setSSLCheckServerIdentity(true) } // set the subject if (emailMessage.subject) email.setSubject((String) emailMessage.subject) // set from, reply to, bounce addresses email.setFrom(fromAddress, (String) emailMessage.fromName) if (emailTemplate?.replyToAddresses) { def rtList = ((String) emailTemplate.replyToAddresses).split(",") for (address in rtList) email.addReplyTo(address.trim()) } if (emailTemplate?.bounceAddress) email.setBounceAddress((String) emailTemplate.bounceAddress) // prep list of allowed to domains, if configured String allowedToDomains = emailServer.allowedToDomains ArrayList toDomainList = null List skippedToAddresses = null if (allowedToDomains) { toDomainList = new ArrayList<>(allowedToDomains.split(",").collect({ it.trim() })) skippedToAddresses = [] } // set to, cc, bcc addresses def toList = ((String) toAddresses).split(",") for (toAddress in toList) { if (isDomainAllowed(toAddress, toDomainList)) email.addTo(toAddress.trim()) else skippedToAddresses.add(toAddress) } if (ccAddresses) { def ccList = ((String) ccAddresses).split(",") for (ccAddress in ccList) { if (isDomainAllowed(ccAddress, toDomainList)) email.addCc(ccAddress.trim()) else skippedToAddresses.add(ccAddress) } } if (bccAddresses) { def bccList = ((String) bccAddresses).split(",") for (def bccAddress in bccList) { if (isDomainAllowed(bccAddress, toDomainList)) email.addBcc(bccAddress.trim()) else skippedToAddresses.add(bccAddress) } } if (!email.getToAddresses()) { logger.warn("Not sending EmailMessage ${emailMessageId} with no To Addresses; To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}") ec.message.addMessage("Not sending email message with no To Address; address(es) skipped because domain not allowed: ${skippedToAddresses}", "warning") return } else if (skippedToAddresses) { logger.warn("Sending EmailMessage ${emailMessageId} to remaining To Address(es) ${email.getToAddresses()}; some To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}") } // set the html message if (bodyHtml) email.setHtmlMsg(bodyHtml) // set the alternative plain text message if (bodyText) email.setTextMsg(bodyText) if (logger.infoEnabled) logger.info("Sending email [${email.getSubject()}] from ${email.getFromAddress()} to ${email.getToAddresses()} cc ${email.getCcAddresses()} bcc ${email.getBccAddresses()} via ${emailServer.mailUsername}@${email.getHostName()}:${email.getSmtpPort()} SSL? ${email.isSSLOnConnect()}:${email.isSSLCheckServerIdentity()} StartTLS? ${email.isStartTLSEnabled()}:${email.isStartTLSRequired()}") if (logger.traceEnabled) logger.trace("Sending email [${email.getSubject()}] to ${email.getToAddresses()} with bodyHtml:\n${bodyHtml}\nbodyText:\n${bodyText}") // email.setDebug(true) // send the email try { messageId = email.send() if (statusId in ['ES_READY', 'ES_BOUNCED']) { ec.service.sync().name("update", "moqui.basic.email.EmailMessage").requireNewTransaction(true) .parameters([emailMessageId:emailMessageId, sentDate:ec.user.nowTimestamp, statusId:"ES_SENT", messageId:messageId]) .disableAuthz().call() } } catch (Throwable t) { logger.error("Error in sendEmailTemplate", t) ec.message.addMessage("Error sending email: ${t.toString()}") } return } catch (Throwable t) { logger.error("Error in sendEmailTemplate", t) ec.message.addMessage("Error sending email: ${t.toString()}") // don't rethrow: throw new BaseArtifactException("Error in sendEmailTemplate", t) } static boolean isDomainAllowed(String emailAddress, ArrayList toDomainList) { if (emailAddress == null || emailAddress.isEmpty()) return false boolean domainAllowed = true if (toDomainList != null && !toDomainList.isEmpty()) { domainAllowed = false int atIndex = emailAddress.indexOf("@") if (atIndex == -1) return false String emailDomain = emailAddress.substring(atIndex + 1, emailAddress.length()) for (toDomain in toDomainList) { if (emailDomain.endsWith(toDomain)) { domainAllowed = true break } } } return domainAllowed } ================================================ FILE: framework/src/main/resources/org/moqui/impl/sendEmailTemplate.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ /* JavaMail API Documentation at: https://java.net/projects/javamail/pages/Home For JavaMail JavaDocs see: https://javamail.java.net/nonav/docs/api/index.html */ import org.apache.commons.mail2.jakarta.DefaultAuthenticator import org.apache.commons.mail2.jakarta.HtmlEmail import org.moqui.entity.EntityList import org.moqui.entity.EntityValue import org.moqui.impl.context.ExecutionContextImpl import jakarta.activation.DataSource import jakarta.mail.util.ByteArrayDataSource import javax.xml.transform.stream.StreamSource import org.slf4j.Logger import org.slf4j.LoggerFactory Logger logger = LoggerFactory.getLogger("org.moqui.impl.sendEmailTemplate") ExecutionContextImpl ec = context.ec try { // logger.info("sendEmailTemplate with emailTemplateId [${emailTemplateId}], bodyParameters [${bodyParameters}]") // add the bodyParameters to the context so they are available throughout this script if (bodyParameters) context.putAll(bodyParameters) EntityValue emailTemplate = ec.entity.find("moqui.basic.email.EmailTemplate").condition("emailTemplateId", emailTemplateId).one() if (emailTemplate == null) ec.message.addError(ec.resource.expand('No EmailTemplate record found for ID [${emailTemplateId}]','')) if (ec.message.hasError()) return emailTypeEnumId = emailTypeEnumId ?: emailTemplate.emailTypeEnumId // combine ccAddresses and bccAddresses if (ccAddresses) { if (emailTemplate.ccAddresses) ccAddresses = ccAddresses + "," + emailTemplate.ccAddresses } else { ccAddresses = emailTemplate.ccAddresses } if (bccAddresses) { if (emailTemplate.bccAddresses) bccAddresses = bccAddresses + "," + emailTemplate.bccAddresses } else { bccAddresses = emailTemplate.bccAddresses } // prepare the fromAddress, fromName, subject, etc; no type or def so that they go into the context for templates fromAddress = ec.resource.expand((String) emailTemplate.fromAddress, "") fromName = ec.resource.expand((String) emailTemplate.fromName, "") subject = ec.resource.expand((String) emailTemplate.subject, "") webappName = (String) emailTemplate.webappName ?: "webroot" webHostName = (String) emailTemplate.webHostName // create an moqui.basic.email.EmailMessage record with info about this sent message // NOTE: can do anything with? purposeEnumId if (createEmailMessage) { Map cemParms = [statusId:"ES_DRAFT", subject:subject, fromAddress:fromAddress, fromName:fromName, toAddresses:toAddresses, ccAddresses:ccAddresses, bccAddresses:bccAddresses, contentType:"text/html", emailTypeEnumId:emailTypeEnumId, emailTemplateId:emailTemplateId, emailServerId:emailTemplate.emailServerId, fromUserId:(fromUserId ?: ec.user?.userId), toUserId:toUserId] Map cemResults = ec.service.sync().name("create", "moqui.basic.email.EmailMessage").requireNewTransaction(true) .parameters(cemParms).disableAuthz().call() emailMessageId = cemResults.emailMessageId } // prepare the html message def bodyRender = ec.screen.makeRender().rootScreen((String) emailTemplate.bodyScreenLocation) .webappName(webappName).renderMode("html") String bodyHtml = bodyRender.render() // prepare the alternative plain text message // render screen with renderMode=text for this def bodyTextRender = ec.screen.makeRender().rootScreen((String) emailTemplate.bodyScreenLocation) .webappName(webappName).renderMode("text") String bodyText = bodyTextRender.render() if (emailMessageId) { ec.service.sync().name("update", "moqui.basic.email.EmailMessage").requireNewTransaction(true) .parameters([emailMessageId:emailMessageId, statusId:"ES_READY", body:bodyHtml, bodyText:bodyText]) .disableAuthz().call() } EntityList emailTemplateAttachmentList = (EntityList) emailTemplate.attachments emailServer = (EntityValue) emailTemplate.server // check a couple of required fields if (emailServer == null) ec.message.addError(ec.resource.expand('No EmailServer record found for EmailTemplate ${emailTemplateId}','')) if (!fromAddress) ec.message.addError(ec.resource.expand('From address is empty for EmailTemplate ${emailTemplateId}','')) if (ec.message.hasError()) { logger.info("Error sending email: ${ec.message.getErrorsString()}\nsubject: ${subject}\nbodyHtml:\n${bodyHtml}\nbodyText:\n${bodyText}") if (emailMessageId) logger.info("Email with error saved as Ready in EmailMessage [${emailMessageId}]") return } if (!emailServer.smtpHost) { logger.warn("SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email ${emailMessageId} template ${emailTemplateId}") // logger.warn("SMTP Host is empty for EmailServer ${emailServer.emailServerId}, not sending email:\nbodyHtml:\n${bodyHtml}\nbodyText:\n${bodyText}") return } String smtpHost = emailServer.smtpHost int smtpPort = (emailServer.smtpPort ?: "25") as int HtmlEmail email = new HtmlEmail() email.setCharset("utf-8") email.setHostName(smtpHost) email.setSmtpPort(smtpPort) if (emailServer.mailUsername) { email.setAuthenticator(new DefaultAuthenticator((String) emailServer.mailUsername, (String) emailServer.mailPassword)) // logger.info("Set user=${emailServer.mailUsername}, password=${emailServer.mailPassword}") } if (emailServer.smtpStartTls == "Y") { email.setStartTLSEnabled(true) // email.setStartTLSRequired(true) } if (emailServer.smtpSsl == "Y") { email.setSSLOnConnect(true) email.setSslSmtpPort(smtpPort as String) // email.setSSLCheckServerIdentity(true) } // set the subject email.setSubject(subject) // set from, reply to, bounce addresses email.setFrom(fromAddress, fromName) if (emailTemplate.replyToAddresses) { def rtList = ((String) emailTemplate.replyToAddresses).split(",") for (address in rtList) email.addReplyTo(address.trim()) } if (emailTemplate.bounceAddress) email.setBounceAddress((String) emailTemplate.bounceAddress) // prep list of allowed to domains, if configured String allowedToDomains = emailServer.allowedToDomains ArrayList toDomainList = null List skippedToAddresses = null if (allowedToDomains) { toDomainList = new ArrayList<>(allowedToDomains.split(",").collect({ it.trim() })) skippedToAddresses = [] } // set to, cc, bcc addresses def toList = ((String) toAddresses).split(",") for (toAddress in toList) { if (isDomainAllowed(toAddress, toDomainList)) email.addTo(toAddress.trim()) else skippedToAddresses.add(toAddress) } if (ccAddresses) { def ccList = ((String) ccAddresses).split(",") for (ccAddress in ccList) { if (isDomainAllowed(ccAddress, toDomainList)) email.addCc(ccAddress.trim()) else skippedToAddresses.add(ccAddress) } } if (bccAddresses) { def bccList = ((String) bccAddresses).split(",") for (def bccAddress in bccList) { if (isDomainAllowed(bccAddress, toDomainList)) email.addBcc(bccAddress.trim()) else skippedToAddresses.add(bccAddress) } } if (!email.getToAddresses()) { logger.warn("Not sending EmailMessage ${emailMessageId} for Template ${emailTemplateId} with no To Addresses; To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}") ec.message.addMessage("Not sending email message with no To Address; address(es) skipped because domain not allowed: ${skippedToAddresses}", "warning") return } else if (skippedToAddresses) { logger.warn("Sending EmailMessage ${emailMessageId} for Template ${emailTemplateId} to remaining To Address(es) ${email.getToAddresses()}; some To, CC, BCC addresses skipped because domain not allowed: ${skippedToAddresses} allowed domains: ${toDomainList}") } // set the html message if (bodyHtml) email.setHtmlMsg(bodyHtml) // set the alternative plain text message if (bodyText) email.setTextMsg(bodyText) //email.setTextMsg("Your email client does not support HTML messages") // parameter attachments if (attachments instanceof List) for (Map attachmentInfo in attachments) { String filename = ec.resourceFacade.expand((String) attachmentInfo.fileName, null) if (attachmentInfo.contentText) { String mimeType = (String) attachmentInfo.contentType ?: ec.resourceFacade.getContentType(filename) ?: "text/plain" DataSource dataSource = new ByteArrayDataSource(attachmentInfo.contentText.toString(), mimeType) email.attach(dataSource, filename, "") } else if (attachmentInfo.contentBytes) { String mimeType = (String) attachmentInfo.contentType ?: ec.resourceFacade.getContentType(filename) ?: "application/octet-stream" DataSource dataSource = new ByteArrayDataSource((byte[]) attachmentInfo.contentBytes, mimeType) email.attach(dataSource, (String) attachmentInfo.fileName, "") } else if (attachmentInfo.screenRenderMode && (attachmentInfo.attachmentLocation || attachmentInfo.screenPath)) { renderScreenAttachment(emailTemplate, email, ec, logger, filename, (String) attachmentInfo.screenRenderMode, (String) attachmentInfo.attachmentLocation, (String) attachmentInfo.screenPath, (String) attachmentInfo.contentType) } else if (attachmentInfo.attachmentLocation) { // not a screen, get straight data with type depending on extension DataSource dataSource = ec.resource.getLocationDataSource((String) attachmentInfo.attachmentLocation) email.attach(dataSource, (String) attachmentInfo.fileName, "") } else { logger.error("Attachment info invalid for email template ${emailTemplateId} to ${toList} subject '${subject}': ${attachmentInfo}") } } // DB configured attachments for (EntityValue emailTemplateAttachment in emailTemplateAttachmentList) { // check attachmentCondition if there is one String attachmentCondition = (String) emailTemplateAttachment.attachmentCondition if (attachmentCondition && !ec.resourceFacade.condition(attachmentCondition, null)) continue // if screenRenderMode render attachment, otherwise just get attachment from location if (emailTemplateAttachment.screenRenderMode) { String forEachIn = (String) emailTemplateAttachment.forEachIn if (forEachIn) { Collection forEachCol = (Collection) ec.resourceFacade.expression(forEachIn, null) if (forEachCol) for (Object forEachEntry in forEachCol) { ec.contextStack.push() try { if (forEachEntry instanceof Map) { ec.contextStack.putAll((Map) forEachEntry) } else { ec.contextStack.put("forEachEntry", forEachEntry) } renderScreenAttachment(emailTemplate, emailTemplateAttachment, email, ec, logger) } finally { ec.contextStack.pop() } } } else { renderScreenAttachment(emailTemplate, emailTemplateAttachment, email, ec, logger) } } else { // not a screen, get straight data with type depending on extension DataSource dataSource = ec.resource.getLocationDataSource((String) emailTemplateAttachment.attachmentLocation) email.attach(dataSource, (String) emailTemplateAttachment.fileName, "") } } if (logger.infoEnabled) logger.info("Sending email [${email.getSubject()}] from ${email.getFromAddress()} to ${email.getToAddresses()} cc ${email.getCcAddresses()} bcc ${email.getBccAddresses()} via ${emailServer.mailUsername}@${email.getHostName()}:${email.getSmtpPort()} SSL? ${email.isSSLOnConnect()}:${email.isSSLCheckServerIdentity()} StartTLS? ${email.isStartTLSEnabled()}:${email.isStartTLSRequired()}") if (logger.traceEnabled) logger.trace("Sending email [${email.getSubject()}] to ${email.getToAddresses()} with bodyHtml:\n${bodyHtml}\nbodyText:\n${bodyText}") // email.setDebug(true) // send the email try { messageId = email.send() // if we created an EmailMessage record update it now with the messageId if (emailMessageId) { ec.service.sync().name("update", "moqui.basic.email.EmailMessage").requireNewTransaction(true) .parameters([emailMessageId:emailMessageId, sentDate:ec.user.nowTimestamp, statusId:"ES_SENT", messageId:messageId]) .disableAuthz().call() } } catch (Throwable t) { logger.error("Error in sendEmailTemplate", t) ec.message.addMessage("Error sending email: ${t.toString()}") } return } catch (Throwable t) { logger.error("Error in sendEmailTemplate", t) ec.message.addMessage("Error sending email: ${t.toString()}") // don't rethrow: throw new BaseArtifactException("Error in sendEmailTemplate", t) } static void renderScreenAttachment(EntityValue emailTemplate, EntityValue emailTemplateAttachment, HtmlEmail email, ExecutionContextImpl ec, Logger logger) { renderScreenAttachment(emailTemplate, email, ec, logger, (String) emailTemplateAttachment.fileName, (String) emailTemplateAttachment.screenRenderMode, (String) emailTemplateAttachment.attachmentLocation, (String) emailTemplateAttachment.screenPath, null) } static void renderScreenAttachment(EntityValue emailTemplate, HtmlEmail email, ExecutionContextImpl ec, Logger logger, String filename, String renderMode, String attachmentLocation, String screenPath, String contentType) { if (!filename) { String extension = renderMode == "xsl-fo" ? "pdf" : renderMode filename = attachmentLocation.substring(attachmentLocation.lastIndexOf("/")+1, attachmentLocation.length()-4) + "." + extension } String filenameExp = ec.resource.expand(filename, null) String webappName = (String) emailTemplate.webappName ?: "webroot" String webHostName = (String) emailTemplate.webHostName def attachmentRender if (screenPath == null || screenPath.isEmpty()) { attachmentRender = ec.screen.makeRender().rootScreen(attachmentLocation).webappName(webappName).renderMode(renderMode) } else { attachmentRender = ec.screen.makeRender().webappName(webappName).rootScreenFromHost(webHostName ?: "localhost") .screenPath(screenPath).renderMode(renderMode).lastStandalone("true") } if (ec.screenFacade.isRenderModeText(renderMode)) { String attachmentText = attachmentRender.render() if (attachmentText == null) return if (attachmentText.trim().length() == 0) return if (renderMode == "xsl-fo") { // use ResourceFacade.xslFoTransform() to change to PDF, then attach that try { ByteArrayOutputStream baos = new ByteArrayOutputStream() ec.resource.xslFoTransform(new StreamSource(new StringReader(attachmentText)), null, baos, "application/pdf") email.attach(new ByteArrayDataSource(baos.toByteArray(), "application/pdf"), filenameExp, "") } catch (Exception e) { logger.warn("Error generating PDF from XSL-FO: ${e.toString()}") } } else { String mimeType = contentType ?: ec.screenFacade.getMimeTypeByMode(renderMode) DataSource dataSource = new ByteArrayDataSource(attachmentText, mimeType) email.attach(dataSource, filenameExp, "") } } else { ByteArrayOutputStream baos = new ByteArrayOutputStream() attachmentRender.render(baos) String mimeType = contentType ?: ec.screenFacade.getMimeTypeByMode(renderMode) DataSource dataSource = new ByteArrayDataSource(baos.toByteArray(), mimeType) email.attach(dataSource, filenameExp, "") } } static boolean isDomainAllowed(String emailAddress, ArrayList toDomainList) { if (emailAddress == null || emailAddress.isEmpty()) return false boolean domainAllowed = true if (toDomainList != null && !toDomainList.isEmpty()) { domainAllowed = false int atIndex = emailAddress.indexOf("@") if (atIndex == -1) return false String emailDomain = emailAddress.substring(atIndex + 1, emailAddress.length()) for (toDomain in toDomainList) { if (emailDomain.endsWith(toDomain)) { domainAllowed = true break } } } return domainAllowed } ================================================ FILE: framework/src/main/resources/shiro.ini ================================================ # ======================= # Shiro INI configuration # ======================= [main] # for conf details see: http://shiro.apache.org/session-management.html # before enabling this make sure shiro-ehcache is included in framework/build.gradle # ehcacheManager = org.apache.shiro.cache.ehcache.EhCacheManager # NOTE: no credentialsMatcher set here, configured in Moqui conf file (moqui-conf.user-facade.password.@encrypt-hash-type) moquiRealm = org.moqui.impl.util.MoquiShiroRealm # securityManager.cacheManager = $ehcacheManager securityManager.realms = $moquiRealm ================================================ FILE: framework/src/main/webapp/WEB-INF/web.xml ================================================ Moqui Root Webapp The name of the Moqui webapp used to lookup configuration in the moqui-conf.webapp-list.webapp.@moqui-name attribute. moqui-namewebroot org.moqui.impl.webapp.MoquiContextListener org.apache.commons.fileupload2.jakarta.servlet6.JakartaFileCleaner 60 true__SAME_SITE_LAX__ COOKIE ================================================ FILE: framework/src/start/java/MoquiStart.java ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import java.io.BufferedOutputStream; import java.io.DataInputStream; import java.io.File; import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.lang.reflect.Array; import java.lang.reflect.Method; import java.net.MalformedURLException; import java.net.URL; import java.nio.file.Files; import java.nio.file.Path; import java.nio.file.Paths; import java.security.CodeSource; import java.security.ProtectionDomain; import java.security.cert.Certificate; import java.util.ArrayList; import java.util.Arrays; import java.util.Collections; import java.util.Comparator; import java.util.Enumeration; import java.util.HashMap; import java.util.HashSet; import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Properties; import java.util.Set; import java.util.TreeSet; import java.util.concurrent.ConcurrentHashMap; import java.util.jar.Attributes; import java.util.jar.JarEntry; import java.util.jar.JarFile; import java.util.jar.Manifest; /** This start class implements a ClassLoader and supports loading jars within a jar or war file in order to facilitate * an executable war file. To do this it overrides the findResource, findResources, and loadClass methods of the * ClassLoader class. * * The best source for research on the topic seems to be at http://www.jdotsoft.com, with a lot of good comments in the * JarClassLoader source file there. */ public class MoquiStart { // this default is for development and is here instead of having a buried properties file that might cause conflicts when trying to override private static final String defaultConf = "conf/MoquiDevConf.xml"; private static final String tempDirName = "execwartmp"; private static final boolean reportJarsUnused = Boolean.valueOf(System.getProperty("report.jars.unused", "false")); // private final static boolean reportJarsUnused = true; public static void main(String[] args) throws IOException { // now grab the first arg and see if it is a known command String firstArg = args.length > 0 ? args[0] : ""; // make a list of arguments List argList = Arrays.asList(args); Map argMap = new LinkedHashMap<>(); for (String arg: argList) { // run twice to allow one or two dashes if (arg.startsWith("-")) arg = arg.substring(1); if (arg.startsWith("-")) arg = arg.substring(1); if (arg.contains("=")) { argMap.put(arg.substring(0, arg.indexOf("=")), arg.substring(arg.indexOf("=")+1)); } else { argMap.put(arg, ""); } } if (firstArg.endsWith("help") || "-?".equals(firstArg)) { // setup the class loader StartClassLoader moquiStartLoader = new StartClassLoader(true); Thread.currentThread().setContextClassLoader(moquiStartLoader); Runtime.getRuntime().addShutdownHook(new MoquiShutdown(null, null, moquiStartLoader)); initSystemProperties(moquiStartLoader, false, argMap); /* nice for debugging, messy otherwise: System.out.println("Internal Class Path Jars:"); for (JarFile jf: moquiStartLoader.jarFileList) { String fn = jf.getName(); System.out.println(fn.contains("moqui_temp") ? fn.substring(fn.indexOf("moqui_temp")) : fn); } */ System.out.println("------------------------------------------------"); System.out.println("Current runtime directory (moqui.runtime): " + System.getProperty("moqui.runtime")); System.out.println("Current configuration file (moqui.conf): " + System.getProperty("moqui.conf")); System.out.println("To set these properties use something like: java -Dmoqui.conf=conf/MoquiProductionConf.xml -jar moqui.war ..."); System.out.println("------------------------------------------------"); System.out.println("Executable WAR : java -jar moqui.war [command] [arguments]"); System.out.println("Expanded WAR : java -cp . MoquiStart [command] [arguments]"); System.out.println("help, -? ---- Help (this text)"); System.out.println("load -------- Run data loader"); System.out.println(" types=[,] ------- Data types to load (can be anything, common are: seed, seed-initial, install, demo, ...)"); System.out.println(" components=[,] -- Component names to load for data types; if none specified loads from all"); System.out.println(" location= --------- Location of data file to load"); System.out.println(" timeout= ----------- Transaction timeout for each file, defaults to 600 seconds (10 minutes)"); System.out.println(" no-fk-create ---------------- Don't create foreign-keys, for empty database to avoid referential integrity errors"); System.out.println(" dummy-fks ------------------- Use dummy foreign-keys to avoid referential integrity errors"); System.out.println(" use-try-insert -------------- Try insert and update on error instead of checking for record first"); System.out.println(" disable-eeca ---------------- Disable Entity ECA rules"); System.out.println(" disable-audit-log ----------- Disable Entity Audit Log"); System.out.println(" disable-data-feed ----------- Disable Entity DataFeed"); System.out.println(" raw ------------------------- For raw data load to an empty database; short for no-fk-create, use-try-insert, disable-eeca, disable-audit-log, disable-data-feed"); System.out.println(" conf= ----------- The Moqui Conf XML file to use, overrides other ways of specifying it"); System.out.println(" no-run-es ------------------- Don't Try starting and stopping ElasticSearch in runtime/elasticsearch"); System.out.println(" If no -types or -location argument is used all known data files of all types will be loaded."); System.out.println("[default] ---- Run embedded Jetty server"); System.out.println(" port= ---------------- The http listening port. Default is 8080"); System.out.println(" threads= ------ Maximum number of threads. Default is 100"); System.out.println(" conf= ---------- The Moqui Conf XML file to use, overrides other ways of specifying it"); System.out.println(" no-run-es ------------------- Don't Try starting and stopping OpenSearch in runtime/opensearch or ElasticSearch in runtime/elasticsearch"); System.out.println(""); System.exit(0); } boolean isInWar = true; try { ProtectionDomain pd = MoquiStart.class.getProtectionDomain(); CodeSource cs = pd.getCodeSource(); URL wrapperUrl = cs.getLocation(); File wrapperFile = new File(wrapperUrl.toURI()); if (wrapperFile.isDirectory()) isInWar = false; /* to accommodate an executable start.jar file inside the executable WAR file: if (isInWar && wrapperFile.getName().equals("start.jar")) { isInWar = false; // wrapperFile = wrapperFile.getParentFile(); } */ } catch (Exception e) { System.out.println("Error checking class wrapper: " + e.toString()); } // if doing anything other than help make sure temp dir deleted if (isInWar) { File tempDir = new File(tempDirName); System.out.println("Using temporary directory: " + tempDir.getCanonicalPath()); if (tempDir.exists()) { System.out.println("Found temporary directory " + tempDirName + ", deleting"); try { Files.walk(tempDir.toPath()) .sorted(Comparator.reverseOrder()) .map(Path::toFile) .forEach(File::delete); } catch (IOException e) { System.out.println("Error deleting temp directory " + tempDirName + ": " + e); } } } // run load if is first argument if (firstArg.endsWith("load")) { StartClassLoader moquiStartLoader = new StartClassLoader(true); Thread.currentThread().setContextClassLoader(moquiStartLoader); // Runtime.getRuntime().addShutdownHook(new MoquiShutdown(null, null, moquiStartLoader)); initSystemProperties(moquiStartLoader, false, argMap); Process esProcess = argMap.containsKey("no-run-es") ? null : checkStartElasticSearch(); boolean successfullLoad = true; try { System.out.println("Loading data with args " + argMap); Class c = moquiStartLoader.loadClass("org.moqui.Moqui"); Method m = c.getMethod("loadData", Map.class); m.invoke(null, argMap); } catch (Throwable e) { successfullLoad = false; System.out.println("Error loading or running Moqui.loadData with args [" + argMap + "]: " + e.toString()); e.printStackTrace(); } finally { checkStopElasticSearch(esProcess); System.exit(successfullLoad ? 0 : 1); } } // ===== Done trying specific commands, so load the embedded server // Get a start loader with loadWebInf=false since the container will load those we don't want to here (would be on classpath twice) // NOTE DEJ20210520: now always using StartClassLoader because of breaking classloader changes in 9.4.37 (likely from https://github.com/eclipse/jetty.project/pull/5894) StartClassLoader moquiStartLoader = new StartClassLoader(true); Thread.currentThread().setContextClassLoader(moquiStartLoader); // NOTE: not using MoquiShutdown hook any more, let Jetty stop everything // may need to add back for jar file close, cleaner delete on exit // Thread shutdownHook = new MoquiShutdown(null, null, moquiStartLoader); // shutdownHook.setDaemon(true); // Runtime.getRuntime().addShutdownHook(shutdownHook); initSystemProperties(moquiStartLoader, false, argMap); String runtimePath = System.getProperty("moqui.runtime"); Process esProcess = argMap.containsKey("no-run-es") ? null : checkStartElasticSearch(); if (esProcess != null) { Thread shutdownHook = new ElasticShutdown(esProcess); shutdownHook.setDaemon(true); Runtime.getRuntime().addShutdownHook(shutdownHook); } try { int port = 8080; String portStr = argMap.get("port"); if (portStr != null && portStr.length() > 0) port = Integer.parseInt(portStr); int threads = 100; String threadsStr = argMap.get("threads"); if (threadsStr != null && threadsStr.length() > 0) threads = Integer.parseInt(threadsStr); System.out.println("Running Jetty server on port " + port + " max threads " + threads + " with args [" + argMap + "]"); Class serverClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.Server"); Class handlerClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.Handler"); Class sizedThreadPoolClass = moquiStartLoader.loadClass("org.eclipse.jetty.util.thread.ThreadPool$SizedThreadPool"); Class httpConfigurationClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.HttpConfiguration"); Class forwardedRequestCustomizerClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.ForwardedRequestCustomizer"); Class customizerClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.HttpConfiguration$Customizer"); Class sessionIdManagerClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.SessionIdManager"); Class sessionManagerClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.SessionManager"); Class sessionHandlerClass = moquiStartLoader.loadClass("org.eclipse.jetty.ee11.servlet.SessionHandler"); Class defaultSessionIdManagerClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.DefaultSessionIdManager"); Class sessionCacheClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.SessionCache"); Class sessionCacheFactoryClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.DefaultSessionCacheFactory"); Class sessionDataStoreClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.SessionDataStore"); Class fileSessionDataStoreClass = moquiStartLoader.loadClass("org.eclipse.jetty.session.FileSessionDataStore"); Class connectorClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.Connector"); Class serverConnectorClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.ServerConnector"); Class webappClass = moquiStartLoader.loadClass("org.eclipse.jetty.ee11.webapp.WebAppContext"); Class connectionFactoryClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.ConnectionFactory"); Class connectionFactoryArrayClass = Array.newInstance(connectionFactoryClass, 1).getClass(); Class httpConnectionFactoryClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.HttpConnectionFactory"); Class scHandlerClass = moquiStartLoader.loadClass("org.eclipse.jetty.ee11.servlet.ServletContextHandler"); Class wsInitializerClass = moquiStartLoader.loadClass("org.eclipse.jetty.ee11.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer"); Class wsInitializerConfiguratorClass = moquiStartLoader.loadClass("org.eclipse.jetty.ee11.websocket.jakarta.server.config.JakartaWebSocketServletContainerInitializer$Configurator"); Class gzipHandlerClass = moquiStartLoader.loadClass("org.eclipse.jetty.server.handler.gzip.GzipHandler"); Object server = serverClass.getConstructor().newInstance(); Object httpConfig = httpConfigurationClass.getConstructor().newInstance(); // add ForwardedRequestCustomizer to handle Forwarded and X-Forwarded-* HTTP Request Headers // see https://javadoc.jetty.org/jetty-12.1/org/eclipse/jetty/server/ForwardedRequestCustomizer.html // NOTE: this is the only way Jetty knows about HTTPS/SSL so is needed, but the problem is these headers // are easily spoofed; this isn't too bad for X-Proxied-Https and X-Forwarded-Proto, and those are needed // TODO: at least find some way to skip X-Forwarded-For: current behavior with new client-ip-header setting // is it will use that but if no client IP found that way it gets it from Jetty, which gets it from X-Forwarded-For, opening to spoofing Object forwardedRequestCustomizer = forwardedRequestCustomizerClass.getConstructor().newInstance(); httpConfigurationClass.getMethod("addCustomizer", customizerClass).invoke(httpConfig, forwardedRequestCustomizer); Object httpConnectionFactory = httpConnectionFactoryClass.getConstructor(httpConfigurationClass).newInstance(httpConfig); Object connectionFactoryArray = Array.newInstance(connectionFactoryClass, 1); Array.set(connectionFactoryArray, 0, httpConnectionFactory); Object httpConnector = serverConnectorClass.getConstructor(serverClass, connectionFactoryArrayClass).newInstance(server, connectionFactoryArray); serverConnectorClass.getMethod("setPort", int.class).invoke(httpConnector, port); serverClass.getMethod("addConnector", connectorClass).invoke(server, httpConnector); // SessionDataStore File storeDir = new File(runtimePath + "/sessions"); if (!storeDir.exists()) storeDir.mkdirs(); System.out.println("Creating Jetty FileSessionDataStore with directory " + storeDir.getCanonicalPath()); Object sessionHandler = sessionHandlerClass.getConstructor().newInstance(); sessionHandlerClass.getMethod("setServer", serverClass).invoke(sessionHandler, server); Object sessionCacheFactory = sessionCacheFactoryClass.getConstructor().newInstance(); Object sessionCache = sessionCacheFactoryClass.getMethod("newSessionCache", sessionManagerClass).invoke(sessionCacheFactory, sessionHandler); Object sessionDataStore = fileSessionDataStoreClass.getConstructor().newInstance(); fileSessionDataStoreClass.getMethod("setStoreDir", File.class).invoke(sessionDataStore, storeDir); fileSessionDataStoreClass.getMethod("setDeleteUnrestorableFiles", boolean.class).invoke(sessionDataStore, true); sessionCacheClass.getMethod("setSessionDataStore", sessionDataStoreClass).invoke(sessionCache, sessionDataStore); sessionHandlerClass.getMethod("setSessionCache", sessionCacheClass).invoke(sessionHandler, sessionCache); Object sidMgr = defaultSessionIdManagerClass.getConstructor(serverClass).newInstance(server); defaultSessionIdManagerClass.getMethod("setServer", serverClass).invoke(sidMgr, server); sessionHandlerClass.getMethod("setSessionIdManager", sessionIdManagerClass).invoke(sessionHandler, sidMgr); serverClass.getMethod("addBean", Object.class).invoke(server, sidMgr); // WebApp Object webapp = webappClass.getConstructor().newInstance(); webappClass.getMethod("setContextPath", String.class).invoke(webapp, "/"); webappClass.getMethod("setServer", serverClass).invoke(webapp, server); webappClass.getMethod("setSessionHandler", sessionHandlerClass).invoke(webapp, sessionHandler); webappClass.getMethod("setMaxFormKeys", int.class).invoke(webapp, 5000); if (isInWar) { webappClass.getMethod("setWar", String.class).invoke(webapp, moquiStartLoader.wrapperUrl.toExternalForm()); webappClass.getMethod("setTempDirectory", File.class).invoke(webapp, new File(tempDirName + "/ROOT")); } else { webappClass.getMethod("setBaseResourceAsString", String.class).invoke(webapp, moquiStartLoader.wrapperUrl.toExternalForm()); } webappClass.getMethod("setClassLoader", ClassLoader.class).invoke(webapp, moquiStartLoader); // handle webapp_session_cookie_max_age with setInitParameter (1209600 seconds is about 2 weeks 60 * 60 * 24 * 14) String sessionMaxAge = System.getenv("webapp_session_cookie_max_age"); if (sessionMaxAge != null && !sessionMaxAge.isEmpty()) { Integer maxAgeInt = null; try { maxAgeInt = Integer.parseInt(sessionMaxAge); } catch (Exception e) { System.out.println("Found webapp_session_cookie_max_age env var with invalid number, ignoring: " + sessionMaxAge); } if (maxAgeInt != null) { System.out.println("Setting Servlet Session Max Age based on webapp_session_cookie_max_age " + maxAgeInt); webappClass.getMethod("setInitParameter", String.class, String.class) .invoke(webapp, "org.eclipse.jetty.servlet.MaxAge", maxAgeInt.toString()); } } // WebSocket Object wsContainer = wsInitializerClass.getMethod("configure", scHandlerClass, wsInitializerConfiguratorClass).invoke(null, webapp, null); webappClass.getMethod("setAttribute", String.class, Object.class).invoke(webapp, "jakarta.websocket.server.ServerContainer", wsContainer); // GzipHandler Object gzipHandler = gzipHandlerClass.getConstructor().newInstance(); // use defaults, should include all except certain excludes: // gzipHandlerClass.getMethod("setIncludedMimeTypes", String[].class).invoke(gzipHandler, new Object[] { new String[] {"text/html", "text/plain", "text/xml", "text/css", "application/javascript", "text/javascript"} }); gzipHandlerClass.getMethod("setHandler", handlerClass).invoke(gzipHandler, webapp); serverClass.getMethod("setHandler", handlerClass).invoke(server, gzipHandler); // Log getMinThreads, getMaxThreads Object threadPool = serverClass.getMethod("getThreadPool").invoke(server); sizedThreadPoolClass.getMethod("setMaxThreads", int.class).invoke(threadPool, threads); int minThreads = (int) sizedThreadPoolClass.getMethod("getMinThreads").invoke(threadPool); int maxThreads = (int) sizedThreadPoolClass.getMethod("getMaxThreads").invoke(threadPool); System.out.println("Jetty min threads " + minThreads + ", max threads " + maxThreads); // Tell Jetty to stop on JVM shutdown serverClass.getMethod("setStopAtShutdown", boolean.class).invoke(server, true); serverClass.getMethod("setStopTimeout", long.class).invoke(server, 30000L); // Start serverClass.getMethod("start").invoke(server); serverClass.getMethod("join").invoke(server); /* Jetty 12 / Jakarta EE 11 notes: - SessionIdManager is server-scoped and must be registered as a Server bean. - SessionHandler discovers the SessionIdManager automatically. - Handler hierarchy: Server └── GzipHandler └── WebAppContext └── SessionHandler The classpath dependent code we are running: Server server = new Server(); HttpConfiguration httpConfig = new HttpConfiguration(); httpConfig.addCustomizer(new ForwardedRequestCustomizer()); HttpConnectionFactory httpConnectionFactory = new HttpConnectionFactory(httpConfig); ServerConnector httpConnector = new ServerConnector(server, httpConnectionFactory); httpConnector.setPort(port); server.addConnector(httpConnector); File storeDir = new File(runtimePath + "/sessions"); storeDir.mkdirs(); SessionHandler sessionHandler = new SessionHandler(); sessionHandler.setServer(server); DefaultSessionCacheFactory sessionCacheFactory = new DefaultSessionCacheFactory(); SessionCache sessionCache = sessionCacheFactory.newSessionCache(sessionHandler); FileSessionDataStore sessionDataStore = new FileSessionDataStore(); sessionDataStore.setStoreDir(storeDir); sessionDataStore.setDeleteUnrestorableFiles(true); sessionCache.setSessionDataStore(sessionDataStore); sessionHandler.setSessionCache(sessionCache); SessionIdManager sessionIdManager = new DefaultSessionIdManager(server); server.addBean(sessionIdManager); sessionHandler.setSessionIdManager(sessionIdManager); WebAppContext webapp = new WebAppContext(); webapp.setContextPath("/"); webapp.setServer(server); webapp.setSessionHandler(sessionHandler); webapp.setMaxFormKeys(5000); if (isInWar) { webapp.setWar(moquiStartLoader.wrapperUrl.toExternalForm()); webapp.setTempDirectory(new File("execwartmp/ROOT")); } else { webapp.setBaseResourceAsString(moquiStartLoader.wrapperUrl.toExternalForm()); } webapp.setClassLoader(moquiStartLoader); String sessionMaxAge = System.getenv("webapp_session_cookie_max_age"); if (sessionMaxAge != null && !sessionMaxAge.isEmpty()) { try { Integer maxAgeInt = Integer.parseInt(sessionMaxAge); webapp.setInitParameter("org.eclipse.jetty.servlet.MaxAge", maxAgeInt.toString()); } catch (Exception ignored) {} } ServerContainer wsContainer = JakartaWebSocketServletContainerInitializer.configure(webapp, null); webapp.setAttribute("jakarta.websocket.server.ServerContainer", wsContainer); GzipHandler gzipHandler = new GzipHandler(); gzipHandler.setHandler(webapp); server.setHandler(gzipHandler); ThreadPool.SizedThreadPool threadPool = (ThreadPool.SizedThreadPool) server.getThreadPool(); threadPool.setMaxThreads(threads); server.setStopAtShutdown(true); server.setStopTimeout(30000L); server.start(); // The use of server.join() the will make the current thread join and // wait until the server is done executing. // See http://docs.oracle.com/javase/7/docs/api/java/lang/Thread.html#join() server.join(); */ } catch (Exception e) { System.out.println("Error loading or running Jetty embedded server with args [" + argMap + "]: " + e.toString()); e.printStackTrace(); } // now wait for break... } private static void initSystemProperties(StartClassLoader cl, boolean useProperties, Map argMap) throws IOException { Properties moquiInitProperties = null; if (useProperties) { moquiInitProperties = new Properties(); URL initProps = cl.getResource("MoquiInit.properties"); if (initProps != null) { InputStream is = initProps.openStream(); moquiInitProperties.load(is); is.close(); } } // before doing anything else make sure the moqui.runtime system property exists (needed for config of various things) String runtimePath = System.getProperty("moqui.runtime"); if (runtimePath != null && runtimePath.length() > 0) System.out.println("Determined runtime from Java system property: " + runtimePath); if (moquiInitProperties != null && (runtimePath == null || runtimePath.length() == 0)) { runtimePath = moquiInitProperties.getProperty("moqui.runtime"); if (runtimePath != null && runtimePath.length() > 0) System.out.println("Determined runtime from MoquiInit.properties file: " + runtimePath); } if (runtimePath == null || runtimePath.length() == 0) { // see if runtime directory under the current directory exists, if not default to the current directory File testFile = new File("runtime"); if (testFile.exists()) runtimePath = "runtime"; if (runtimePath != null && runtimePath.length() > 0) System.out.println("Determined runtime from existing runtime subdirectory: " + testFile.getCanonicalPath()); } if (runtimePath == null || runtimePath.length() == 0) { runtimePath = "."; System.out.println("Determined runtime by defaulting to current directory: " + runtimePath); } File runtimeFile = new File(runtimePath); runtimePath = runtimeFile.getCanonicalPath(); System.out.println("Canonicalized runtimePath: " + runtimePath); if (runtimePath.endsWith("/")) runtimePath = runtimePath.substring(0, runtimePath.length()-1); System.setProperty("moqui.runtime", runtimePath); /* Don't do this here... loads as lower-level that WEB-INF/lib jars and so can't have dependencies on those, and dependencies on those are necessary // add runtime/lib jar files to the class loader File runtimeLibFile = new File(runtimePath + "/lib"); for (File jarFile: runtimeLibFile.listFiles()) { if (jarFile.getName().endsWith(".jar")) cl.jarFileList.add(new JarFile(jarFile)); } */ String confPath = argMap.get("conf"); if (confPath != null && !confPath.isEmpty()) System.out.println("Determined conf from conf argument: " + confPath); if (confPath == null || confPath.isEmpty()) { confPath = System.getProperty("moqui.conf"); if (confPath != null && !confPath.isEmpty()) System.out.println("Determined conf from Java system property: " + confPath); } if (moquiInitProperties != null && (confPath == null || confPath.isEmpty())) { confPath = moquiInitProperties.getProperty("moqui.conf"); if (confPath != null && !confPath.isEmpty()) System.out.println("Determined conf from MoquiInit.properties file: " + confPath); } if (confPath == null || confPath.isEmpty()) { File testFile = new File(runtimePath + "/" + defaultConf); if (testFile.exists()) confPath = defaultConf; System.out.println("Determined conf by default (dev conf file): " + confPath); } if (confPath != null && !confPath.isEmpty()) System.setProperty("moqui.conf", confPath); } private static Process checkStartElasticSearch() { String runtimePath = System.getProperty("moqui.runtime"); File osDir = new File(runtimePath + "/opensearch"); boolean osDirExists = osDir.exists(); String baseName = osDirExists ? "opensearch" : "elasticsearch"; String workDir = runtimePath + "/" + baseName; if (!new File(workDir + "/bin").exists()) return null; if (new File(workDir + "/pid").exists()) { System.out.println((osDirExists ? "OpenSearch" : "ElasticSearch") + " install found in " + workDir + ", pid file found so not starting"); return null; } String javaHome = System.getProperty("java.home"); System.out.println("Starting " + (osDirExists ? "OpenSearch" : "ElasticSearch") + " install found in " + workDir + ", pid file not found (JDK: " + javaHome + ")"); String os = System.getProperty("os.name").toLowerCase(); boolean isWindows = os.startsWith("windows"); boolean isMac = os.startsWith("mac"); boolean isLinux = os.contains("nix") || os.contains("nux") || os.contains("aix"); try { String[] command; if (isWindows) { command = new String[] {"cmd.exe", "/c", "bin\\" + baseName + ".bat"}; } else { command = new String[]{"./bin/" + baseName}; try { boolean elasticsearchOwner = Files.getOwner(Paths.get(runtimePath, baseName)).getName().equals(baseName); boolean suAble = false; if (isLinux) { suAble = Runtime.getRuntime().exec(new String[]{"/bin/su", "-c", "/bin/true", baseName}).waitFor() == 0; } else if(isMac) { suAble = Runtime.getRuntime().exec(new String[]{"/usr/bin/sudo", "-n", "/usr/bin/true"}).waitFor() == 0; } if (elasticsearchOwner && suAble) command = new String[]{"su", "-c", "./bin/" + baseName, baseName}; } catch (IOException e) { System.out.println("Error to run " + (Arrays.toString(new String[]{"/usr/bin/sudo", "-n", "/usr/bin/true"})) + ": " + e.getMessage()); } } ProcessBuilder pb = new ProcessBuilder(command); pb.redirectErrorStream(true); pb.directory(new File(workDir)); pb.environment().put("JAVA_HOME", javaHome); pb.inheritIO(); Process esProcess = pb.start(); System.setProperty("moqui.elasticsearch.started", "true"); return esProcess; } catch (Exception e) { System.out.println("Error starting " + (osDirExists ? "OpenSearch" : "ElasticSearch") + " in " + workDir + ": " + e); return null; } } private static void checkStopElasticSearch(Process esProcess) { if (esProcess != null) esProcess.destroy(); } private static class ElasticShutdown extends Thread { final Process esProcess; ElasticShutdown(Process esProcess) { super(); this.esProcess = esProcess; } @Override public void run() { esProcess.destroy(); } } private static class MoquiShutdown extends Thread { final Method callMethod; final Object callObject; final StartClassLoader moquiStart; MoquiShutdown(Method callMethod, Object callObject, StartClassLoader moquiStart) { super(); this.callMethod = callMethod; this.callObject = callObject; this.moquiStart = moquiStart; } @Override public void run() { // run this first, ie shutdown the container before closing jarFiles to avoid errors with classes missing if (callMethod != null) { try { callMethod.invoke(callObject); } catch (Exception e) { System.out.println("Error in shutdown: " + e.toString()); } } // give things a couple seconds to destroy; this way of running is mostly for dev/test where this should be sufficient try { synchronized (this) { this.wait(2000); } } catch (Exception e) { System.out.println("Shutdown wait interrupted"); } System.out.println("========== Shutting down Moqui Executable (closing jars, etc) =========="); // close all jarFiles so they will "deleteOnExit" for (JarFile jarFile : moquiStart.jarFileList) { try { jarFile.close(); } catch (IOException e) { System.out.println("Error closing jar [" + jarFile + "]: " + e.toString()); } } if (reportJarsUnused) { Set sortedJars = new TreeSet<>(); String baseName = "execwartmp/moqui_temp"; for (String jarName: moquiStart.jarsUnused) { if (jarName.startsWith(baseName)) { jarName = jarName.substring(baseName.length()); while (Character.isDigit(jarName.charAt(0))) jarName = jarName.substring(1); } sortedJars.add(jarName); } for (String jarName: sortedJars) System.out.println("JAR unused: " + jarName); } } } private static class StartClassLoader extends ClassLoader { private URL wrapperUrl = null; private boolean isInWar = true; final ArrayList jarFileList = new ArrayList<>(); private final Map jarLocationByJarName = new HashMap<>(); private final Map> classCache = new HashMap<>(); private final Map resourceCache = new HashMap<>(); private ProtectionDomain pd; private final boolean loadWebInf; final Set jarsUnused = new HashSet<>(); private StartClassLoader(boolean loadWebInf) { this(ClassLoader.getSystemClassLoader(), loadWebInf); } private StartClassLoader(ClassLoader parent, boolean loadWebInf) { super(parent); this.loadWebInf = loadWebInf; try { // get outer file (the war file) pd = getClass().getProtectionDomain(); CodeSource cs = pd.getCodeSource(); wrapperUrl = cs.getLocation(); File wrapperFile = new File(wrapperUrl.toURI()); isInWar = !wrapperFile.isDirectory(); /* to accommodate an executable start.jar file inside the executable WAR file: if (isInWar && wrapperFile.getName().equals("start.jar")) { isInWar = false; wrapperFile = wrapperFile.getParentFile(); wrapperUrl = wrapperFile.toURI().toURL(); } */ if (isInWar) { JarFile outerFile = new JarFile(wrapperFile); // allow for classes in the outerFile as well jarFileList.add(outerFile); jarLocationByJarName.put(outerFile.getName(), wrapperUrl); Enumeration jarEntries = outerFile.entries(); while (jarEntries.hasMoreElements()) { JarEntry je = jarEntries.nextElement(); if (je.isDirectory()) continue; // if we aren't loading the WEB-INF files and it is one, skip it if (!loadWebInf && je.getName().startsWith("WEB-INF")) continue; // get jars, can be anywhere in the file String jeName = je.getName().toLowerCase(); if (jeName.lastIndexOf(".jar") == jeName.length() - 4) { File file = createTempFile(outerFile, je); JarFile newJarFile = new JarFile(file); jarFileList.add(newJarFile); jarLocationByJarName.put(newJarFile.getName(), file.toURI().toURL()); } } } else { ArrayList jarList = new ArrayList<>(); addJarFilesNested(wrapperFile, jarList, loadWebInf); for (File jarFile : jarList) { JarFile newJarFile = new JarFile(jarFile); jarFileList.add(newJarFile); jarLocationByJarName.put(newJarFile.getName(), jarFile.toURI().toURL()); // System.out.println("jar file: " + jarFile.getAbsolutePath()); } } } catch (Exception e) { System.out.println("Error loading jars in war file [" + wrapperUrl + "]: " + e.toString()); } if (reportJarsUnused) for (JarFile jf : jarFileList) jarsUnused.add(jf.getName()); } private ConcurrentHashMap protectionDomainByUrl = new ConcurrentHashMap<>(); private ProtectionDomain getProtectionDomain(URL jarLocation) { ProtectionDomain curPd = protectionDomainByUrl.get(jarLocation); if (curPd != null) return curPd; CodeSource codeSource = new CodeSource(jarLocation, (Certificate[]) null); ProtectionDomain newPd = new ProtectionDomain(codeSource, null, this, null); ProtectionDomain existingPd = protectionDomainByUrl.putIfAbsent(jarLocation, newPd); return existingPd != null ? existingPd : newPd; } private void addJarFilesNested(File file, List jarList, boolean loadWebInf) { for (File child : file.listFiles()) { if (child.isDirectory()) { // generally run with the runtime directory in the same directory, so skip it (or causes weird class dependency errors) if ("runtime".equals(child.getName())) continue; // if we aren't loading the WEB-INF files and it is one, skip it if (!loadWebInf && "WEB-INF".equals(child.getName())) continue; addJarFilesNested(child, jarList, loadWebInf); } else if (child.getName().endsWith(".jar")) { jarList.add(child); } } } @SuppressWarnings("ThrowFromFinallyBlock") private File createTempFile(JarFile outerFile, JarEntry je) throws IOException { byte[] jeBytes = getJarEntryBytes(outerFile, je); String tempName = je.getName().replace('/', '_') + "."; File tempDir = new File(tempDirName); if (tempDir.mkdir()) tempDir.deleteOnExit(); File file = File.createTempFile("moqui_temp", tempName, tempDir); file.deleteOnExit(); BufferedOutputStream os = null; try { os = new BufferedOutputStream(new FileOutputStream(file)); os.write(jeBytes); } finally { if (os != null) os.close(); } return file; } @SuppressWarnings("ThrowFromFinallyBlock") private byte[] getJarEntryBytes(JarFile jarFile, JarEntry je) throws IOException { DataInputStream dis = null; byte[] jeBytes = null; try { long lSize = je.getSize(); if (lSize <= 0 || lSize >= Integer.MAX_VALUE) { throw new IllegalArgumentException("Size [" + lSize + "] not valid for war entry [" + je + "]"); } jeBytes = new byte[(int)lSize]; InputStream is = jarFile.getInputStream(je); dis = new DataInputStream(is); dis.readFully(jeBytes); } finally { if (dis != null) dis.close(); } return jeBytes; } /** @see java.lang.ClassLoader#findResource(java.lang.String) */ @Override protected URL findResource(String resourceName) { if (resourceCache.containsKey(resourceName)) return resourceCache.get(resourceName); // try the runtime/classes directory for conf files and such String runtimePath = System.getProperty("moqui.runtime"); String fullPath = runtimePath + "/classes/" + resourceName; File resourceFile = new File(fullPath); if (resourceFile.exists()) try { return resourceFile.toURI().toURL(); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in runtime classes directory [" + runtimePath + "/classes/" + "]: " + e.toString()); } String webInfResourceName = "WEB-INF/classes/" + resourceName; int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); JarEntry jarEntry = jarFile.getJarEntry(resourceName); if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName()); // to better support war format, look for the resourceName in the WEB-INF/classes directory if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfResourceName); if (jarEntry != null) { try { URL jarLocation = jarLocationByJarName.get(jarFile.getName()); if (jarLocation == null) jarLocation = new File(jarFile.getName()).toURI().toURL(); URL resourceUrl = new URL("jar:" + jarLocation.toExternalForm() + "!/" + jarEntry.getName()); resourceCache.put(resourceName, resourceUrl); return resourceUrl; } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in jar [" + jarFile + "] in war file [" + wrapperUrl + "]: " + e.toString()); } } } return super.findResource(resourceName); } /** @see java.lang.ClassLoader#findResources(java.lang.String) */ @Override public Enumeration findResources(String resourceName) throws IOException { String webInfResourceName = "WEB-INF/classes/" + resourceName; List urlList = new ArrayList<>(); int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); JarEntry jarEntry = jarFile.getJarEntry(resourceName); if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName()); // to better support war format, look for the resourceName in the WEB-INF/classes directory if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfResourceName); if (jarEntry != null) { try { URL jarLocation = jarLocationByJarName.get(jarFile.getName()); if (jarLocation == null) jarLocation = new File(jarFile.getName()).toURI().toURL(); urlList.add(new URL("jar:" + jarLocation.toExternalForm() + "!/" + jarEntry.getName())); } catch (MalformedURLException e) { System.out.println("Error making URL for [" + resourceName + "] in jar [" + jarFile + "] in war file [" + wrapperUrl + "]: " + e.toString()); } } } // add all resources found in parent loader too Enumeration superResources = super.findResources(resourceName); while (superResources.hasMoreElements()) urlList.add(superResources.nextElement()); return Collections.enumeration(urlList); } @Override protected synchronized Class loadClass(String className, boolean resolve) throws ClassNotFoundException { Class c = null; try { try { ClassLoader cl = getParent(); c = cl.loadClass(className); if (c != null) return c; } catch (ClassNotFoundException e) { /* let the next one handle this */ } try { c = findJarClass(className); if (c != null) return c; } catch (Exception e) { System.out.println("Error loading class [" + className + "] from jars in war file [" + wrapperUrl + "]: " + e.toString()); e.printStackTrace(); } throw new ClassNotFoundException("Class [" + className + "] not found"); } finally { if (c != null && resolve) { resolveClass(c); } } } private Class findJarClass(String className) throws IOException, ClassFormatError { if (classCache.containsKey(className)) return classCache.get(className); Class c = null; String classFileName = className.replace('.', '/') + ".class"; String webInfFileName = "WEB-INF/classes/" + classFileName; int jarFileListSize = jarFileList.size(); for (int i = 0; i < jarFileListSize; i++) { JarFile jarFile = jarFileList.get(i); // System.out.println("Finding Class [" + className + "] in jarFile [" + jarFile.getName() + "]"); JarEntry jarEntry = jarFile.getJarEntry(classFileName); if (reportJarsUnused && jarEntry != null) jarsUnused.remove(jarFile.getName()); // to better support war format, look for the resourceName in the WEB-INF/classes directory if (loadWebInf && jarEntry == null) jarEntry = jarFile.getJarEntry(webInfFileName); if (jarEntry != null) { definePackage(className, jarFile); byte[] jeBytes = getJarEntryBytes(jarFile, jarEntry); if (jeBytes == null) { System.out.println("Could not get bytes for [" + jarEntry.getName() + "] in [" + jarFile.getName() + "]"); continue; } // System.out.println("Class [" + classFileName + "] FOUND in jarFile [" + jarFile.getName() + "], size is " + (jeBytes == null ? "null" : jeBytes.length)); URL jarLocation = jarLocationByJarName.get(jarFile.getName()); c = defineClass(className, jeBytes, 0, jeBytes.length, jarLocation != null ? getProtectionDomain(jarLocation) : pd); break; } } classCache.put(className, c); return c; } private void definePackage(String className, JarFile jarFile) throws IllegalArgumentException { Manifest mf = null; try { mf = jarFile.getManifest(); } catch (IOException e) { System.out.println("Error getting manifest from " + jarFile.getName() + ": " + e.toString()); } // if no manifest use default if (mf == null) mf = new Manifest(); int dotIndex = className.lastIndexOf('.'); String packageName = dotIndex > 0 ? className.substring(0, dotIndex) : ""; // NOTE: for Java 11 changed getPackage() to getDefinedPackage(), can't do before because getDefinedPackage() doesn't exist in Java 8 if (getDefinedPackage(packageName) == null) { definePackage(packageName, mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_TITLE), mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VERSION), mf.getMainAttributes().getValue(Attributes.Name.SPECIFICATION_VENDOR), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_TITLE), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VERSION), mf.getMainAttributes().getValue(Attributes.Name.IMPLEMENTATION_VENDOR), getSealURL(mf)); } } private URL getSealURL(Manifest mf) { String seal = mf.getMainAttributes().getValue(Attributes.Name.SEALED); if (seal == null) return null; try { return new URL(seal); } catch (MalformedURLException e) { return null; } } } } ================================================ FILE: framework/src/test/groovy/CacheFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.jcache.MCache import spock.lang.* class CacheFacadeTests extends Specification { @Shared ExecutionContext ec @Shared MCache testCache def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() testCache = ec.cache.getLocalCache("CacheFacadeTests") } def cleanupSpec() { ec.destroy() } def "add cache element"() { when: testCache.put("key1", "value1") int hitCountBefore = testCache.stats.getCacheHits() then: testCache.get("key1") == "value1" testCache.stats.getCacheHits() == hitCountBefore + 1 cleanup: testCache.clear() } /* New caches doesn't support this (local/MCache doesn't support size limit, distributed/Hazelcast can't be changed on the fly like this: def "overflow cache size limit"() { when: testCache.setMaxElements(3, Cache.LEAST_RECENTLY_ADDED) testCache.put("key1", "value1") testCache.put("key2", "value2") testCache.put("key3", "value3") testCache.put("key4", "value4") int hitCountBefore = testCache.getHitCount() int removeCountBefore = testCache.getRemoveCount() int missCountBefore = testCache.getMissCountTotal() then: testCache.getEvictionStrategy() == Cache.LEAST_RECENTLY_ADDED testCache.getMaxElements() == 3 testCache.size() == 3 testCache.getRemoveCount() == removeCountBefore testCache.get("key1") == null !testCache.containsKey("key1") testCache.getMissCountTotal() == missCountBefore + 1 testCache.get("key2") == "value2" testCache.getHitCount() == hitCountBefore + 1 cleanup: testCache.clear() // go back to size limit defaults testCache.setMaxElements(10000, Cache.LEAST_RECENTLY_USED) } */ def "get cache concurrently"() { def getCache = { ec.cache.getLocalCache("CacheFacadeConcurrencyTests") } when: def caches = ConcurrentExecution.executeConcurrently(10, getCache) then: caches.size() == 10 // all elements must be instances of the Cache class, no exceptions or nulls caches.every { item -> item instanceof MCache } // all elements must be references to the same object caches.every { item -> item.equals(caches[0]) } } // TODO: test cache expire time } ================================================ FILE: framework/src/test/groovy/ConcurrentExecution.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import groovy.transform.CompileStatic import java.util.concurrent.Callable import java.util.concurrent.CyclicBarrier import java.util.concurrent.ExecutionException import java.util.concurrent.Executors import java.util.concurrent.ExecutorService import java.util.concurrent.Future @CompileStatic class ConcurrentExecution { def static executeConcurrently(int threads, Closure closure) { ExecutorService executor = Executors.newFixedThreadPool(threads) CyclicBarrier barrier = new CyclicBarrier(threads) def futures = new LinkedList() for (int i = 0; i < threads; i++) { futures.add((Future) executor.submit(new Callable() { def call() throws Exception { barrier.await() closure.call() } })) } def values = new LinkedList() for (Future future: futures) { try { def value = future.get() values << value } catch (ExecutionException e) { values << e.cause } } return values } } ================================================ FILE: framework/src/test/groovy/EntityCrud.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.entity.EntityException import org.moqui.entity.EntityList import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.entity.EntityValue import org.moqui.Moqui import java.sql.Timestamp class EntityCrud extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() ec.transaction.begin(null) } def cleanup() { ec.artifactExecution.enableAuthz() ec.transaction.commit() } def "create and find TestEntity CRDTST1"() { when: ec.entity.makeValue("moqui.test.TestEntity") .setAll([testId:"CRDTST1", testMedium:"Test Name", lastUpdatedStamp:ec.user.nowTimestamp]) .create() EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition("testId", "CRDTST1").one() then: testEntity.testMedium == "Test Name" } def "update TestEntity CRDTST1"() { when: EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition("testId", "CRDTST1").one() testEntity.testMedium = "Test Name 2" testEntity.update() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"CRDTST1"]).one() then: testEntityCheck.testMedium == "Test Name 2" } def "update TestEntity CRDTST1 through cache"() { when: Exception immutableError = null EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition("testId", "CRDTST1").useCache(true).one() try { testEntity.testMedium = "Test Name Cache" } catch (EntityException e) { immutableError = e } then: immutableError != null } def "update TestEntity from list through cache"() { when: Exception immutableError = null EntityList testEntityList = ec.entity.find("moqui.test.TestEntity").condition("testId", "CRDTST1").useCache(true).list() EntityValue testEntity = testEntityList.first() try { testEntity.testMedium = "Test Name List Cache" } catch (EntityException e) { immutableError = e } then: immutableError != null } def "delete TestEntity CRDTST1"() { when: ec.entity.find("moqui.test.TestEntity").condition([testId:"CRDTST1"]).one().delete() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"CRDTST1"]).one() then: testEntityCheck == null } def "delete EnumerationType cascade"() { when: ec.entity.makeValue("moqui.basic.EnumerationType").setAll([enumTypeId:"TEST_DEL_ET", description:"Test delete enum type"]).create() ec.entity.makeValue("moqui.basic.Enumeration").setAll([enumId:"TDELEN1", enumTypeId:"TEST_DEL_ET", description:"Test delete enum 1"]).create() ec.entity.makeValue("moqui.basic.Enumeration").setAll([enumId:"TDELEN2", enumTypeId:"TEST_DEL_ET", description:"Test delete enum 2"]).create() EntityValue enumType = ec.entity.find("moqui.basic.EnumerationType").condition("enumTypeId", "TEST_DEL_ET").one() EntityList enumsBefore = enumType.findRelatedFk(null) boolean gotExpectedError = false try { enumType.deleteWithCascade(null, new HashSet()) } catch (EntityException e) { gotExpectedError = true } EntityList enumsBetween = enumType.findRelatedFk(null) enumType.deleteWithCascade(null, null) EntityValue enumTypeAfter = ec.entity.find("moqui.basic.EnumerationType").condition("enumTypeId", "TEST_DEL_ET").one() EntityList enumsAfter = enumType.findRelatedFk(null) then: enumsBefore.size() == 2 gotExpectedError enumsBetween.size() == 2 enumTypeAfter == null enumsAfter.size() == 0 } def "serialize And Deserialize"() { when: Timestamp nowStamp = new Timestamp(System.currentTimeMillis()) EntityValue origVal = ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"AnId", testMedium:"testMediumVal", testNumberInteger:123, testNumberDecimal:12.34, testDateTime:nowStamp]) ByteArrayOutputStream baos = new ByteArrayOutputStream() ObjectOutputStream oos = new ObjectOutputStream(baos) try { oos.writeObject(origVal) } catch (Throwable t) { t.println() t.printStackTrace() } oos.flush() ByteArrayInputStream bais = new ByteArrayInputStream(baos.toByteArray()) ObjectInputStream ois = new ObjectInputStream(bais) EntityValue deSerVal = (EntityValue) ois.readObject() then: deSerVal.testMedium == "testMediumVal" deSerVal.testNumberInteger == 123 deSerVal.testNumberDecimal == 12.34 deSerVal.testDateTime == nowStamp } } ================================================ FILE: framework/src/test/groovy/EntityFindTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.entity.EntityValue import org.moqui.Moqui import java.sql.Timestamp import org.moqui.entity.EntityCondition import org.moqui.entity.EntityList class EntityFindTests extends Specification { protected final static Logger logger = LoggerFactory.getLogger(EntityFindTests.class) @Shared ExecutionContext ec @Shared Timestamp timestamp def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() timestamp = ec.user.nowTimestamp } def cleanupSpec() { ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() ec.transaction.begin(null) ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testIndicator:null, testLong:"", testMedium:"Test Name", testNumberInteger:4321, testDateTime:timestamp]).createOrUpdate() } def cleanup() { ec.entity.makeValue("moqui.test.TestEntity").set("testId", "EXTST1").delete() ec.artifactExecution.enableAuthz() ec.transaction.commit() } @Unroll def "find TestEntity by single condition (#fieldName = #value)"() { expect: EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition(fieldName, value).one() testEntity != null testEntity.testId == "EXTST1" where: fieldName | value "testId" | "EXTST1" // fails on some DBs without pre-JDBC type conversion: "testNumberInteger" | "4321" "testNumberInteger" | 4321 // fails on some DBs without pre-JDBC type conversion: "testDateTime" | ec.l10n.format(timestamp, "yyyy-MM-dd HH:mm:ss.SSS") "testDateTime" | timestamp } def "find TestEntity and GeoAndType by null PK"() { when: EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition("testId", null).one() EntityValue geoAndType = ec.entity.find("moqui.basic.GeoAndType").condition("geoId", null).one() then: testEntity == null geoAndType == null } @Unroll def "find TestEntity by operator condition (#fieldName #operator #value)"() { expect: EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition(fieldName, operator, value).one() testEntity != null testEntity.testId == "EXTST1" where: fieldName | operator | value "testId" | EntityCondition.BETWEEN | ["EXTST0", "EXTST2"] "testId" | EntityCondition.EQUALS | "EXTST1" "testId" | EntityCondition.IN | ["EXTST1"] "testId" | EntityCondition.LIKE | "%XTST%" } @Unroll def "find TestEntity by searchFormMap (#inputsMap #resultId)"() { expect: EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").searchFormMap(inputsMap, null, null, "", false).one() resultId ? testEntity != null && testEntity.testId == resultId : testEntity == null where: inputsMap | resultId [testId: "EXTST1", testId_op: "equals"] | "EXTST1" [testId: "%XTST%", testId_op: "like"] | "EXTST1" [testId: "XTST", testId_op: "contains"] | "EXTST1" [testMedium:"Test Name", testIndicator_op: "empty"] | "EXTST1" [testMedium:"Test Name", testLong_op: "empty"] | "EXTST1" [testMedium:"Test Name", testDateTime_from: "", testDateTime_thru: ""] | "EXTST1" [testMedium:"Test Name", testDateTime_from: timestamp, testDateTime_thru: timestamp - 1] | null [testMedium:"Test Name", testDateTime_from: timestamp, testDateTime_thru: timestamp + 1] | "EXTST1" [testNumberInteger:4321, testMedium_not: "Y", testMedium_op: "equals", testMedium: ""] | "EXTST1" [testNumberInteger:4321, testMedium_not: "Y", testMedium_op: "empty"] | "EXTST1" } def "find EnumerationType related FK"() { when: EntityValue enumType = ec.entity.find("moqui.basic.EnumerationType").condition("enumTypeId", "DataSourceType").one() EntityList enums = enumType.findRelatedFk(null) // for (EntityValue val in enums) logger.warn("DST Enum ${val.resolveEntityName()} ${val}") EntityList noEnums = enumType.findRelatedFk(new HashSet(["moqui.basic.Enumeration"])) then: enums.size() >= 4 noEnums.size() == 0 } def "auto cache clear for list"() { // update the testMedium and make sure we get the new value when: ec.entity.find("moqui.test.TestEntity").condition("testNumberInteger", 4321).useCache(true).list() ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testMedium:"Test Name 2"]).update() EntityList testEntityList = ec.entity.find("moqui.test.TestEntity") .condition("testNumberInteger", 4321).useCache(true).list() then: testEntityList.size() == 1 testEntityList.first.testMedium == "Test Name 2" } def "auto cache clear for one by primary key"() { when: ec.entity.find("moqui.test.TestEntity").condition("testId", "EXTST1").useCache(true).one() ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testMedium:"Test Name 3"]).update() EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition("testId", "EXTST1").useCache(true).one() then: testEntity.testMedium == "Test Name 3" } def "auto cache clear for one by non-primary key"() { when: ec.entity.find("moqui.test.TestEntity").condition([testNumberInteger:4321, testDateTime:timestamp]).useCache(true).one() ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testMedium:"Test Name 4"]).update() EntityValue testEntity = ec.entity.find("moqui.test.TestEntity") .condition([testNumberInteger:4321, testDateTime:timestamp]).useCache(true).one() then: testEntity.testMedium == "Test Name 4" } def "auto cache clear for one by non-pk and initially no result"() { when: EntityValue testEntity1 = ec.entity.find("moqui.test.TestEntity").condition([testMedium:"Test Name 5"]).useCache(true).one() ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testMedium:"Test Name 5"]).update() EntityValue testEntity2 = ec.entity.find("moqui.test.TestEntity").condition([testMedium:"Test Name 5"]).useCache(true).one() then: testEntity1 == null testEntity2 != null testEntity2.testMedium == "Test Name 5" } def "auto cache clear for list on update of record not included"() { // update the testMedium and make sure we get the new value when: ec.entity.find("moqui.test.TestEntity").condition("testNumberInteger", 1234).useCache(true).list() ec.entity.makeValue("moqui.test.TestEntity").setAll([testId:"EXTST1", testNumberInteger:1234]).update() EntityList testEntityList = ec.entity.find("moqui.test.TestEntity") .condition("testNumberInteger", 1234).useCache(true).list() then: testEntityList.size() == 1 testEntityList.first.testNumberInteger == 1234 } def "auto cache clear for view list on create of record not included"() { // this is similar to what happens with authz checking with changes after startup when: EntityList beforeList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") .condition("userGroupId", "ADMIN").useCache(true).list() // this record exists for ALL_USERS, but not for ADMIN (redundant for ADMIN, but a good test) ec.entity.makeValue("moqui.security.ArtifactAuthz") .setAll([artifactAuthzId:"SCREEN_TREE_ADMIN", userGroupId:"ADMIN", artifactGroupId:"SCREEN_TREE", authzTypeEnumId:"AUTHZT_ALWAYS", authzActionEnumId:"AUTHZA_VIEW"]).create() EntityList afterList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") .condition("userGroupId", "ADMIN").useCache(true).list() // logger.info("ArtifactAuthzCheckView before (${beforeList.size()}):\n${beforeList}\n after (${afterList.size()}):\n${afterList}") then: // afterList will have 2 more records because SCREEN_TREE artifact group has 2 records afterList.size() == beforeList.size() + 2 afterList.filterByAnd([artifactGroupId:"SCREEN_TREE"]).size() == 2 } def "auto cache clear for view list on create of related record not included"() { // this is similar to what happens with authz checking with changes after startup when: EntityList beforeList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") .condition("userGroupId", "ADMIN").useCache(true).list() EntityValue ev = ec.entity.makeValue("moqui.security.ArtifactGroupMember") .setAll([artifactGroupId:"SCREEN_TREE", artifactName: "TEST", artifactTypeEnumId:"AT_XML_SCREEN"]).create() EntityList afterList = ec.entity.find("moqui.security.ArtifactAuthzCheckView") .condition("userGroupId", "ADMIN").useCache(true).list() ev.delete() // logger.info("ArtifactAuthzCheckView before (${beforeList.size()}):\n${beforeList}\n after (${afterList.size()}):\n${afterList}") then: afterList.size() == beforeList.size() + 1 afterList.filterByAnd([artifactGroupId:"SCREEN_TREE"]).size() == 3 } def "auto cache clear for view one after update of member"() { when: EntityValue before = ec.entity.find("moqui.basic.GeoAndType").condition("geoId", "USA").useCache(true).one() ec.entity.makeValue("moqui.basic.Enumeration").setAll([enumId:"GEOT_COUNTRY", description:"Country2"]).update() EntityValue after = ec.entity.find("moqui.basic.GeoAndType").condition("geoId", "USA").useCache(true).one() // set it back so data isn't funny after tests ec.entity.makeValue("moqui.basic.Enumeration").setAll([enumId:"GEOT_COUNTRY", description:"Country"]).update() EntityValue reset = ec.entity.find("moqui.basic.GeoAndType").condition("geoId", "USA").useCache(true).one() then: before.typeDescription == "Country" after.typeDescription == "Country2" reset.typeDescription == "Country" } def "auto cache clear for count by is not null after update"() { when: long before = ec.entity.find("moqui.basic.Enumeration").condition("enumCode", "is-not-null", null).useCache(true).count() EntityValue enumVal = ec.entity.find("moqui.basic.Enumeration").condition("enumId", "DST_PURCHASED_DATA").useCache(false).one() enumVal.enumCode = "TEST" enumVal.update() long after = ec.entity.find("moqui.basic.Enumeration").condition("enumCode", "is-not-null", null).useCache(true).count() // set it back so data isn't funny after tests, and test clear after reset to null enumVal.enumCode = null enumVal.update() long reset = ec.entity.find("moqui.basic.Enumeration").condition("enumCode", "is-not-null", null).useCache(true).count() // logger.warn("count before ${before} after ${after} reset ${reset}") then: before + 1 == after reset == before } def "no cache with for update"() { when: // do query on Geo which has cache=true, with for-update it should not use the cache EntityValue geo = ec.entity.find("moqui.basic.Geo").condition("geoId", "USA").forUpdate(true).one() then: geo.isMutable() } } ================================================ FILE: framework/src/test/groovy/EntityNoSqlCrud.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.entity.EntityList import org.moqui.entity.EntityListIterator import org.moqui.entity.EntityValue import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Shared import spock.lang.Specification import java.sql.Time import java.sql.Timestamp class EntityNoSqlCrud extends Specification { protected final static Logger logger = LoggerFactory.getLogger(EntityNoSqlCrud.class) @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() ec.transaction.begin(null) } def cleanup() { ec.artifactExecution.enableAuthz() ec.transaction.commit() } def "create and find TestNoSqlEntity TEST1"() { when: long curTime = System.currentTimeMillis() ec.entity.makeValue("moqui.test.TestNoSqlEntity") .setAll([testId:"TEST1", testMedium:"Test Name", testLong:"Very Long ".repeat(200), testIndicator:"N", testDate:new java.sql.Date(curTime), testDateTime:new Timestamp(curTime), testTime:new Time(curTime), testNumberInteger:Long.MAX_VALUE, testNumberDecimal:BigDecimal.ZERO, testNumberFloat:Double.MAX_VALUE, testCurrencyAmount:1111.12, testCurrencyPrecise:2222.12345]) .createOrUpdate() EntityValue testCheck = ec.entity.find("moqui.test.TestNoSqlEntity").condition("testId", "TEST1").one() // logger.warn("testCheck.testTime ${testCheck.testTime} ${testCheck.testTime.getTime()} type ${testCheck.testTime?.class} new Time(curTime) ${new Time(curTime)} ${curTime}") then: testCheck.testMedium == "Test Name" testCheck.testLong.toString().startsWith("Very Long Very Long") testCheck.testDate == new java.sql.Date(curTime) testCheck.testDateTime == new Timestamp(curTime) // compare time strings because object compare with original and truncated long millis are not considered the same, even if the time is the same testCheck.testTime.toString() == new Time(curTime).toString() testCheck.testNumberInteger == Long.MAX_VALUE testCheck.testNumberDecimal == BigDecimal.ZERO testCheck.testNumberFloat == Double.MAX_VALUE testCheck.testCurrencyAmount == 1111.12 testCheck.testCurrencyPrecise == 2222.12345 } def "update TestNoSqlEntity TEST1"() { when: EntityValue testValue = ec.entity.find("moqui.test.TestNoSqlEntity").condition("testId", "TEST1").one() testValue.testMedium = "Test Name 2" testValue.update() EntityValue testCheck = ec.entity.find("moqui.test.TestNoSqlEntity").condition([testId:"TEST1"]).one() then: testCheck.testMedium == "Test Name 2" } def "delete TestNoSqlEntity TEST1"() { when: ec.entity.find("moqui.test.TestNoSqlEntity").condition([testId:"TEST1"]).one().delete() EntityValue testCheck = ec.entity.find("moqui.test.TestNoSqlEntity").condition([testId:"TEST1"]).one() then: testCheck == null } def "createBulk TestNoSqlEntity"() { when: long beforeCount = ec.entity.find("moqui.test.TestNoSqlEntity").count() int recordCount = 200 List createList = new ArrayList<>(recordCount) for (int i = 0; i < recordCount; i++) { EntityValue newValue = ec.entity.makeValue("moqui.test.TestNoSqlEntity") newValue.setAll([testId:"BULK" + i, testMedium:"Test Name ${i}", testNumberInteger:i]) createList.add(newValue) } ec.entity.createBulk(createList) long afterCount = ec.entity.find("moqui.test.TestNoSqlEntity").count() // logger.warn("beforeCount ${beforeCount} recordCount ${recordCount} afterCount ${afterCount}") then: afterCount == beforeCount + recordCount } def "ELI find TestNoSqlEntity"() { when: EntityList partialEl = null EntityValue first = null try (EntityListIterator eli = ec.entity.find("moqui.test.TestNoSqlEntity") .orderBy("-testNumberInteger").iterator()) { partialEl = eli.getPartialList(0, 100, false) eli.beforeFirst() first = eli.next() } catch (Exception e) { logger.error("partialEl error", e) } // logger.warn("partialEl.size() ${partialEl.size()} first value ${first}") then: partialEl?.size() == 100 first?.testNumberInteger == 199 } } ================================================ FILE: framework/src/test/groovy/L10nFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.Moqui import org.moqui.entity.EntityValue import java.sql.Timestamp class L10nFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } @Unroll def "get Localized Message (#original - #language #country)"() { // NOTE: this relies on a LocalizedMessage records in CommonL10nData.xml expect: ec.user.setLocale(new Locale(language, country)) localized == ec.l10n.localize(original) cleanup: ec.user.setLocale(Locale.US) where: original | language | country | localized "Create" | "en" | "" | "Create" "Create" | "es" | "" | "Crear" "Create" | "es" | "ES" | "Crear" "Create" | "es" | "MX" | "Crear" "Create" | "fr" | "" | "Cr\u00E9er" "Create" | "zh" | "" | "\u65B0\u5EFA" // for XML: 新建 "Not Localized" | "en" | "" | "Not Localized" "Not Localized" | "es" | "" | "Not Localized" "Not Localized" | "zh" | "" | "Not Localized" } @Unroll def "LocalizedEntityField with Enumeration.description (#enumId - #language #country)"() { // NOTE: this relies on a LocalizedEntityField records in CommonL10nData.xml setup: ec.artifactExecution.disableAuthz() expect: ec.user.setLocale(new Locale(language, country)) EntityValue enumValue = ec.entity.find("Enumeration").condition("enumId", enumId).one() localized == enumValue.get("description") cleanup: ec.artifactExecution.enableAuthz() ec.user.setLocale(Locale.US) where: enumId | language | country | localized "GEOT_CITY" | "en" | "" | "City" "GEOT_CITY" | "es" | "" | "Ciudad" "GEOT_CITY" | "es" | "ES" | "Ciudad" "GEOT_CITY" | "es" | "MX" | "Ciudad" "GEOT_CITY" | "zh" | "" | "\u5E02" // for XML: 市 "GEOT_STATE" | "en" | "" | "State" "GEOT_STATE" | "es" | "" | "Estado" "GEOT_COUNTRY" | "es" | "" | "Pa\u00EDs" } /* TODO alternative for example def "localized message with variable expansion"() { // test localized message with variable expansion (ensure translate then expand) // NOTE: this relies on a LocalizedMessage record in ExampleL10nData.xml expect: ec.l10n.localize("Test expansion \${ec.user.locale} original") == "Test expansion \${ec.tenantId} localized" ec.resource.expand("Test expansion \${ec.user.locale} original", "") == "Test expansion DEFAULT localized" } */ def "format USD and GBP currency in US and UK locales"() { expect: ec.user.setLocale(Locale.US) ec.l10n.formatCurrency(new BigDecimal("12.34"), "USD", 2) == '$12.34' ec.l10n.formatCurrency(new BigDecimal("43.21"), "GBP", 2) in ["GBP43.21", "£43.21"] ec.user.setLocale(Locale.UK) ec.l10n.formatCurrency(new BigDecimal("12.34"), "USD", 2) in ["USD12.34", '$12.34'] ec.l10n.formatCurrency(new BigDecimal("43.21"), "GBP", 2) == "\u00A343.21" cleanup: // back to the default ec.user.setLocale(Locale.US) } @Unroll def "format output value (#value - #format)"() { expect: result == ec.l10n.format(value, format) where: value | format | result new BigDecimal("5") | "##.#" | "5" new BigDecimal("5") | "##.00" | "5.00" Timestamp.valueOf("2010-01-02 12:34:56.789") | "yyyy-MM-dd" | "2010-01-02" Timestamp.valueOf("2010-01-02 12:34:56.789") | "d MMM yyyy" | "2 Jan 2010" Timestamp.valueOf("2010-01-02 12:34:56.789") | "hh:mm:ss" | "12:34:56" } def "parse time"() { expect: java.sql.Time.valueOf("12:34:56") == ec.l10n.parseTime("12:34:56", "HH:mm:ss") java.sql.Time.valueOf("00:34:56") == ec.l10n.parseTime("12:34:56 AM", "hh:mm:ss a") java.sql.Time.valueOf("12:34:56") == ec.l10n.parseTime("12:34:56 PM", "hh:mm:ss a") } def "parse date"() { expect: java.sql.Date.valueOf("2010-01-02") == ec.l10n.parseDate("2010-01-02", "yyyy-MM-dd") java.sql.Date.valueOf("2010-01-02") == ec.l10n.parseDate("2 Jan 2010", "d MMM yyyy") } def "parse timestamp"() { expect: Timestamp.valueOf("2010-01-02 12:34:56.000") == ec.l10n.parseTimestamp("2010-01-02 12:34:56", "yyyy-MM-dd HH:mm:ss") } // TODO test parseDateTime // TODO test parseNumber } ================================================ FILE: framework/src/test/groovy/MessageFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.Moqui import org.moqui.entity.EntityValue import java.sql.Timestamp class MessageFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def "add non-error message"() { when: String testMessage = "This is a test message" ec.message.addMessage(testMessage) then: ec.message.messages.contains(testMessage) ec.message.messagesString.contains(testMessage) !ec.message.hasError() cleanup: ec.message.clearAll() } def "add public message"() { when: String testMessage = "This is a test public message" ec.message.addPublic(testMessage, 'warning') then: ec.message.messages.contains(testMessage) ec.message.messageInfos[0].typeString == 'warning' ec.message.messagesString.contains(testMessage) ec.message.publicMessages.contains(testMessage) ec.message.publicMessageInfos[0].typeString == 'warning' !ec.message.hasError() cleanup: ec.message.clearAll() } def "add error message"() { when: String testMessage = "This is a test error message" ec.message.addError(testMessage) then: ec.message.errors.contains(testMessage) ec.message.errorsString.contains(testMessage) ec.message.hasError() cleanup: ec.message.clearErrors() } def "add validation error"() { when: String errorMessage = "This is a test validation error" ec.message.addValidationError("form", "field", "service", errorMessage, new Exception("validation error location")) then: ec.message.validationErrors[0].message == errorMessage ec.message.validationErrors[0].form == "form" ec.message.validationErrors[0].field == "field" ec.message.validationErrors[0].serviceName == "service" ec.message.errorsString.contains(errorMessage) ec.message.hasError() cleanup: ec.message.clearErrors() } } ================================================ FILE: framework/src/test/groovy/MoquiSuite.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.junit.jupiter.api.AfterAll import org.junit.platform.suite.api.SelectClasses import org.junit.platform.suite.api.Suite import org.moqui.Moqui // for JUnit Platform Suite annotations see: https://junit.org/junit5/docs/current/api/org.junit.platform.suite.api/org/junit/platform/suite/api/package-summary.html // for JUnit 5 Jupiter annotations see: https://junit.org/junit5/docs/current/user-guide/index.html#writing-tests-annotations @Suite @SelectClasses([ CacheFacadeTests.class, EntityCrud.class, EntityFindTests.class, EntityNoSqlCrud.class, L10nFacadeTests.class, MessageFacadeTests.class, ResourceFacadeTests.class, ServiceCrudImplicit.class, ServiceFacadeTests.class, SubSelectTests.class, TransactionFacadeTests.class, UserFacadeTests.class, SystemScreenRenderTests.class, ToolsRestApiTests.class, ToolsScreenRenderTests.class]) class MoquiSuite { @AfterAll static void destroyMoqui() { Moqui.destroyActiveExecutionContextFactory() } } ================================================ FILE: framework/src/test/groovy/ResourceFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.Moqui import org.moqui.resource.ResourceReference class ResourceFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } @Unroll def "get Location ResourceReference (#location)"() { expect: ResourceReference rr = ec.resource.getLocationReference(location) // the resolved location is different for some of these tests, so don't test for that: rr.location == location rr.uri.scheme == scheme rr.uri.host == host rr.fileName == fileName rr.contentType == contentType (!rr.supportsExists() || rr.exists) == exists (!rr.supportsDirectory() || rr.file) == isFile (!rr.supportsDirectory() || rr.directory) == isDirectory where: location | scheme | host | fileName | contentType | exists | isFile | isDirectory "component://tools/screen/Tools.xml" | "file" | null | "Tools.xml" | "text/xml" | true | true | false "component://tools/screen/ToolsFoo.xml" | "file" | null | "ToolsFoo.xml" | "text/xml" | false | false | false "classpath://entity/BasicEntities.xml" | "file" | null | "BasicEntities.xml" | "text/xml" | true | true | false "classpath://bitronix-default-config.properties" | "file" | null | "bitronix-default-config.properties" | "text/x-java-properties" | true | true | false "classpath://shiro.ini" | "file" | null | "shiro.ini" | "text/plain" | true | true | false "template/screen-macro/ScreenHtmlMacros.ftl" | "file" | null | "ScreenHtmlMacros.ftl" | "text/x-freemarker" | true | true | false "template/screen-macro" | "file" | null | "screen-macro" | "application/octet-stream" | true | false | true } @Unroll def "get Location Text (#location)"() { expect: String text = ec.resource.getLocationText(location, true) text.contains(contents) where: location | contents "component://tools/screen/Tools.xml" | "" "classpath://shiro.ini" | "org.moqui.impl.util.MoquiShiroRealm" } // TODO: add tests for template() and script() @Unroll def "groovy evaluate Condition (#expression)"() { expect: result == ec.resource.condition(expression, "") where: expression | result "true" | true "false" | false "ec.context instanceof org.moqui.util.ContextStack" | true } @Unroll def "groovy evaluate Context Field (#expression)"() { expect: result == ec.resource.expression(expression, "") where: expression | result "ec.factory.moquiVersion" | ec.factory.moquiVersion "null" | null "undefinedVariable" | null } @Unroll def "groovy evaluate String Expand (#inputString)"() { expect: result == ec.resource.expand(inputString, "") where: inputString | result 'Version: ${ec.factory.moquiVersion}' | "Version: ${ec.factory.moquiVersion}" "plain string" | "plain string" } } ================================================ FILE: framework/src/test/groovy/ServiceCrudImplicit.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.entity.EntityValue import org.moqui.Moqui class ServiceCrudImplicit extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() } def cleanup() { ec.artifactExecution.enableAuthz() } def "create and find TestEntity SVCTST1 with service"() { when: ec.service.sync().name("create#moqui.test.TestEntity").parameters([testId:"SVCTST1", testMedium:"Test Name"]).call() EntityValue testEntity = ec.entity.find("moqui.test.TestEntity").condition([testId:"SVCTST1"]).one() then: testEntity.testMedium == "Test Name" } def "update TestEntity SVCTST1 with service"() { when: ec.service.sync().name("update#moqui.test.TestEntity").parameters([testId:"SVCTST1", testMedium:"Test Name 2"]).call() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"SVCTST1"]).one() then: testEntityCheck.testMedium == "Test Name 2" } def "store update TestEntity SVCTST1 with service"() { when: ec.service.sync().name("store#moqui.test.TestEntity").parameters([testId:"SVCTST1", testMedium:"Test Name 3"]).call() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"SVCTST1"]).one() then: testEntityCheck.testMedium == "Test Name 3" } def "delete TestEntity SVCTST1 with service"() { when: ec.service.sync().name("delete#moqui.test.TestEntity").parameters([testId:"SVCTST1"]).call() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"SVCTST1"]).one() then: testEntityCheck == null } def "store create TestEntity TEST_A with service"() { when: ec.service.sync().name("store#moqui.test.TestEntity").parameters([testId:"SVCTSTA", testMedium:"Test Name A"]).call() EntityValue testEntityCheck = ec.entity.find("moqui.test.TestEntity").condition([testId:"SVCTSTA"]).one() then: testEntityCheck.testMedium == "Test Name A" } def "create and find TestIntPk 123 with service"() { when: // create with String for ID though is type number-integer, test single PK type conversion ec.service.sync().name("create#moqui.test.TestIntPk").parameters([intId:"123", testMedium:"Test Name"]).call() EntityValue testString = ec.entity.find("moqui.test.TestIntPk").condition([intId:"123"]).one() EntityValue testInt = ec.entity.find("moqui.test.TestIntPk").condition([intId:123]).one() then: testString?.testMedium == "Test Name" testInt?.testMedium == "Test Name" } } ================================================ FILE: framework/src/test/groovy/ServiceFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.impl.service.ServiceFacadeImpl import org.moqui.service.ServiceCallback import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.Moqui class ServiceFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def "register callback concurrently"() { def sfi = (ServiceFacadeImpl)ec.service ServiceCallback scb = Mock(ServiceCallback) when: ConcurrentExecution.executeConcurrently(10, { sfi.registerCallback("foo", scb) }) sfi.callRegisteredCallbacks("foo", null, null) then: 10 * scb.receiveEvent(null, null) } } ================================================ FILE: framework/src/test/groovy/SubSelectTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.entity.EntityFind import org.moqui.entity.EntityList import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Shared import spock.lang.Specification import java.sql.Timestamp class SubSelectTests extends Specification { protected final static Logger logger = LoggerFactory.getLogger(SubSelectTests.class) @Shared ExecutionContext ec @Shared Timestamp timestamp def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() timestamp = ec.user.nowTimestamp } def cleanupSpec() { ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() ec.transaction.begin(null) // create some entity to trigger the table creation. ec.entity.makeValue("moqui.test.Foo").setAll([fooId:"EXTST1"]).createOrUpdate() ec.entity.makeValue("moqui.test.Bar").setAll([barId:"EXTST1"]).createOrUpdate() ec.entity.makeValue("moqui.test.Foo").setAll([fooId:"EXTST1"]).delete() ec.entity.makeValue("moqui.test.Bar").setAll([barId:"EXTST1"]).delete() } def cleanup() { ec.artifactExecution.enableAuthz() ec.transaction.commit() } def "find subselect search form equal"() { when: EntityFind find = ec.entity.find("moqui.test.FooBar").searchFormMap(["barRank":100], null,null,null,true) EntityList list = find.list() then: list.isEmpty() find.getQueryTextList()[0].contains(" BAR_RANK = ? ") } def "find subselect search form range"() { when: EntityFind find = ec.entity.find("moqui.test.FooBar").searchFormMap(["barRank_from":100], null,null,null,true) EntityList list = find.list() then: list.isEmpty() find.getQueryTextList()[0].contains(" BAR_RANK >= ? ") } } ================================================ FILE: framework/src/test/groovy/SystemScreenRenderTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.screen.ScreenTest import org.moqui.screen.ScreenTest.ScreenTestRender import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Shared import spock.lang.Specification import spock.lang.Unroll class SystemScreenRenderTests extends Specification { protected final static Logger logger = LoggerFactory.getLogger(SystemScreenRenderTests.class) @Shared ExecutionContext ec @Shared ScreenTest screenTest def setupSpec() { ec = Moqui.getExecutionContext() ec.user.loginUser("john.doe", "moqui") screenTest = ec.screen.makeTest().baseScreenPath("apps/system") } def cleanupSpec() { long totalTime = System.currentTimeMillis() - screenTest.startTime logger.info("Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, "0.000")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, "#,##0")}k chars") ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() } def cleanup() { ec.artifactExecution.enableAuthz() } @Unroll def "render system screen #screenPath (#containsText1, #containsText2)"() { setup: ScreenTestRender str = screenTest.render(screenPath, [lastStandalone:"-2"], null) // logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms") boolean contains1 = containsText1 ? str.assertContains(containsText1) : true boolean contains2 = containsText2 ? str.assertContains(containsText2) : true if (!contains1) logger.info("In ${screenPath} text 1 [${containsText1}] not found:\n${str.output}") if (!contains2) logger.info("In ${screenPath} text 2 [${containsText2}] not found:\n${str.output}") expect: !str.errorMessages contains1 contains2 where: screenPath | containsText1 | containsText2 "dashboard" | "" | "" // NOTE: see AuditLog, DataDocument, EntitySync, SystemMessage, Visit screen tests in SystemScreenRenderTests in the example component // ArtifactHit screens "ArtifactHitSummary?artifactName=basic&artifactName_op=contains" | "moqui.basic.Enumeration" | "entity" "ArtifactHitBins?artifactName=basic&artifactName_op=contains" | "moqui.basic.Enumeration" | "create" // Cache screens "Cache/CacheList" | "entity.definition" | "artifact.tarpit.hits" "Cache/CacheElements?orderByField=key&cacheName=l10n.message" | '${artifactName}::en_US' | "evictionStrategy" // Localization screens "Localization/Messages" | "Add" | "Añadir" "Localization/EntityFields?entityName=moqui.basic.Enumeration&pkValue=GEOT_STATE" | "moqui.basic.Enumeration" | "GEOT_STATE" // Print screens // NOTE: without real printers setup and jobs sent through service calls not much to test in Print/* screens "Print/PrintJob/PrintJobList" | "" | "" "Print/Printer/PrinterList" | "" | "" // Resource screen // NOTE: without a real browser client not much to test in ElFinder "Resource/ElFinder" | "" | "" // Security screens "Security/UserAccount/UserAccountList?username=john.doe" | "john.doe" | "John Doe" "Security/UserAccount/UserAccountDetail?userId=EX_JOHN_DOE" | "john.doe@moqui.org" | "Administrators (full access)" "Security/UserGroup/UserGroupList" | "Administrators (full access)" | "" "Security/UserGroup/UserGroupDetail?userGroupId=ADMIN" | "" | "System App (via root screen)" "Security/UserGroup/GroupUsers?userGroupId=ADMIN" | "john.doe - John Doe" | "" "Security/ArtifactGroup/ArtifactGroupList" | "All Screens" | "" "Security/ArtifactGroup/ArtifactGroupDetail?artifactGroupId=SYSTEM_APP" | "component://tools/screen/System.xml" | "Administrators (full access)" } } ================================================ FILE: framework/src/test/groovy/TimezoneTest.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.entity.EntityValue import spock.lang.Shared import spock.lang.Specification import java.sql.Date import java.sql.Time import java.sql.Timestamp class TimezoneTest extends Specification { @Shared ExecutionContext ec @Shared String oldTz @Shared String oldDbTz def setupSpec() { // init the framework, get the ec oldTz = System.setProperty("default_time_zone", 'Pacific/Kiritimati') oldDbTz = System.setProperty("database_time_zone", 'US/Samoa') ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() if (oldTz == null) { System.clearProperty("default_time_zone") } else { System.setProperty("default_time_zone", oldTz) } if (oldDbTz == null) { System.clearProperty("database_time_zone") } else { System.setProperty("database_time_zone", oldDbTz) } } def setup() { ec.artifactExecution.disableAuthz() } def cleanup() { ec.artifactExecution.enableAuthz() } def "test timestamp with timezone"() { given: Timestamp ts = new Timestamp(0) when: EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity') testValue.set('testId', 'TIMEZONE1') testValue.set('testDateTime', ts) testValue.create() testValue.refresh() testValue.delete() then: testValue.testDateTime.time == ts.time } def "test time with timezone"() { given: Time t = new Time(0) when: EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity') testValue.set('testId', 'TIMEZONE1') testValue.set('testTime', t) testValue.create() testValue.refresh() testValue.delete() then: testValue.testTime.toLocalTime() == t.toLocalTime() } def "test date with timezone"() { given: Date d = new Date(0) when: EntityValue testValue = ec.entity.makeValue('moqui.test.TestEntity') testValue.set('testId', 'TIMEZONE1') testValue.set('testDate', d) testValue.create() testValue.refresh() testValue.delete() then: testValue.testDate.toLocalDate() == d.toLocalDate() } } ================================================ FILE: framework/src/test/groovy/ToolsRestApiTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.screen.ScreenTest import org.moqui.screen.ScreenTest.ScreenTestRender import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Shared import spock.lang.Specification import spock.lang.Unroll class ToolsRestApiTests extends Specification { protected final static Logger logger = LoggerFactory.getLogger(ToolsRestApiTests.class) @Shared ExecutionContext ec @Shared ScreenTest screenTest def setupSpec() { ec = Moqui.getExecutionContext() ec.user.loginUser("john.doe", "moqui") screenTest = ec.screen.makeTest().baseScreenPath("rest") } def cleanupSpec() { long totalTime = System.currentTimeMillis() - screenTest.startTime logger.info("Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, "0.000")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, "#,##0")}k chars") ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() } def cleanup() { ec.artifactExecution.enableAuthz() } @Unroll def "call Moqui Tools REST API #screenPath (#containsText1, #containsText2)"() { expect: ScreenTestRender str = screenTest.render(screenPath, null, null) // logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms") boolean contains1 = containsText1 ? str.assertContains(containsText1) : true boolean contains2 = containsText2 ? str.assertContains(containsText2) : true if (!contains1) logger.info("In ${screenPath} text 1 [${containsText1}] not found:\n${str.output}") if (!contains2) logger.info("In ${screenPath} text 2 [${containsText2}] not found:\n${str.output}") // assertions !str.errorMessages contains1 contains2 where: screenPath | containsText1 | containsText2 "s1/moqui/artifacts/hitSummary?artifactType=AT_ENTITY&artifactSubType=create&artifactName=moqui.basic&artifactName_op=contains" | "moqui.basic.StatusType" | '"artifactSubType" : "create"' "s1/moqui/basic/geos/USA" | "United States" | "Country" "s1/moqui/basic/geos/USA/regions" | "" | "" "s1/moqui/email/templates" | "PASSWORD_RESET" | "Default Password Reset" // TODO add more... current are enough to make sure Service REST API working generally, but more would be nice } } ================================================ FILE: framework/src/test/groovy/ToolsScreenRenderTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import org.moqui.Moqui import org.moqui.context.ExecutionContext import org.moqui.screen.ScreenTest import org.moqui.screen.ScreenTest.ScreenTestRender import org.slf4j.Logger import org.slf4j.LoggerFactory import spock.lang.Shared import spock.lang.Specification import spock.lang.Unroll class ToolsScreenRenderTests extends Specification { protected final static Logger logger = LoggerFactory.getLogger(ToolsScreenRenderTests.class) @Shared ExecutionContext ec @Shared ScreenTest screenTest def setupSpec() { ec = Moqui.getExecutionContext() ec.user.loginUser("john.doe", "moqui") screenTest = ec.screen.makeTest().baseScreenPath("apps/tools") } def cleanupSpec() { long totalTime = System.currentTimeMillis() - screenTest.startTime logger.info("Rendered ${screenTest.renderCount} screens (${screenTest.errorCount} errors) in ${ec.l10n.format(totalTime/1000, "0.000")}s, output ${ec.l10n.format(screenTest.renderTotalChars/1000, "#,##0")}k chars") ec.destroy() } def setup() { ec.artifactExecution.disableAuthz() } def cleanup() { ec.artifactExecution.enableAuthz() } @Unroll def "render tools screen #screenPath (#containsText1, #containsText2)"() { setup: ScreenTestRender str = screenTest.render(screenPath, [lastStandalone:"-2"], null) // logger.info("Rendered ${screenPath} in ${str.getRenderTime()}ms") boolean contains1 = containsText1 ? str.assertContains(containsText1) : true boolean contains2 = containsText2 ? str.assertContains(containsText2) : true if (!contains1) logger.info("In ${screenPath} text 1 [${containsText1}] not found:\n${str.output}") if (!contains2) logger.info("In ${screenPath} text 2 [${containsText2}] not found:\n${str.output}") expect: !str.errorMessages contains1 contains2 where: screenPath | containsText1 | containsText2 "dashboard" | "" | "" // AutoScreen screens "AutoScreen/MainEntityList" | "" | "" "AutoScreen/AutoFind?aen=moqui.test.TestEntity&testMedium=Test&testMedium_op=begins" | "Test Name A" | "" "AutoScreen/AutoEdit/AutoEditMaster?testId=SVCTSTA&aen=moqui.test.TestEntity" | "Test Name A" | "" // TODO "AutoScreen/AutoEdit/AutoEditDetail?exampleId=TEST1&aen=moqui.example.Example&den=moqui.example.ExampleItem" | "Amount Uom ID" | "Test 1 Item 1" // test moqui.test.TestEntity create through transition, then view it "AutoScreen/AutoFind/create?aen=moqui.test.TestEntity&testId=TEST_SCR&testMedium=Screen Test Example" | "" | "" "AutoScreen/AutoEdit/AutoEditMaster?testId=TEST_SCR&aen=moqui.test.TestEntity" | "Screen Test Example" | "" // ArtifactStats screen // don't run, takes too long: "ArtifactStats" | "" | "" // DataView screens // see "render DataView screens" // Entity/DataEdit screens "Entity/DataEdit/EntityList?filterRegexp=basic" | "Enumeration" | "moqui.basic" "Entity/DataEdit/EntityDetail?selectedEntity=moqui.test.TestEntity" | "text-medium" | "date-time" "Entity/DataEdit/EntityDataFind?selectedEntity=moqui.test.TestEntity" | "Test Name A" | "" "Entity/DataEdit/EntityDataEdit?testId=SVCTSTA&selectedEntity=moqui.test.TestEntity" | "Test Name A" | "" // Other Entity screens "Entity/DataExport" | "moqui.test.TestEntity" | "" // test export JSON and XML for moqui.test.TestEntity "Entity/DataExport/EntityExport?entityNames=moqui.test.TestEntity&dependentLevels=1&fileType=JSON&output=browser" | "Test Name A" | "testMedium" "Entity/DataExport/EntityExport?entityNames=moqui.test.TestEntity&dependentLevels=1&fileType=XML&output=browser" | "Test Name A" | "testMedium" "Entity/DataImport" | "" | "" // test admin user no longer has access to this by default: "Entity/SqlRunner?groupName=transactional&sql=SELECT * FROM TEST_ENTITY" | "Test Name A" | "" // run with very few baseCalls so it doesn't take too long "Entity/SpeedTest?baseCalls=10" | "" | "" // Service screens "Service/ServiceReference?serviceName=UserServices" | "org.moqui.impl.UserServices.create#UserAccount" | "Service Detail" "Service/ServiceDetail?serviceName=org.moqui.impl.UserServices.create#UserAccount" | "moqui.security.UserAccount.username" | """ec.service.sync().name("create#moqui.security.UserAccount")""" "Service/ServiceRun?serviceName=org.moqui.impl.UserServices.create#UserAccount" | "User Full Name" | "Run Service" // run the service, then make sure it ran "Service/ServiceRun/run?serviceName=org.moqui.impl.UserServices.create#UserAccount&username=ScreenTest&newPassword=moqui1!!&newPasswordVerify=moqui1!!&userFullName=Screen Test User&emailAddress=screen@test.com" | "" | "" "Entity/DataEdit/EntityDataFind?username=ScreenTest&selectedEntity=moqui.security.UserAccount" | "Screen Test User" | "screen@test.com" } def "render DataView screens"() { // create a DbViewEntity, set MASTER and fields, view it when: ScreenTestRender createStr = screenTest.render("DataView/FindDbView/create", [dbViewEntityName: 'UomDbView', packageName: 'test.basic', isDataView: 'Y'], null) logger.info("Called FindDbView/create in ${createStr.getRenderTime()}ms") ScreenTestRender fdvStr = screenTest.render("DataView/FindDbView", [lastStandalone:"-2"], null) logger.info("Rendered DataView/FindDbView in ${fdvStr.getRenderTime()}ms, ${fdvStr.output?.length()} characters") ScreenTestRender setMeStr = screenTest.render("DataView/EditDbView/setMasterEntity", [dbViewEntityName: 'UomDbView', entityAlias: 'MASTER', entityName: 'moqui.basic.Uom'], null) logger.info("Called EditDbView/setMasterEntity in ${setMeStr.getRenderTime()}ms") ScreenTestRender setMfStr = screenTest.render("DataView/EditDbView/setMasterFields", [dbViewEntityName_0: 'UomDbView', field_0: 'moqui.basic.Uom.description', dbViewEntityName_1: 'UomDbView', field_1: 'UomType#moqui.basic.Enumeration.description', dbViewEntityName_2: 'UomDbView', field_2: 'UomType#moqui.basic.Enumeration.enumTypeId'], null) logger.info("Called EditDbView/setMasterFields in ${setMfStr.getRenderTime()}ms") ScreenTestRender vdvStr = screenTest.render("DataView/ViewDbView?dbViewEntityName=UomDbView&orderByField=description", [lastStandalone:"-2"], null) logger.info("Rendered DataView/FindDbView in ${vdvStr.getRenderTime()}ms, ${vdvStr.output?.length()} characters") then: !createStr.errorMessages !fdvStr.errorMessages fdvStr.assertContains("UomDbView") !setMeStr.errorMessages !setMfStr.errorMessages !vdvStr.errorMessages vdvStr.assertContains("Afghani") vdvStr.assertContains("Area") // for Acre } } ================================================ FILE: framework/src/test/groovy/TransactionFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import java.sql.Connection import java.sql.Statement import org.moqui.Moqui import org.moqui.context.ExecutionContext import spock.lang.Shared import spock.lang.Specification class TransactionFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def "test connection bind to tx"() { when: boolean beganTransaction = false Connection rawCon1, rawCon2, rawCon3 try { beganTransaction = ec.transaction.begin(null) Connection conn1 = ec.entity.getConnection("transactional") Statement st = conn1.createStatement() rawCon1 = conn1.unwrap(Connection.class) conn1.close() Connection conn2 = ec.entity.getConnection("transactional") conn2.createStatement() rawCon2 = conn2.unwrap(Connection.class) conn2.close() Connection conn3 = ec.entity.getConnection("transactional") conn3.createStatement() rawCon3 = conn3.unwrap(Connection.class) conn3.close() } finally { ec.transaction.commit(beganTransaction) } then: noExceptionThrown() rawCon1 == rawCon2 rawCon1 == rawCon3 } def "test connection bind to tx atomikos bug"() { when: boolean beganTransaction = false Connection rawCon1, rawCon2, rawCon3 try { beganTransaction = ec.transaction.begin(null) Connection conn1 = ec.entity.getConnection("transactional") Statement st = conn1.createStatement() Connection conn2 = ec.entity.getConnection("transactional") conn2.createStatement() rawCon2 = conn2.unwrap(Connection.class) conn2.close() rawCon1 = conn1.unwrap(Connection.class) conn1.close() Connection conn3 = ec.entity.getConnection("transactional") conn3.createStatement() rawCon3 = conn3.unwrap(Connection.class) conn3.close() } finally { ec.transaction.commit(beganTransaction) } then: noExceptionThrown() rawCon1 == rawCon2 rawCon1 == rawCon3 } def "test suspend resume"() { when: boolean beganTransaction = false Connection rawCon1, rawCon2, rawCon3 try { beganTransaction = ec.transaction.begin(null) Connection conn1 = ec.entity.getConnection("transactional") Statement st = conn1.createStatement() rawCon1 = conn1.unwrap(Connection.class) conn1.close() ec.transaction.suspend() ec.transaction.begin(null) Connection conn2 = ec.entity.getConnection("transactional") conn2.createStatement() rawCon2 = conn2.unwrap(Connection.class) conn2.close() ec.transaction.commit() ec.transaction.resume() Connection conn3 = ec.entity.getConnection("transactional") conn3.createStatement() rawCon3 = conn3.unwrap(Connection.class) conn3.close() } finally { ec.transaction.commit(beganTransaction) } then: noExceptionThrown() rawCon1 != rawCon2 rawCon1 == rawCon3 } def "test atomikos bug"() { when: // This bug cause runtime add missing not work boolean beganTransaction = false Connection rawCon1, rawCon2, rawCon3 try { beganTransaction = ec.transaction.begin(null) Connection conn1 = ec.entity.getConnection("transactional") Statement st = conn1.createStatement() rawCon1 = conn1.unwrap(Connection.class) conn1.close() //A connection close without create statement cause atomikos mark //a previouse to delisted XAResource to terminate state. Connection conn2 = ec.entity.getConnection("transactional") rawCon2 = conn2.unwrap(Connection.class) conn2.close() // A new connection other than conn1 will return. Connection conn3 = ec.entity.getConnection("transactional") // Call createStatement cause enlist, will throw Exception. conn3.createStatement() rawCon3 = conn3.unwrap(Connection.class) conn3.close() } finally { ec.transaction.commit(beganTransaction) } then: noExceptionThrown() rawCon1 == rawCon2 rawCon1 == rawCon3 } } ================================================ FILE: framework/src/test/groovy/UserFacadeTests.groovy ================================================ /* * This software is in the public domain under CC0 1.0 Universal plus a * Grant of Patent License. * * To the extent possible under law, the author(s) have dedicated all * copyright and related and neighboring rights to this software to the * public domain worldwide. This software is distributed without any * warranty. * * You should have received a copy of the CC0 Public Domain Dedication * along with this software (see the LICENSE.md file). If not, see * . */ import spock.lang.* import org.moqui.context.ExecutionContext import org.moqui.Moqui class UserFacadeTests extends Specification { @Shared ExecutionContext ec def setupSpec() { // init the framework, get the ec ec = Moqui.getExecutionContext() } def cleanupSpec() { ec.destroy() } def "login user john.doe"() { expect: ec.user.loginUser("john.doe", "moqui") } def "check userId username currencyUomId locale userAccount.userFullName defaults"() { expect: ec.user.userId == "EX_JOHN_DOE" ec.user.username == "john.doe" ec.user.locale.toString() == "en_US" ec.user.timeZone.ID == "US/Central" ec.user.currencyUomId == "USD" ec.user.userAccount.userFullName == "John Doe" } def "set and get Locale UK"() { when: ec.user.setLocale(Locale.UK) then: ec.user.getLocale() == Locale.UK ec.user.getLocale().toString() == "en_GB" } def "set and get Locale US"() { when: // set back to en_us ec.user.setLocale(Locale.US) then: ec.user.locale.toString() == "en_US" } def "set and get TimeZone US/Pacific"() { when: ec.user.setTimeZone(TimeZone.getTimeZone("US/Pacific")) then: ec.user.getTimeZone() == TimeZone.getTimeZone("US/Pacific") ec.user.getTimeZone().getID() == "US/Pacific" ec.user.getTimeZone().getRawOffset() == -28800000 } def "set and get TimeZone US/Central"() { when: // set TimeZone back to default US/Central ec.user.setTimeZone(TimeZone.getTimeZone("US/Central")) then: ec.user.getTimeZone().getID() == "US/Central" } def "set and get currencyUomId GBP"() { when: ec.user.setCurrencyUomId("GBP") then: ec.user.getCurrencyUomId() == "GBP" } def "set and get currencyUomId USD"() { when: // reset to the default USD ec.user.setCurrencyUomId("USD") then: ec.user.getCurrencyUomId() == "USD" } def "check userGroupIdSet and isInGroup for ALL_USERS and ADMIN"() { expect: ec.user.userGroupIdSet.contains("ALL_USERS") ec.user.isInGroup("ALL_USERS") ec.user.userGroupIdSet.contains("ADMIN") ec.user.isInGroup("ADMIN") } /* TODO replacement for this def "check default admin group permission ExamplePerm"() { expect: ec.user.hasPermission("ExamplePerm") !ec.user.hasPermission("BogusPerm") } */ def "not in web context so no visit"() { expect: ec.user.visitId == null } def "set and get Preference"() { when: ec.user.setPreference("testPref1", "prefValue1") then: ec.user.getPreference("testPref1") == "prefValue1" } def "logout user"() { expect: ec.user.logoutUser() } } ================================================ FILE: framework/template/XmlActions.groovy.ftl ================================================ <#-- This software is in the public domain under CC0 1.0 Universal plus a Grant of Patent License. To the extent possible under law, the author(s) have dedicated all copyright and related and neighboring rights to this software to the public domain worldwide. This software is distributed without any warranty. You should have received a copy of the CC0 Public Domain Dedication along with this software (see the LICENSE.md file). If not, see . --> import static org.moqui.util.ObjectUtilities.* import static org.moqui.util.CollectionUtilities.* import static org.moqui.util.StringUtilities.* import java.sql.Timestamp import java.sql.Time import java.time.* // these are in the context by default: ExecutionContext ec, Map context, Map result <#visit xmlActionsRoot/> <#macro actions> <#recurse/> // make sure the last statement is not considered the return value return; <#macro "always-actions"> <#recurse/> // make sure the last statement is not considered the return value return; <#macro "pre-actions"> <#recurse/> // make sure the last statement is not considered the return value return; <#macro "row-actions"> <#recurse/> // make sure the last statement is not considered the return value return; <#-- NOTE should we handle out-map?has_content and async!=false with a ServiceResultWaiter? --> <#macro "service-call"> <#assign handleResult = (.node["@out-map"]?has_content && (!.node["@async"]?has_content || .node["@async"] == "false"))> <#assign outAapAddToExisting = !.node["@out-map-add-to-existing"]?has_content || .node["@out-map-add-to-existing"] == "true"> <#assign isAsync = .node.@async?has_content && .node.@async != "false"> if (true) { <#if handleResult>def call_service_result = ec.service.<#if isAsync>async()<#else>sync()<#rt> <#t>.name("${.node.@name}")<#if .node["@async"]?if_exists == "distribute">.distribute(true) <#t><#if !isAsync && .node["@disable-authz"]?if_exists == "true">.disableAuthz() <#t><#if !isAsync && .node["@multi"]?if_exists == "true">.multi(true)<#if !isAsync && .node["@multi"]?if_exists == "parameter">.multi(ec.web?.requestParameters?._isMulti == "true") <#t><#if !isAsync && .node["@transaction"]?has_content><#if .node["@transaction"] == "ignore">.ignoreTransaction(true)<#elseif .node["@transaction"] == "force-new" || .node["@transaction"] == "force-cache">.requireNewTransaction(true) <#t><#if !isAsync && .node["@transaction-timeout"]?has_content>.transactionTimeout(${.node["@transaction-timeout"]}) <#t><#if !isAsync && (.node["@transaction"] == "cache" || .node["@transaction"] == "force-cache")>.useTransactionCache(true)<#else>.useTransactionCache(false) <#if .node["@in-map"]?if_exists == "true">.parameters(context)<#elseif .node["@in-map"]?has_content && .node["@in-map"] != "false">.parameters(${.node["@in-map"]})<#list .node["field-map"] as fieldMap>.parameter("${fieldMap["@field-name"]}",<#if fieldMap["@from"]?has_content>${fieldMap["@from"]}<#elseif fieldMap["@value"]?has_content>"""${fieldMap["@value"]}"""<#else>${fieldMap["@field-name"]}).call() <#if handleResult><#if outAapAddToExisting>if (${.node["@out-map"]} != null) { if (call_service_result) ${.node["@out-map"]}.putAll(call_service_result) } else { ${.node["@out-map"]} = call_service_result <#if outAapAddToExisting>} <#if (.node["@web-send-json-response"]?if_exists == "true")> ec.web.sendJsonResponse(call_service_result) <#elseif (.node["@web-send-json-response"]?has_content && .node["@web-send-json-response"] != "false")> ec.web.sendJsonResponse(ec.resource.expression("${.node["@web-send-json-response"]}", "", call_service_result)) <#if (.node["@ignore-error"]?if_exists == "true")> if (ec.message.hasError()) { ec.logger.warn("Ignoring error running service ${.node.@name}: " + ec.message.getErrorsString()) ec.message.clearErrors() } <#else> if (ec.message.hasError()) return <#t> } <#macro "script"><#if .node["@location"]?has_content>ec.resource.script("${.node["@location"]}", null) // begin inline script ${.node} // end inline script <#macro set> <#if .node["@set-if-empty"]?has_content && .node["@set-if-empty"] == "false"> _temp_internal = <#if .node["@type"]?has_content>basicConvert(<#if .node["@from"]?has_content>${.node["@from"]}<#elseif .node["@value"]?has_content>"""${.node["@value"]}"""<#else>null<#if .node["@default-value"]?has_content> ?: """${.node["@default-value"]}"""<#if .node["@type"]?has_content>, "${.node["@type"]}") if (!isEmpty(_temp_internal)) ${.node["@field"]} = _temp_internal <#else> ${.node["@field"]} = <#if .node["@type"]?has_content>basicConvert(<#if .node["@from"]?has_content>${.node["@from"]}<#elseif .node["@value"]?has_content>"""${.node["@value"]}"""<#else>null<#if .node["@default-value"]?has_content> ?: """${.node["@default-value"]}"""<#if .node["@type"]?has_content>, "${.node["@type"]}") <#macro "order-map-list"> orderMapList(${.node["@list"]}, [<#list .node["order-by"] as ob>"${ob["@field-name"]}"<#if ob_has_next>, ]) <#macro "filter-map-list"> if (${.node["@list"]} != null) { <#if .node["@to-list"]?has_content> ${.node["@to-list"]} = new ArrayList(${.node["@list"]}) def _listToFilter = ${.node["@to-list"]} <#else> def _listToFilter = ${.node["@list"]} <#if .node["field-map"]?has_content> filterMapList(_listToFilter, [<#list .node["field-map"] as fm>"${fm["@field-name"]}":<#if fm["@value"]?has_content>"""${fm["@value"]}"""<#elseif fm["@from"]?has_content>${fm["@from"]}<#else>${fm["@field-name"]}<#if fm_has_next>, ]) <#list .node["date-filter"] as df> filterMapListByDate(_listToFilter, "${df["@from-field-name"]?default("fromDate")}", "${df["@thru-field-name"]?default("thruDate")}", <#if df["@valid-date"]?has_content>${df["@valid-date"]} ?: ec.user.nowTimestamp<#else>null, ${df["@ignore-if-empty"]?default("false")}) } <#macro "entity-sequenced-id-primary"> ${.node["@value-field"]}.setSequencedIdPrimary() <#macro "entity-sequenced-id-secondary"> ${.node["@value-field"]}.setSequencedIdSecondary() <#macro "entity-data"> // TODO impl entity-data <#-- =================== entity-find elements =================== --> <#macro "entity-find-one"> <#assign autoFieldMap = .node["@auto-field-map"]?if_exists> if (true) { org.moqui.entity.EntityValue find_one_result = ec.entity.find("${.node["@entity-name"]}")<#if .node["@cache"]?has_content>.useCache(${.node["@cache"]})<#if .node["@for-update"]?has_content>.forUpdate(${.node["@for-update"]})<#if .node["@use-clone"]?has_content>.useClone(${.node["@use-clone"]}) <#if autoFieldMap?has_content><#if autoFieldMap == "true">.condition(context)<#elseif autoFieldMap != "false">.condition(${autoFieldMap})<#elseif !.node["field-map"]?has_content>.condition(context)<#list .node["field-map"] as fieldMap>.condition("${fieldMap["@field-name"]}", <#if fieldMap["@from"]?has_content>${fieldMap["@from"]}<#elseif fieldMap["@value"]?has_content>"""${fieldMap["@value"]}"""<#else>${fieldMap["@field-name"]})<#list .node["select-field"] as sf>.selectField("${sf["@field-name"]}").one() if (${.node["@value-field"]} instanceof Map && !(${.node["@value-field"]} instanceof org.moqui.entity.EntityValue)) { if (find_one_result) ${.node["@value-field"]}.putAll(find_one_result) } else { ${.node["@value-field"]} = find_one_result } } <#macro "entity-find"> <#assign useCache = (.node["@cache"]?if_exists == "true")> <#assign listName = .node["@list"]> <#assign doPaginate = .node["search-form-inputs"]?has_content && !(.node["search-form-inputs"][0]["@paginate"]?if_exists == "false")> ${listName}_xafind = ec.entity.find("${.node["@entity-name"]}")<#if .node["@cache"]?has_content>.useCache(${.node["@cache"]})<#if .node["@for-update"]?has_content>.forUpdate(${.node["@for-update"]})<#if .node["@distinct"]?has_content>.distinct(${.node["@distinct"]})<#if .node["@use-clone"]?has_content>.useClone(${.node["@use-clone"]})<#if .node["@offset"]?has_content>.offset(${.node["@offset"]})<#if .node["@limit"]?has_content>.limit(${.node["@limit"]})<#list .node["select-field"] as sf>.selectField("${sf["@field-name"]}")<#list .node["order-by"] as ob>.orderBy("${ob["@field-name"]}") <#if !useCache><#list .node["date-filter"] as df>.condition(<#visit df/>)<#list .node["econdition"] as ecn>.condition(<#visit ecn/>)<#list .node["econditions"] as ecs>.condition(<#visit ecs/>) <#list .node["econdition-object"] as eco><#if eco["@field"]?has_content> if (${eco["@field"]} != null) { ${listName}_xafind.condition(${eco["@field"]}) } <#-- do having-econditions first, if present will disable cached query, used in search-form-inputs --> <#if .node["having-econditions"]?has_content>${listName}_xafind<#list .node["having-econditions"][0]?children as havingCond>.havingCondition(<#visit havingCond/>) <#if .node["search-form-inputs"]?has_content><#assign sfiNode = .node["search-form-inputs"][0]> if (true) { <#if sfiNode["default-parameters"]?has_content><#assign sfiDpNode = sfiNode["default-parameters"][0]> Map efSfiDefParms = [<#list sfiDpNode?keys as dpName>${dpName}:"""${sfiDpNode["@" + dpName]}"""<#if dpName_has_next>, ] <#else> Map efSfiDefParms = null <#if sfiNode["@require-parameters"]?has_content>${listName}_xafind.requireSearchFormParameters(ec.resource.expand('''${sfiNode["@require-parameters"]}''', "") == "true") ${listName}_xafind.searchFormMap(${sfiNode["@input-fields-map"]!"ec.context"}, efSfiDefParms, "${sfiNode["@skip-fields"]!("")}", "${sfiNode["@default-order-by"]!("")}", ${sfiNode["@paginate"]!("true")}) } <#if .node["limit-range"]?has_content && !useCache> org.moqui.entity.EntityListIterator ${listName}_xafind_eli = ${listName}_xafind.iterator() ${listName} = ${listName}_xafind_eli.getPartialList(${.node["limit-range"][0]["@start"]}, ${.node["limit-range"][0]["@size"]}, true) <#elseif .node["limit-view"]?has_content && !useCache> org.moqui.entity.EntityListIterator ${listName}_xafind_eli = ${listName}_xafind.iterator() ${listName} = ${listName}_xafind_eli.getPartialList((${.node["limit-view"][0]["@view-index"]} - 1) * ${.node["limit-view"][0]["@view-size"]}, ${.node["limit-view"][0]["@view-size"]}, true) <#elseif .node["use-iterator"]?has_content && !useCache> ${listName} = ${listName}_xafind.iterator() <#else> ${listName} = ${listName}_xafind.list() <#if useCache> <#list .node["date-filter"] as df> ${listName} = ${listName}.filterByDate("${df["@from-field-name"]?default("fromDate")}", "${df["@thru-field-name"]?default("thruDate")}", <#if df["@valid-date"]?has_content>${df["@valid-date"]} as java.sql.Timestamp<#else>null, ${df["@ignore-if-empty"]!"false"}) <#if doPaginate> <#-- get the Count after the date-filter, but before the limit/pagination filter --> ${listName}Count = ${listName}.size() ${listName} = ${listName}.filterByLimit("${sfiNode["@input-fields-map"]!""}", true) <#-- get the PageIndex and PageSize after date-filter AND after limit filter --> ${listName}PageIndex = ${listName}.pageIndex ${listName}PageSize = ${listName}.pageSize <#if doPaginate> <#if !useCache> if (${listName}_xafind.getLimit() == null) { ${listName}Count = ${listName}.size() ${listName}PageIndex = ${listName}.getPageIndex() ${listName}PageSize = ${listName}Count > 20 ? ${listName}Count : 20 } else { ${listName}PageIndex = ${listName}_xafind.getPageIndex() ${listName}PageSize = ${listName}_xafind.getPageSize() if (${listName}.size() < ${listName}PageSize) { ${listName}Count = ${listName}.size() + ${listName}PageIndex * ${listName}PageSize } else { ${listName}Count = ${listName}_xafind.count() } } ${listName}PageMaxIndex = ((BigDecimal) (${listName}Count - 1)).divide(${listName}PageSize ?: (${listName}Count - 1), 0, java.math.RoundingMode.DOWN) as int ${listName}PageRangeLow = ${listName}PageIndex * ${listName}PageSize + 1 ${listName}PageRangeHigh = (${listName}PageIndex * ${listName}PageSize) + ${listName}PageSize if (${listName}PageRangeHigh > ${listName}Count) ${listName}PageRangeHigh = ${listName}Count <#macro "entity-find-count"> <#if .node["search-form-inputs"]?has_content> <#assign sfiNode = .node["search-form-inputs"][0]> <#if sfiNode["default-parameters"]?has_content><#assign sfiDpNode = sfiNode["default-parameters"][0]> Map efSfiDefParams = [<#list sfiDpNode?keys as dpName>${dpName}:"""${sfiDpNode["@" + dpName]}"""<#if dpName_has_next>, ] <#else> Map efSfiDefParams = null ${.node["@count-field"]} = ec.entity.find("${.node["@entity-name"]}") <#t><#if .node["@cache"]?has_content>.useCache(${.node["@cache"]}) <#t><#if .node["@distinct"]?has_content>.distinct(${.node["@distinct"]}) <#t><#if .node["search-form-inputs"]?has_content>.searchFormMap(${"ec.context"}, efSfiDefParams, "${sfiNode["@skip-fields"]!("")}", null, false) <#t><#list .node["select-field"] as sf>.selectField("${sf["@field-name"]}") <#t><#list .node["date-filter"] as df>.condition(<#visit df/>) <#t><#list .node["econdition"] as econd>.condition(<#visit econd/>) <#t><#list .node["econditions"] as ecs>.condition(<#visit ecs/>) <#t><#list .node["econdition-object"] as eco>.condition(<#visit eco/>) <#t><#if .node["having-econditions"]?has_content><#list .node["having-econditions"]["*"] as havingCond>.havingCondition(<#visit havingCond/>) <#lt>.count() <#-- =================== entity-find sub-elements =================== --> <#macro "date-filter">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeConditionDate("${.node["@from-field-name"]!("fromDate")}", "${.node["@thru-field-name"]!("thruDate")}", <#if .node["@valid-date"]?has_content>${.node["@valid-date"]} as java.sql.Timestamp<#else>null, ${.node["@ignore-if-empty"]!("false")}, "${.node["@ignore"]!"false"}") <#macro "econdition">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeActionConditionDirect("${.node["@field-name"]}", "${.node["@operator"]!"equals"}", ${.node["@from"]?default(.node["@field-name"])}, <#if .node["@value"]?has_content>"${.node["@value"]}"<#else>null, <#if .node["@to-field-name"]?has_content>"${.node["@to-field-name"]}"<#else>null, ${.node["@ignore-case"]!"false"}, ${.node["@ignore-if-empty"]!"false"}, ${.node["@or-null"]!"false"}, "${.node["@ignore"]!"false"}") <#macro "econditions">(org.moqui.entity.EntityCondition) ec.entity.conditionFactory.makeCondition([<#list .node?children as subCond><#visit subCond/><#if subCond_has_next>, ], org.moqui.impl.entity.EntityConditionFactoryImpl.getJoinOperator("${.node["@combine"]!"and"}")) <#macro "econdition-object">${.node["@field"]} <#-- =================== entity other elements =================== --> <#macro "entity-find-related-one"> ${.node["@to-value-field"]} = ${.node["@value-field"]}?.findRelatedOne("${.node["@relationship-name"]}", ${.node["@cache"]!"null"}, ${.node["@for-update"]!"null"}) <#macro "entity-find-related"> ${.node["@list"]} = ${.node["@value-field"]}?.findRelated("${.node["@relationship-name"]}", ${.node["@map"]!"null"}, ${.node["@order-by-list"]!"null"}, ${.node["@cache"]!"null"}, ${.node["@for-update"]!"null"}) <#macro "entity-make-value"> ${.node["@value-field"]} = ec.entity.makeValue("${.node["@entity-name"]}")<#if .node["@map"]?has_content> ${.node["@value-field"]}.setFields(${.node["@map"]}, true, null, null) <#macro "entity-create"> ${.node["@value-field"]}<#if .node["@or-update"]?has_content && .node["@or-update"] == "true">.createOrUpdate()<#else>.create() <#macro "entity-update"> ${.node["@value-field"]}.update() <#macro "entity-delete"> ${.node["@value-field"]}.delete() <#macro "entity-delete-related"> ${.node["@value-field"]}.deleteRelated("${.node["@relationship-name"]}") <#macro "entity-delete-by-condition"> ec.entity.find("${.node["@entity-name"]}") <#list .node["date-filter"] as df>.condition(<#visit df/>)<#list .node["econdition"] as econd>.condition(<#visit econd/>)<#list .node["econditions"] as ecs>.condition(<#visit ecs/>)<#list .node["econdition-object"] as eco>.condition(<#visit eco/>).deleteAll() <#macro "entity-set"> ${.node["@value-field"]}.setFields(${.node["@map"]!"context"}, ${.node["@set-if-empty"]!"false"}, ${.node["@prefix"]!"null"}, <#if .node["@include"]?has_content && .node["@include"] == "pk">true<#elseif .node["@include"]?has_content && .node["@include"] == "nonpk"/>false<#else>null) <#macro break> break <#macro continue> continue <#macro iterate> <#if .node["@key"]?has_content> if (${.node["@list"]} instanceof Map) { ${.node["@entry"]}_index = 0 def _${.node["@entry"]}Iterator = ${.node["@list"]}.entrySet().iterator() while (_${.node["@entry"]}Iterator.hasNext()) { def ${.node["@entry"]}Entry = _${.node["@entry"]}Iterator.next() ${.node["@entry"]}_has_next = _${.node["@entry"]}Iterator.hasNext() ${.node["@entry"]} = ${.node["@entry"]}Entry.getValue() ${.node["@key"]} = ${.node["@entry"]}Entry.getKey() <#recurse/> ${.node["@entry"]}_index++ } } else if (${.node["@list"]} instanceof Collection) { ${.node["@entry"]}_index = 0 def _${.node["@entry"]}Iterator = ${.node["@list"]}.iterator() while (_${.node["@entry"]}Iterator.hasNext()) { def ${.node["@entry"]}Entry = _${.node["@entry"]}Iterator.next() ${.node["@entry"]}_has_next = _${.node["@entry"]}Iterator.hasNext() ${.node["@entry"]} = ${.node["@entry"]}Entry.getValue() ${.node["@key"]} = ${.node["@entry"]}Entry.getKey() <#recurse/> ${.node["@entry"]}_index++ } } else <#-- note no opening curly brace, will turn into "else if" with if below --> if (true) { int ${.node["@entry"]}_index = 0 Iterator _${.node["@entry"]}Iterator = ${.node["@list"]}.iterator() // behave differently for EntityListIterator, avoid using hasNext() boolean ${.node["@entry"]}IsEli = (_${.node["@entry"]}Iterator instanceof org.moqui.entity.EntityListIterator) while (${.node["@entry"]}IsEli || _${.node["@entry"]}Iterator.hasNext()) { ${.node["@entry"]} = _${.node["@entry"]}Iterator.next() if (${.node["@entry"]}IsEli && ${.node["@entry"]} == null) break if (!${.node["@entry"]}IsEli) ${.node["@entry"]}_has_next = _${.node["@entry"]}Iterator.hasNext() // begin iterator internal block <#recurse/> // end iterator internal block for list ${.node["@list"]} ${.node["@entry"]}_index++ } if(${.node["@entry"]}IsEli) _${.node["@entry"]}Iterator.close() } <#macro message> <#if .node["@error"]?has_content && .node["@error"] == "true"> ec.message.addError(ec.resource.expand('''${.node?trim}''','')) <#elseif .node["@public"]?has_content && .node["@public"] == "true"> ec.message.addPublic(ec.resource.expand('''${.node?trim}''',''), "${.node["@type"]!"info"}") <#else> ec.message.addMessage(ec.resource.expand('''${.node?trim}''',''), "${.node["@type"]!"info"}") <#macro "check-errors"> if (ec.message.errors) return <#-- NOTE: if there is an error message (in ec.messages.errors) then the actions result is an error, otherwise it is not, so we need a default error message here --> <#macro return> <#assign returnMessage = .node["@message"]!""/> <#if returnMessage?has_content><#if .node["@error"]?has_content && .node["@error"] == "true"> ec.message.addError(ec.resource.expand('''${returnMessage?trim}''' ?: "Error in actions",'')) <#elseif .node["@public"]?has_content && .node["@public"] == "true"> ec.message.addPublic(ec.resource.expand('''${returnMessage?trim}''',''), "${.node["@type"]!"info"}") <#else> ec.message.addMessage(ec.resource.expand('''${returnMessage?trim}''',''), "${.node["@type"]!"info"}") return; <#macro assert><#list .node["*"] as childCond> if (!(<#visit childCond/>)) ec.message.addError(ec.resource.expand('''<#if .node["@title"]?has_content>[${.node["@title"]}] Assert failed: <#visit childCond/>''','')) <#macro if> if (<#if .node["@condition"]?has_content>${.node["@condition"]}<#if .node["@condition"]?has_content && .node["condition"]?has_content> && <#if .node["condition"]?has_content><#recurse .node["condition"][0]/>) { <#recurse .node/><#if .node["then"]?has_content> <#recurse .node["then"][0]/> }<#if .node["else-if"]?has_content><#list .node["else-if"] as elseIf> else if (<#if elseIf["@condition"]?has_content>${elseIf["@condition"]}<#if elseIf["@condition"]?has_content && elseIf["condition"]?has_content> && <#if elseIf["condition"]?has_content><#recurse elseIf["condition"][0]/>) { <#recurse elseIf/><#if elseIf.then?has_content> <#recurse elseIf["then"][0]/> }<#if .node["else"]?has_content> else { <#recurse .node["else"][0]/> } <#macro while> while (<#if .node.@condition?has_content>${.node.@condition}<#if .node["@condition"]?has_content && .node["condition"]?has_content> && <#if .node["condition"]?has_content><#recurse .node["condition"][0]/>) { <#recurse .node/> } <#-- =================== if/when sub-elements =================== --> <#macro condition><#-- do nothing when visiting, only used explicitly inline --> <#macro then><#-- do nothing when visiting, only used explicitly inline --> <#macro "else-if"><#-- do nothing when visiting, only used explicitly inline --> <#macro else><#-- do nothing when visiting, only used explicitly inline --> <#macro or>(<#list .node.children as childNode><#visit childNode/><#if childNode_has_next> || ) <#macro and>(<#list .node.children as childNode><#visit childNode/><#if childNode_has_next> && ) <#macro not>!<#visit .node.children[0]/> <#macro compare> <#if (.node?size > 0)>if (compare(${.node["@field"]}, <#if .node["@operator"]?has_content>"${.node["@operator"]}"<#else>"equals", <#if .node["@value"]?has_content>"""${.node["@value"]}"""<#else>null, <#if .node["@to-field"]?has_content>${.node["@to-field"]}<#else>null, <#if .node["@format"]?has_content>"${.node["@format"]}"<#else>null, <#if .node["@type"]?has_content>"${.node["@type"]}"<#else>"Object")) { <#recurse .node/> }<#if .node.else?has_content> else { <#recurse .node.else[0]/> } <#else>compare(${.node["@field"]}, <#if .node["@operator"]?has_content>"${.node["@operator"]}"<#else>"equals", <#if .node["@value"]?has_content>"""${.node["@value"]}"""<#else>null, <#if .node["@to-field"]?has_content>${.node["@to-field"]}<#else>null, <#if .node["@format"]?has_content>"${.node["@format"]}"<#else>null, <#if .node["@type"]?has_content>"${.node["@type"]}"<#else>"Object") <#macro expression>${.node} <#-- =================== other elements =================== --> <#macro "log"> ec.logger.log(<#if .node["@level"]?has_content>"${.node["@level"]}"<#else>"info", """${.node["@message"]}""", null) ================================================ FILE: framework/xsd/common-types-3.xsd ================================================ Match against the regular expression in the comparison value. The name of a transition in the current screen. URL will be built basedon the transition definition. Technically the same as 'screen' as both are evaluated as a screen path. The path of a screen relative to the current screen (or the root screen if begins with '/' or '//' for a sparse path). A content location (without the content://). URL will be one that can access that content. A plain URL to be used literally (may be relative or start with http:// or https://). Authentication and authorization are required and checked Authentication and authorization are NOT required and not checked When used an anonymous user is effectively logged in and granted ALLOW authorization for view and update operations for the artifact and all artifacts below it. For a screen this grants an ALLOW authorization for all sub-screens, and for a service any services/entities/etc it uses. If an actual user is authenticated already no anonymous user is effectively logged in, but the ALLOW authorization is still added. Like anonymous-all but authorization is only granted for view (find) operations. Don't do anything with transactions (if one is in place use it, if no transaction in place don't begin one). Use active transaction or if no active transaction begin one. This is the default. Always begin a new transaction, pausing/resuming the active transaction if there is one. Like use-or-begin but with a write-through per-transaction cache in place (works even if active TX is in place). See notes and warnings in the JavaDoc comments of the TransactionCache class for details. Like force-new with a transaction cache in place like the cache option. ================================================ FILE: framework/xsd/email-eca-3.xsd ================================================ Whenever an email message is received the actions will be run if the condition is met. The context for the condition and actions will include a "headers" Map with all of the email headers in it (either String, or List of String if there are more than one of the header), a "fields" Map with the following: toList, ccList, bccList, from, subject, sentDate, and receivedDate, a "flags Map, and a bodyPartList which is a List of Map with info for each body part. For a full description of the structure see the org.moqui.EmailServices.process#EmailEca service interface. If a single service is used to process the email it should implement this interface. ================================================ FILE: framework/xsd/entity-definition-3.xsd ================================================ Use cache during queries by default (code may override this). Do not use cache during queries by default (code may override this). Do not use cache during queries ever(code may NOT override this). The intended use of an entity. transactional business entities that need atomic operations, immediate consistency, etc; typically never cached; includes things like work efforts, orders, shipments, inventory/assets, invoices, accounting and financial transactions non-transactional business entities; eventual consistency is adequate; may be cached; non-transactional does not mean transactions are not used, but that strict consistency is not important; includes things like parties, facilities, products, history/tracking data (master data, meta-data) framework and application configuration data; eventual consistency is okay; typically cached analytical entities with data typically derived from transactional data entities used for logging with non-transactional data that is generated in high volumes and used mostly for auditing and analytics Uses java.util.UUID.randomUUID() to get sequenced IDs for this entity. The maximum amount to stagger the sequenced ID, if 1 the sequence will be incremented by 1, otherwise the current sequence ID will be incremented by a value between 1 and staggerMax. Prefix to apply to primary sequenced ID values for this entity. Can be an expression (string expansion) with the current value added to the context. If specified front-pads the secondary sequenced value with zeroes until it is this length. Defaults to 2. The Entity Facade by default adds a single field (lastUpdatedStamp) to each entity for use in optimistic locking and data synchronization. If you do not want it to create that stamp for this entity then set this attribute to false. If true values are immutable, can only be created and not updated or deleted. No default (effectively defaults to false). If set this value will be used for @enable-audit-log attribute on all fields without a value set (the field level value overrides this value). Note that this also sets the value for the automatically added field lastUpdatedStamp so if enabled there will be a record of all updates. A short alias for the entity, mainly meant for REST URLs but entities may be referenced by this. Must be unique across all entities defined. If a duplicate is found the later loading entity will be used. Should be short, start with a lowercase character, and be plural (ie products, not product). Deprecated, use package attribute Deprecated, use group attribute Defaults to false. If true whenever the value for this field on a record changes the Entity Facade will record the change (create or update) in the moqui.entity.EntityAuditLog entity. If set to update will not create an audit record for the initial create, only for updates. If true gets on this field will be looked up with the moqui.basic.LocalizedEntityField entity and if there is a matching record the localized value there will be returned instead of the actual record's value. Defaults to false for performance reasons, only set to true for fields that will have translations. A Groovy expression with the default value of the field. It can be derived from other fields on the same record, set to a constant, etc. Set during create and update operations, after EECA before rules are run, and only if the field value is null or an empty String. If true values are immutable, can only be set on create and not update. Overrides entity.@create-only value, set to false explicitly to allow update of certain fields on create-only entities. Constant values for looking up related records, should only be used with type 'many' Deprecated, use the related attribute A short alias for the relationship, mainly meant for REST URLs but relationships may be referenced by this. Must be unique for relationships within an entity (ie only has meaning in the context of a particular entity). Should be short, start with a lowercase character, and be plural (ie products, not product). If true related record may be modified through create/update auto-service calls with child records. If false relationship is read-only for aggregated records (using master definition or dependent levels). Defaults to false for type one* relationships, to true for type many (many are generally detail or join entities). Deprecated, use the related attribute Define the structure of this entity as a master entity for outgoing messages and definitions for incoming messages, though all relationships are supported in incoming/imported data. Also useful for extended query to get all data associated with a master record. A name to distinguish multiple master definitions for an entity. Required when there is more than one master definition for an entity. The relationship linking the master or parent detail to the detail. May be either short-alias or full relationship name (${title}#${related-entity-name} or just related-entity-name if no title). The name of a master definition in the related entity to include all detail under this master. Correlated sub-select with ON conditions moved to WHERE clause of sub-select (far more efficient for what would be a large temporary table from the less constrained sub-select, common for Moqui view entities) Simple non-correlated sub-select (full sub-select run in temp table) Not a sub-select Specify if the function is an aggregate function. If unspecified determined automatically from function attribute. Normally determined automatically from the entity field it is based on but needs to be specified for complex-alias with a function if you want something other than number-decimal. Mostly used internally for auto form fields from entity fields. If not set uses default behavior (in form-list display all types except text-long, text-very-long, binary-very-long). Post-query Groovy expression evaluated using the values of other aliased fields on this view-entity. Meant primarily for use in EntityDynamicView instances created from DataDocument definitions. If specified the alias is not queried from the database and other attributes such as entity-alias, field, and function are ignored. In every SELECT statement, the fields that are normally used are really defined to be expressions. This means for example that you can supply an expression like (discountPercent * 100) in place of just a field name. The complex-alias tag is the way to do this. The argument to the right of operator = can be any operator valid for that data type on the database system you are using. For example *, +, -, and / are commonly available mathematical operators. You can also use any operator on any data type supported on the underlying database system including string and date operators. complex-alias can be as complex as you need by adding nested complex-alias statements and complex-alias-field can use the same functions (min, max, count, count-distinct, sum, avg, upper, and lower) as the alias tag. If specified operator is ignored and child elements are treated as function parameters If specified all else is ignored and only this is included in the SQL test. Expression may use ${} for field expansion Adds a econdition to find to filter by the from and thru dates in each record, comparing them to the valid-date value. The name of a field in the context to compare each value to. Defaults to now. The member-entity alias for the from and thru field names, if entity-condition under a member-entity (for join conditions) defaults to current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields. The name of the entity field to use as the from/beginning effective date. Defaults to fromDate. The name of the entity field to use as the thru/ending effective date.Defaults to thruDate. The member-entity alias for field-name, if entity-condition under a member-entity (for join conditions) defaults to current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields. The member-entity alias for to-field-name, if entity-condition under a member-entity (for join conditions) defaults to current member-entity alias. If no entity alias specified field names are treated as view-entity aliased fields. If true make a condition specified value or null as valid matches. ================================================ FILE: framework/xsd/entity-eca-3.xsd ================================================ Triggered by entity operations such a create, update, delete, and find. If condition (optional) evaluates to true then the actions are run. Entity ECAs are meant for maintenance of data derived from other entities. Entity ECAs should NOT generally be used for triggering business processes, Service ECA rules are a much better tool for that. For create, update, and delete operations the context coming in will be the current context plus the entity value's fields added to the context for convenience in reading, and a "entityValue" variable for the actual EntityValue object. Optional but recommended. If another EECA rule has the same id it will override any previously found with that id to change behavior or disable by override with empty actions. If false (default) runs after the entity operation. If true runs before the operation. Get the entire entity before running the actions for update and delete operations and add unset values to field values from the operation. Adds an 'originalValue' field to the context with the value from the database if called before the entity operation and is a update or delete. If true loop through field names and set on the entity values any values added to the context in the actions or in a Map returned from actions. ================================================ FILE: framework/xsd/framework-catalog.xml ================================================ ================================================ FILE: framework/xsd/moqui-conf-3.xsd ================================================ Comma-separated list of data file types to load if database is empty (if there are no records in the table for moqui.basic.Enumeration). Empty or 'none' means load nothing, use 'all' to load all found data files regardless of type. Comma-separated list of data file types to load on start. Empty or 'none' means load nothing. Does not run if empty-db-load runs. Comma-separated list of component names to load on start, used with on-start-load-types. Does not run if empty-db-load runs. The maximum size of the worker queue. The core (minimum) size of the worker thread pool. The maximum size of the worker thread pool. The amount of time, in seconds, to keep idle worker threads alive (beyond core pool size). The ToolFactory to use to get a SimpleTopic for distributed NotificationMessage Must implement the org.moqui.context.ToolFactory interface. The name of the ToolFactory to use for the local CacheManager implementation. The name of the ToolFactory to use for the distributed CacheManager implementation. Idle expire time in seconds. Live expire time in seconds. Local only cache (MCache) Distributed cache; keys and values must be serializable The bin length should be less than or equal to one hour and evenly divisible into an hour, the default is 900 seconds (15 minutes) If evaluates to true skips creating visit and visitor, and doesn't track ArtifactHit for screens. A pattern to match the host name against (from ServletRequest.getServerName()) The location of the root screen XML file 401 - authentication required 403 - authorization failed 404 - screen/resource not found 429 - tarpit limit reached 500 - general error The path to the screen to render on error Note that for Jetty, and other servlet containers, these can only be implementations of HttpSessionListener, HttpSessionIdListener, and HttpSessionAttributeListener; other listeners such as ServletContextListener implementations must be in the web.xml file (cannot be loaded when MoquiContextListener initializes). Add a WebSocket Endpoint The path for the endpoint, relative to webapp, must start with forward slash ('/') Must extend javax.websocket.Endpoint. For Moqui-specific features including setting up an ExecutionContext extend org.moqui.impl.webapp.MoquiAbstractEndpoint. Even if not using MoquiAbstractEndpoint it will try to add the following ServerEndpointConfig user properties: handshakeRequest, httpSession, executionContextFactory, and maxIdleTimeout (set to value of @timeout). Matched against value of a context-param.param-value element in the web.xml file where the param-name is "moqui-name". Set to false to disable CORS handling globally in MoquiServlet including Origin validation and adding cors-preflight and cors-actual configured response headers Comma separated list of host name or protocol plus host name to match against Origin request header; can be '*' to allow all If not false (default true) moquiSessionToken (from ec.web.sessionToken) must be passed to all screen/transition requests in a session after the first. The default WebSocket session max idle timeout for the whole server. Set this if there is a reverse proxy in front of the Moqui server. Using X-Forwarded-For is risky because clients can set a value for the header to spoof a client IP address; if the outer-most proxy handles X-Forwarded-For by always setting it to its client IP address instead of the common default behavior of appending to an existing X-Forwarded-For header then it is fine, but should generally be set to a more reliable header for the reverse-proxy used. Minimum length of password Minimum number of digits (numeric characters) in the password Minimum number of other (non-alpha/numeric, not letters or digits) characters in the password Number of old passwords to save that cannot be reused (0 means don't save any history) Require password change after this many months (0 means don't ever require change) Require password change after a password reset email? How long will the new password be valid from the password reset email? Expire key after this many hours Account is disabled after max failures How long to disable the account (0 means no limit, ie forever) Store records of login attempts? Store incorrect passwords in login attempt history? If not present the default JNDI server will be used. If true track locks from create, update, and delete plus FK locks and find for-update, use that data to warn about possible lock conflicts If true runs all JDBC statements in a separate Thread in order to enforce a timeout, this has significant overhead but protects against long held locks, etc Name of the ToolFactory to use for XSL-FO transformation in the ResourceFacade.xslFoTransform() method. If specified should point to a class that implements the org.moqui.context.ScriptRunner interface. The JSR-223 engine name, i.e. the name passed to the javax.script.ScriptEngine.getEngineByName() method. If you use this attribute do not use the class attribute as that will override this setting. NOTE: If you use a default extension supported in JSR-223 for the desired scripting language you do not need a script-runner element. The ScriptEngine will be looked up using the ScriptEngineManager.getEngineByExtension() method and the script (pre-compiled if supported) will be cached in the "resource.script${extension}.location" cache. See the screen.subscreens.subscreens-item element in xml-screen-{version}.xsd for more details Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv. Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv. The name of the ToolFactory to use for the distributed async service ExecutorService implementation. How often to check for and run scheduled service jobs in seconds. Set to 0 (zero) to disable. The maximum number of jobs to queue when job-pool-max is reached. The core (minimum) size of the service job thread pool. The maximum size of the service job thread pool. The amount of time, in seconds, to keep idle worker threads alive (beyond core pool size). Fully qualified name of class that implements the org.moqui.impl.service.ServiceRunner interface. The username The password Prefix added to all ES index names just before requests Must follow ES index name requirements (lower case, etc) No separator character is added, recommend ending with underscore (_) Alternate settings for decryption only; useful for migrating keys, supporting data imported from other systems, etc If not present the default JNDI server will be used. Enable distributed cache invalidate by distributed Topic Topic factory for distributed cache invalidate This is only required for JDBC/SQL datasources. The references class must implement the org.moqui.entity.EntityDatasourceFactory interface. Uses java.util.UUID.randomUUID() to get sequenced IDs for all entities in this datasource. Currently only for the H2 database. Start a remote access server for the embedded DB using these arguments. See the main() method at http://www.h2database.com/javadoc/org/h2/tools/Server.html for details. Maximum time in seconds that unused excess connections should stay in the pool. Time in seconds that the connection pool will allow a connection to be in use, before claiming it back. Defaults to no limit. Running interval in seconds for the pool maintenance thread. Defaults to 60 seconds. Sets the maximum amount of time in seconds the pool will block waiting for a connection to become available in the pool when it is empty. Defaults to 30 seconds. Zero means no waiting. Most resources should be loaded by directory convention (convention over configuration) within a component so this should only be used rarely. Sometimes that is not possible such as remote locations or on a classpath that is inside a war or ear file, or that is from a special ClassLoader. Most resources should be loaded by directory convention (convention over configuration) within a component so this should only be used rarely. Sometimes that is not possible such as remote locations or on a classpath that is inside a war or ear file, or that is from a special ClassLoader. Name of the database for Liquibase, defaults to name attribute, see http://www.liquibase.org/databases.html Use manually declared indexes? For manually declared indexes (if used), use the unique constraint? For manually declared indexes (if used), unique constraints should disregard null values? Use the SQL:2008 syntax (OFFSET ? ROWS FETCH FIRST ? ROWS ONLY) Use the basic limit/offset syntax (LIMIT ? OFFSET ?) Don't use an SQL syntax, use a database cursor through the EntityListIterator.getPartialList() method. Note that there may be different behavior when calling EntityFind.iterator() as it only seeks to the offset but doesn't restrict by the limit. The SQL style for correlated sub-selects for joins in a FROM clause for sub-select=true; setting this to 'none' results in the same SQL that would be generated for sub-select=non-lateral Use the SQL:1999 syntax ([INNER|OUTER LEFT] JOIN LATERAL) Use the apply syntax (CROSS APPLY or OUTER APPLY for join-optional=true) No sort of lateral join supported, use non-correlated sub-selects Set to true to include the schema name for primary keys, foreign keys, and indexes. Never use NULLS FIRST/LAST in ORDER BY clause Never use try insert feature when storing a record For Bitronix set this to false to not use tm join (for Atomikos this is set in the serial_jta_transactions property in jta.properties) Configuration for a javax.jcr.Repository retrieved through a javax.jcr.RepositoryFactory using parameter sub-elements (with name and value attributes), and a javax.jcr.Session using the workspace, username, and password attributes. Defaults to the repository's default workspace. Use this to specify components to load in addition to those in the runtime/component directory. This is useful for components in JCR repositories or wherever. The location needs to use a Resource Facade protocol/schema that supports looking at sub-directories, etc (like content:, file:, etc). Required when under component-list (in Moqui Conf XML file or a components.xml file), optional when component element is in a component.xml file within a component. ================================================ FILE: framework/xsd/rest-api-3.xsd ================================================ If set to true arbitrary path elements following this screen's path are allowed. Default is false and an exception will be thrown if there is an extra path element that does not match a resource below the id element, or it has a child id element. ================================================ FILE: framework/xsd/service-definition-3.xsd ================================================ This can be any verb, and will often be one of: create, update, store, delete, or find. The full name of the service will be: "${path}.${verb}#${noun}". The verb is required and the noun is optional so if there is no noun the service name will be just the verb. For entity-auto services this should be a valid entity name. In many other cases an entity name is the best way to describe what is being acted on, but this can really be anything. The service type specifies how the service is implemented. Additional types can be added by implementing the org.moqui.impl.service.ServiceRunner interface and adding an service-facade.service-type element in the Moqui Conf XML file. The default value is inline meaning the service implementation is under the service.actions element. The location of the service. For scripts this is the Resource Facade location of the file. For Java class methods this is the full class name. For remote services this is the URL of the remote service. Instead of an actual location can also refer to a pre-defined location from the service-facade.service-location element in the Moqui Conf XML file. This is especially useful for remote service URLs. The method within the location, if applicable to the service type. If not set to false (true by default) a user must be logged in to run this service. If the service is running in an ExecutionContext with a user logged in that will qualify. If not then either a "authUserAccount" parameter or the "authUsername" AND "authPassword" parameters must be specified and must contain valid values for a user of the system. If the "authUserAccount" parameter or the "authUsername" AND "authPassword" parameters are passed in they will be used for the service call even if a user is logged in to the ExecutionContext that the service is running in. If set to anonymous-all or anonymous-view then not only will authentication not be required, but this service will run as if authorized (using the _NA_ UserAccount) for all actions or for view-only. The authz action to use when checking authorization for this service (using ArtifactAuthz records). If not specified defaults to all unless the verb corresponds to an authz action. Defaults to false meaning this service cannot be called through remote interfaces such as JSON-RPC and XML-RPC. If set to true it can be. Before settings to true make sure the service is adequately secured (for authentication and authorization). Defaults to true. Set to false to not validate input parameters, and not automatically remove unspecified parameters. If true do not remember parameters in ArtifactExecutionFacade history and stack, important for service calls with large parameters that should be de-referenced for GC before ExecutionContext is destroyed. Note that this attribute can be used on interface service definitions (service.@type=interface) and if true will be cause all services that implement the interface to have this set to true to disable parameter remember. The timeout for the transaction, in seconds. Defaults to global transaction timeout default (usually 60s). This value is only used if this service begins a transaction (force-new, force-cache, or use-or-begin or cache and there is no other transaction already in place). If true and a TransactionCache is active flush and remove it before calling the service. Intended for use in long-running services (usually scheduled). This uses a record in the database to "lock" the service so that only one instance of it can run against a given database at any given time. Defaults to the service name, use the same name on multiple services to share a semaphore When waiting how long before timing out, in seconds. Defaults to 120s. When waiting how long to sleep between checking the semaphore, in seconds. Defaults to 5s. Ignore existing semaphores after this time, in seconds. Defaults to 3600s (1 hour). The name of a parameter to use for distinct semaphores for the same services. The parameter should be required in the service, though a single null semaphore is supported. This should not be used for IDs of transactional records, better to lock directly on those records (find with for update). If set to true or false all parameters inherited have that value for the required attribute. Nested parameters are for List, Map, Node, etc type parameters. To override the default message for each just add the message inside the element. The name of the parameter, matches against the key of an entry in the parameters Map passed into or returned from the service. The type of the attribute, a full Java class name or one of the common Java API classes (including String, Timestamp, Time, Date, Integer, Long, Float, Double, BigDecimal, BigInteger, Boolean, Object, Blob, Clob, Collection, List, Map, Set, Node). Used only when the parameter is passed in as a String but the type is something other than String to convert to that type. For date/time uses standard Java format strings described here: http://download.oracle.com/javase/6/docs/api/java/text/SimpleDateFormat.html The field or expression specified will be used for the parameter if no value is passed in (only used if required=false). Like default-value but is an field name or expression instead of a text value. If both this and default-value are specified this will be evaluated first and only if empty will default-value be used. The text value specified will be used for the parameter if no value is passed in (only used if required=false). If both this and default are specified default will be evaluated first and this will only be used if default evaluates to an empty value. Optional name of an entity with a field that this parameter is associated with. Optional field name within the named entity that this parameter is associated with. Most useful for form fields defined automatically from the service parameter. This is automatically populated when parameters are defined automatically with the auto-parameters element. Behave the same as if the parameter did not exist, useful when overriding a previously defined parameter. Applies only to String fields. Only checked for incoming parameters (meant for validating input from users, other systems, etc). Defaults to "none" meaning no HTML is allowed (will result in an error message). If some HTML is desired then use "safe" which will follow the rules in the antisamy-esapi.xml file. This should be safe for both internal and public users. In rare cases when users are trusted or it is not a sensitive field the "any" option may be used to not check the HTML content at all. Validate the current parameter against the regular expression specified in the regexp attribute. Validate the number within the min and max range. To pass number must be greater than or equal to this value. Should the range include equal to the min number? Defaults to true. To pass number must be less than this value. Should the range include equal to the max number? Defaults to false (exclusive). Validate that the length of the text is within the min and max range. Validate that the text is a valid email address. Validate that the text is a valid URL. Validate that the text contains only letters. Validate that the text contains only digits. Validate that the date/time is within the before and after range, using the specified format. Can be date/time string, or "now" to compare to the current time. Can be date/time string, or "now" to compare to the current time. If the value is a String instead of Date/Time/Timestamp, specify the format for conversion here. Validate that the text is a valid credit card number using Luhn MOD-10 and if specified for the given card types. A comma-separated list of the types of credit card to allow. The available options include: visa,mastercard,amex,discover,dinersclub If empty defaults to allow any type of card (ie doesn't check the card type, just checks the number using the Luhn MOD-10 checksum). NOTE: removed with updated for Validator 1.4.0: enroute, jcb, solo, switch, visaelectron ================================================ FILE: framework/xsd/service-eca-3.xsd ================================================ Triggered by service calls with various options about when during the service call to trigger the event. If condition (optional) evaluates to true then the actions are run. Service ECAs are meant for triggering business processes and for extending the functionality of existing services that you don't want to, or can't, modify. Service ECAs should NOT generally be used for maintenance of data derived from other entities, Entity ECA rules are a much better tool for that. When this runs the context will be whatever context the service was run in, plus the individual parameters for convenience in reading the values. If when is before the service itself is run there will be a context field called parameters with the input parameters Map in it that you can modify as needed in the ECA actions. If when is after the service itself the parameters field will contain the input parameters and a results field will contain the output parameters (results) that also may be modified. Optional but recommended. If another SECA rule has the same id it will override any previously found with that id to change behavior or disable by override with empty actions. The combined service name, like: "${path}.${verb}${noun}", or a pattern to match multiple service names, like: "${path}\.(.*)". To explicitly separate the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}". Runs before input parameters are validated; useful for adding or modifying parameters before validation and data type conversion Runs before authentication and authorization checks, but after the authUsername and authPassword parameters are used and specified user logged in; useful for any custom behavior related to authc or authz Runs before the service itself is run; best place for general things to be done before running the service Runs just after the service is run; best place for general things to be done after the service is run and independent of the transaction Runs just after the commit would be done, whether it is actually done or not (depending on service settings and existing TX in place, etc); to run something on the actual commit use the tx-commit option Runs when the transaction the service is running in is successfully committed. Gets its data after the run of the service so will have the output/results of the service run as well as the input parameters. Runs when the transaction the service is running in is rolled back. Gets its data after the run of the service so will have the output/results of the service run as well as the input parameters. ================================================ FILE: framework/xsd/xml-actions-3.xsd ================================================ XML Actions can be embedded in various files, or put in a file of their own and run like a script. Like a script the parameters passed into the XML Actions will already be defined in the context. Contains a single condition of any sort and evaluates to a boolean value. To combine the other if operations the and, or, and xor elements can be used. Call a service. The combined service name, like: "${path}.${verb}${noun}". To explicitly separate the verb and noun put a hash (#) between them, like: "${path}.${verb}#${noun}". Creates an in parameters with variables matching the names of service in-parameters elements, doing type conversions as needed. If false (default) does nothing. If true constructs an in-map from the context. Otherwise is the name of a Map in the context uses it as the source Map for the service context. Optional name in the method environment to use for the output (results) map. If empty then the output map will be ignored. If true (default) out-map is added to an existing Map with the same name. If false replaces existing Map in the context. If true runs the service asynchronously. Use distribute to run async on any node in a cluster. Include the current user in the service call. If you don't want to pass that in set to false. Defaults to true. Defines the timeout for the transaction, in seconds. This value is only used if this service begins a transaction (either require-new, or use-or-begin and there is no other transaction already in place). Can be false to do nothing, true to send the service result or an expression to run on the service result to get the object to send. Disables checking authorization for this service call. Ignored for async service calls. Runs the script at the specified location. You can also put a Groovy script inline under this element. If a location is specified the file can be a Groovy script, a xml-actions script, or any script setup to run through the Resource Facade. The script will run in the same context as the current operation. Set a field from another field (from) or an inline value, or a default-value. Name of the field to set a value in. Name of a field to copy from. Can also be an expression that evaluates to something to put into the field. Inline value to copy in field. May include variables using the ${} syntax. Default value to set in field if an empty String or null value is found. Type to convert to. NewList will create a new List, NewMap will create a new Map. If an empty String or null value is found, set that in the field. Defaults to true, set to false to do nothing to the field. Sort a List of Maps by field names given in order-by sub-element. Name of the list to be sorted. Filters the given List of Maps by the field-maps specified. The name of the field that contains a List of Map objects. Optional name of the output list. If empty filter the input list in-place. Adds a constraint to find to filter by the from and thru dates in each record, comparing them to the valid-date value. The name of a field in the context to compare each value to. Defaults to now. The name of the entity field to use as the from/beginning effective date. Defaults to fromDate. The name of the entity field to use as the thru/ending effective date. Defaults to thruDate. Leave out the constraint if valid-date is empty or null. Defaults to false. Ignore the econdition (leave out of the find) if set to true or expression evaluates to true. Defaults to false. Load or assert each record in an entity-facade-xml file. Location of an XML file to load in database or verify in assert mode. Start a new transaction and load the data with a longer timeout. Load the file into the datasource. Compare each record in the file to the corresponding record in the datasource and add an error for each difference, or if no record is found in the datasource. Does a find by primary key. If no value is found does nothing to the value-field. Name of the entity to find an instance of. Field to put result resulting EntityValue object in. If true looks for all primary key fields by name in the context. If empty defaults to true (context) unless field-map sub-element is found this will do nothing. If something other than true or false looks for fields in the given Map (an expression run in the current context). Look in the cache before finding in the datasource. The default for this comes from the cache attribute on the entity definition. Lock the selected record so only this transaction can change it until it is ended (committed or rolled back). This does not have to be set to true in order to update the record, it just keeps other transactions from updating it. In SQL this does a select for update. If this is true the cache will not be used, regardless of the cache attribute here and on the entity definition. Use a datasource clone, if one is configured Like entity-and returns a list of entity values if any are found, otherwise returns an empty list. Use any combination of constraint, constraints and constraint-object. Name of entity to find instances of. Name of the list to put results in. Required unless the entity-find is used under the entity-options element in a XML Screen Form. Look in the cache before finding in the datasource. The default for this comes from the cache attribute on the entity definition. Lock the selected record so only this transaction can change it until it is ended (committed or rolled back). This does not have to be set to true in order to update the record, it just keeps other transactions from updating it. In SQL this does a select for update. If this is true the cache will not be used, regardless of the cache attribute here and on the entity definition. Get only distinct results, based on the combination of all fields selected. Defaults to false. Use a datasource clone, if one is configured Get back results starting at this offset. Get back only this many results. Adds econditions for the fields found in the input-fields-map. The fields and special fields with suffixes supported are the same as the *-find fields in the XML Forms. This means that you can use this to process the data from the various inputs generated by XML Forms. The suffixes include things like *_op for operators and *_ic for ignore case. For historical reference, this does basically what the Apache OFBiz prepareFind service does. Attributes of this element are default parameters if there are no constraints in the search parameters. The map to get form fields from. If empty will look at the ec.web.parameters map if the web facade is available, otherwise the current context (ec.context). If no orderByField parameter, order by this. Comma separate list of entity field names to skip when processing input fields Indicate if this find should set pagination options even if there are no pageSize and pageIndex parameters. Also adds a context field called "${entity-find.@list}Count" with a count of the total possible results (ie without the offset/limit). Defaults to true. If true only do find if there is at least one parameter The econditions element contains a list of econditions that are combined with either and or or. The default is and. You can have econditions under econditions, for building fairly complex econdition trees, and you can also drop in econdition-objects at any point. Operator to use to combine econditions in the list. Similar to econditions but runs after the grouping and functions are done. Operator to use to combine econditions in the list. Adds a econdition to the query to compare the field-name field to a context field, a String value, or another field on the entity. The field on the entity to constrain on. If from, value and to-field-name are all empty this is also used as the name of the context field to compare to. Operator to apply to field-name on one side, and from, value, or to-field-name on the other side. For the between operator the from should be a Collection with exactly 2 values in it. For the in operator the from should be a Collection with 1 to many values in it. For the like operator use the standard SQL wildcards, including "%" for any number of characters (like *) and "_" for a single character (like ?), and escape them with a "!" in from of each character to escape). Defaults to equals. Field expression in the context to compare the entity field to. Comparison value, use ${} syntax to expand variables. Compare the field-name field to another field on the entity. Ignore case when doing the compare. Defaults to false. Leave out the constraint if the comparison value is empty or null. Defaults to false. If true make a condition specified value or null as valid matches. Ignore the econdition (leave out of the find) if set to true or expression evaluates to true. Defaults to false. Add a condition that has been defined elsewhere and is available in the current context. Can also be a Map and it will add conditions where the entries are ANDed together and each key/value are compared with equal. Field in the current context that implements the EntityCondition interface or the Map interface. Used to specify fields to select. If there are none of these elements all fields will be selected. Name of a field to select. May be more than one field, comma-separated. Defines a field to order the results by. Name of field to order list by. May be more than one field, comma-separated. Each field name may be prefixed with +/- for ascending/descending, optionally follow by a carat (^) for case insensitive order. Limit the results by a start index and a size. The start/beginning index of results to include. The number of results to include beyond the start. Limit the results using parameters like those used to paginate results in a user interface. Index of records to view, depends on view-size. Number of records to view, like the number of results per-screen. Specifies whether or not to use the EntityListIterator when doing the query. This is much more efficient for large data sets because the results are read incrementally instead of all at once. Note that when using this the use-cache setting will be ignored. Also note that an EntityListIterator must be closed when you are finished, but this is done automatically by the iterate operation. Must be true or false, defaults to false. A name/value pair. If from and value are empty will look in the context for a field matching the field-name. Name of the entity field. Name of the field (variable) in the context. Literal string or use ${} syntax to expand variables. Find the count of the number of records that match the given conditions. Conditions and other application options follow the same structure as in the entity-find operation. Name of entity to search in. Name of the field (variable) to put result of the count in. Look in the cache before finding in the datasource. The default for this comes from the cache attribute on the entity definition. Get only distinct results, based on the combination of all fields selected. Defaults to false. Find a single value related to an existing value. Name of the existing entity value in the context. Name of the relationship to use, consists of the relationship title and the related entity name, like: ${title}${related-entity-name}. Look in the cache before finding in the datasource. The default for this comes from the cache attribute on the entity definition. Lock the selected record so only this transaction can change it until it is ended (committed or rolled back). This does not have to be set to true in order to update the record, it just keeps other transactions from updating it. In SQL this does a select for update. If this is true the cache will not be used, regardless of the cache attribute here and on the entity definition. Name of field to put the entity value result in. Find a list of values related to a specific value. Name of the existing entity value in the context. Name of the relationship to use, consists of the relationship title and the related entity name,like: ${title}${related-entity-name}. A map containing extra constraints for the find. A list of field names to order the results by. Look in the cache before finding in the datasource. The default for this comes from the cache attribute on the entity definition. Lock the selected record so only this transaction can change it until it is ended (committed or rolled back). This does not have to be set to true in order to update the record, it just keeps other transactions from updating it. In SQL this does a select for update. If this is true the cache will not be used, regardless of the cache attribute here and on the entity definition. Name of the list to put the entity list result in. The make-value tag uses the delegator to construct an entity value. The resulting value will not exist in the database, but will simply be assembled using the entity-name and fields map. The resulting EntityValue object will be placed in the method environment using the specified value-field. The name of the entity to construct an instance of. The name of a map in the method environment that will be used for the entity fields.If the map is an EntityValue object then this will clone the value. The name of the field where the EntityValue object will be put. The create-value tag persists the specified EntityValue object by creating a new instance of the entity in the datasource. An error will result if an instance of the entity exists in the datasource with the same primary key. The name of the field that contains the EntityValue object. Update value if already exists instead of returning an error, defaults to false. Updates the specified EntityValue object in the datasource. An error will result if the record is not found in the datasource. The name of the field that contains the EntityValue object. Deletes the specified EntityValue object from the datasource. An error will result if the record is not found in the datasource. The name of the field that contains the EntityValue object. Given a value-field and a relationship-name, follows the relationship and deletes all related records. For a type one relationship it will remove a single record if it exists, and for a type many relationship it will remove all the records that are related to it. Instead of using cascading deletes you should have your code delete all related data with foreign keys pointing the the value-field record, and then delete the value-field. Field that contains an EntityValue object to delete related records from. Name of a relationship to use to delete related records. Deletes entity values that match the econditions. The name of the entity to remove instances of. Looks for each field (pk, nonpk, or all) in the named map and if it exists there it will copy it into the named value object. Field that contains an EntityValue object. The name of a map in the method environment that will be used for the entity fields. Defaults to the context root, which is where incoming parameters go by default. If not null or empty will be pre-pended to each field name (upper-casing the first letter of the field name first), and that will be used as the fields Map lookup name instead of the field-name. Specifies whether or not to set fields that are null or empty. Defaults to false. Get the next guaranteed unique seq id for this entity, and set it in the primary key field. This will set it in the first primary key field in the entity definition, but it really should be used for entities with only one primary key field. The EntityValue object to work on. Given an entity value object with all primary key fields except one already set will generate an ID for the remaining primary key field by looking at all records with the partial primary key and then adding increment-by to the highest value. The EntityValue object to work on. Break from an iterate or while loop (will result in an error elsewhere). Continue in an iterate or while loop (will result in an error elsewhere). The operations contained by the iterate tag will be executed for each of the entries in the list, and will make the current entry available in the method environment by the entry specified. This tag can contain any of the xml-action operations, including the conditional/if operations. Any xml-action operation can be nested under the iterate tag. The name of the field that contains the list to iterate over. The name of the field that will contain each entry as we iterate through the list. If list points to a Map or Collection of Map.Entry the key will be put where this refers to, the value where the entry attribute refers to. Adds the message (sub-element text) to the ExecutionContext MessageFacade, either the errors list if error=true or the messages list otherwise. If true make it a public message, not compatible with error (overrides this) If true will be considered caused by an error, meaning transaction will be rolled back, etc. Checks the Message Facade error message list (ec.message.errors) and if it is not empty returns with an error, otherwise does nothing. Returns immediately. Adds a message to the errors list (ec.message.errors) if error=true, or the messages list (ec.message.messages) otherwise. If true make it a public message, not compatible with error (overrides this) If true will be considered caused by an error, meaning transaction will be rolled back, etc. Each condition under the assert element will be checked and if it fails an error will be added to the given error list. Note that while the definitions for the if operation is used, the tags should be empty because of the differing semantics. This is mainly used for testing, and for writing xml-actions that are meant to be used as part of a test suite. This is mostly useful for testing because the messages are targeted at a programmer, and not really at an end user. A title that can be used in reports for testing. The if operation offers a flexible way of specifying combinations of conditions, alternate conditions, and operations to run on true evaluation of the conditions or to run otherwise. The other if operations are meant for a specific, simple condition when used outside of the condition sub-element of this operation. The attributes of the other if operations are the same when used inside this operation. Note that while the definitions for the if-* operations are used, the tags should be empty because of the differing semantics. A boolean expression in Groovy. Will be AND combined with other conditions if present. While loop operation, uses the same condition element as the if operation. A boolean expression in Groovy. Will be AND combined with other conditions if present. Operations to run if the corresponding condition evaluate to true. The else-if element can be used to specify alternate conditional execution blocks. Each else-if element must contain two sub-elements: condition and then. If the condition of the parent is evaluated to false, each condition of the else-if sub-elements will be evaluated, and the operations under the element corresponding to the first condition that evaluates to true will be run. A boolean expression in Groovy. Will be AND combined with other conditions if present. The else element can be used to contain operations that will run if the condition evaluates to false, and when under an if element when no else-if sub-conditions evaluate to true. It can contain any xml-actions operation. To be true just one of the conditions underneath needs to be true. Will return true as soon as a condition is true, not evaluating remaining conditions. To be true all of the conditions underneath need to be true. Will return false as soon as a condition evaluates to false, not evaluating remaining conditions. If no conditions evaluate to false will return true. Can only have one condition underneath and simply reverse the boolean value of this condition. The operations contained by the if-compare tag will only be executed if the comparison returns true. This tag can contain any of the xml-action operations, including the conditional/if operations. The name of the field in the context (environment) that will be compared. The value that the field will compared to. Will evaluate to a String but can be converted to other types. The name of the context field that the main field will be compared to. If left empty will default to the field attribute's value. Format string based on the type of the object (date, number, etc). A boolean expression should be inline under this element (to avoid problems with character encoding, etc). When not under a condition element any xml-action operation can be nested under this tag, and will only be run if it evaluates to true. Logs a message using Log4J. The logging/debug level to use. The message to log. Can insert variables using the ${} syntax. ================================================ FILE: framework/xsd/xml-form-3.xsd ================================================ The parameter element is used in many places, only some (such as screen.parameter) use this attribute. A single form is used to view or edit fields of a single map/hash/record/etc. The name of the form. Used to reference the form along with the XML Screen file location. For HTML output this is the form name and id, and for other output may also be used to identify the part of the output corresponding to the form. The location and name separated by a hash/pound sign (#) of the form to extend. If there is no location it is treated as a form name in the current screen. The name (HTML id) of a form that will own the fields in this form. When used no form elements are generated, only fields, and each will have the form attribute populated with the value of this attribute. The transition in the current screen to submit the form to. The Map to get field values from. Is often a EntityValue object or a Map with data pulled from various places to populate in the form. Map keys are matched against field names. This is ignored if the field.@from attribute is used, that is evaluated against the context in place at the time each field is rendered. Defaults to "fieldValues". The name of the field to focus on when the form is rendered. Skip the starting rendered elements of the form. When used after a form with skip-end=true this will effectively combine the forms into one. Skip the ending rendered elements of the form. Use this to leave a form open so that additional forms can be combined with it. If true this form will be considered dynamic and the internal definition will be built up each time it is used instead of only when first referred to. This is necessary when auto-fields-* elements have ${} string expansion for service or entity names. Submit the form in the background without reloading the screen. After the form is submitted in the background reload the dynamic-container with this id. After the form is submitted in the background hide the element (usually a dialog) with the specified id. After the form is submitted in the background show this message in a dialog. See details for the screen.@server-static attribute. Defaults to the screen's value. Comma separated list of field names (no spaces) to make sure are passed as body parameters, for vuet mode only and forms that have a non-transition target where form-link is used. Add hidden input elements for all parameters (for companion form-list find options, etc) If true exclude empty fields when the form is submitted instead of including (as zero length string); NOTE: currently only supported in qvt and vuet render modes A list form is a list of individual forms in a table (could be called a tabular form), it has a list of sets of values and creates one form for each list element. A variation on the list form is the multi form (set the attribute multi=true). In the multi mode all list elements will be put into a single large form with suffixes on each field for each row, with a single submit button at the bottom instead of a submit button on each row. Configuration for selected row action forms. NOTE: currently only supported in 'qvt' render mode Put the action widget (form-single) in a dialog, supports standalone widgets displayed in dialog above the action widget Form field name for the ID field to include in row-selection action requests following the multi-submit pattern (_{rowNumber} suffix) If specified use instead of the id-field value for the parameter name If true pass values as a comma-separated list in a single parameter instead of a list of parameters with underscore suffixes, also does not set _isMulti=true parameter Parameters specific here will be passed through hidden inputs for all forms generated by form-list including header, first/second/last row, and multi or per-row forms. Meant to be used for context pass through parameters. For multi=true these will be global, not per row (named without row number extension, expressions evaluated without row data). A Map to get parameter names and values from in addition to the parameter sub-elements. Fields in this set will be in the same column in the list form table. Configuration for default columns and fields in each Fields in this set will be in the same column in the list form table (alternative to old form-list-column element). Used to distinguish multiple columns configurations, can be anything used in the '_uiType' parameter (or context field). If type not specified used as a default for any _uiType value not specified in another columns elements. Initially 'desktop' and 'mobile' are supported. Corresponds to FormConfig.configTypeEnumId for per-user and other saved form configurations. The name of the form. Used to reference the form along with the XML Screen file location. For HTML output this is the form name and id, and for other output may also be used to identify the part of the output corresponding to the form. The location and name separated by a hash/pound sign (#) of the form to extend. If there is no location it is treated as a form name in the current screen. The transition in the current screen to submit the main form(s) to. The transition in the current screen to submit the first row form to. The transition in the current screen to submit the second row form to. The transition in the current screen to submit the last row form to. The Map to use for field values in the first-row fields, like form-single.@map. The Map to use for field values in the second-row fields, like form-single.@map. The Map to use for field values in the last-row fields, like form-single.@map. Make the form a multi-submit form where all rows on a page are submitted together in a single request with a "_${rowNumber}" suffix on each field. Also passes a _isMulti=true parameter so the Service Facade knows to run the service (for a single service-call in a transition) for each row. Defaults to false, so set to true to enable this behavior and have a separate form (submitted separately) for each row. A Groovy expression that evaluates to a list to iterate over. If specified each list entry will be put in the context with this name, otherwise the list entry must be a Map and the entries in the Map will be put into the context for each row. Indicate if this form should paginate or not. Defaults to true. Always show the pagination control with count of rows, even when there is only one page? Defaults to true. The name of the field to focus on in the first row when the form is rendered. Make the output a plain table, not submittable (in HTML don't generate 'form' elements). Useful for view-only list forms to minimize output. Skip the table header element. Put header-field widgets in a dialog instead of the table header. Includes all fields with header widgets, not just those displayed. Enable per-user selection of which columns to display. Enable saved finds (query parameters, order by). Show a button to export as CSV (renderMode=csv), if the pagination header is displayed Show a button to export as XLS (renderMode=xlsx), if the pagination header is displayed Show a button to export as plain text (renderMode=text), if the pagination header is displayed. Because fixed-width text output is limited by nature and the default of evenly sizing all columns is not very useful in most cases, this should only be set to true when the field.@print-width and @align attributes are used for the form-list. Show a button to render the screen as XSL-FO (renderMode=xsl-fo) and convert to PDF, if the pagination header is displayed. Because fixed-width text output is limited by nature and the default of evenly sizing all columns is not very useful in most cases, this should only be set to true when the field.@print-width and @align attributes are used for the form-list. Show a button to display all results (pageNoLimit=true), if the pagination header is displayed Show a drop-down to select different page sizes, if the pagination header is displayed If true then this form will be considered dynamic and the internal definition will be built up each time it is used instead of only when first referred to. See details for the screen.@server-static attribute. Defaults to the screen's value. Use this to specify the accordion section index to be open, or false for all to be closed. Fields in the field-row will be split into two columns. If you want more than two fields in a row, use field-row-big. All fields go in a single column, with an optional title column first. Fields will be "floated" left so that they stack up on a single line as long as them will fit, and then will overflow to the next line, etc. Fields not explicitly referenced will be inserted where this element is. If this element is left out the non-referenced fields will not be displayed. If selected the hidden-form type will be used if the url-mode is transition and the transition has actions, otherwise the anchor-button type will be used. The type for the url attribute. Defaults to transition (on this screen). If true don't add parameters to the URL (mainly for anchors). An expression that evaluates to a Map in the context that will be used in addition to the context when expanding the @text value. If true text will be encoded so that it does not interfere with markup of the target output. For example, if output is HTML then data presented will be HTML encoded so that all HTML-specific characters are escaped. Icon name, actually an icon style used in an 'i' element in HTML output. Text to put in a badge on the right side If there is a message here it will show in a confirmation box when the link is clicked on. A Map to get parameter names and values from in addition to the parameter sub-elements. Add URL parameters or hidden input elements for all parameters (for companion form-list find options, etc) If this target transition has no condition, no actions, no conditional responses and the default-response type is "url" and url-type is "screen-path" then URLs to this transition may be expanded. Set this to true to expand them to what the default-response points to instead of a URL to this transition. If specified URL will be loaded into the dynamic-container with this ID. If specified and evaluates to false link is not rendered. A Map to get parameter names and values from in addition to the parameter sub-elements. If specified and evaluates to false image is not rendered. An expression that evaluates to a Map in the context that will be used in addition to the context when expanding the @text value. If true text will be encoded so that it does not interfere with markup of the target output. For example, if output is HTML then data presented will be HTML encoded so that all HTML-specific characters are escaped. Generate text container even if text value is empty (or just whitespace)? Defaults to false. If specified and evaluates to false label is not rendered. If true text will be encoded so that it does not interfere with markup of the target output. For example, if output is HTML then data presented will be HTML encoded so that all HTML-specific characters are escaped. Generate text container even if text value is empty (or just whitespace)? Defaults to false. The name of the parameter to pass the edited value in. A Map to get parameter names and values from in addition to the parameter sub-elements. A Map to get parameter names and values from in addition to the parameter sub-elements. A unique name for this field. Used for the parameter name, referencing the field in other places, etc. A Groovy expression to get the value of the field from the context; may be a simple context field name, arithmetic expression, include function calls, etc. If false field will always be visible (at least the title if nothing else). If true will always be hidden regardless of title and widgets defined. If empty (default) will guess based on definition of field. Note that for form-list fields this governs the entire column for the field, and not just a single row. Column width when printing (formatted text, xsl-fo render modes); when there are multiple fields in a column the highest width is used; not on form-list-column because that is dynamic with select-columns. By default display, display-entity, are printed and link is printed if it is an anchor (not a button). Set to zero (0) to not print this field. The type of print width, default to percent and can be characters. For form-list only. Use to combine rows during render with summary fields (where this is a function), or in a sub-list under a row with values common among the combined rows. To use this at least one field must have at least one field with aggregate=group-by for efficiently finding rows to combine. For form-list only. If anything but false for any field a totals row is added to the output based on the rows currently displayed. The 'true' option is the same as 'sum'. Only applicable to fields until a form-list element. Used to show a field to filter the results by (instead of a separate search form), and/or to show the order-by option in the header. The name of this field that will be shown to the user; can use the ${} and map.key (dot) syntax to insert values from the context. Only applicable to multi and list type forms. If true header links for ordering by this field will be displayed. A style (HTML class) for the container around the field (table cell, etc). The name of this field that will be shown to the user; can use the ${} and map.key (dot) syntax to insert values from the context. The text to show on mouse over or help for more information; can use the ${} and map.key (dot) syntax to insert values from the context. The widget/interaction part will be red if the date value is before-now (for thruDate), after-now (for fromDate), or by-name (if the field's @name, @from, fromDate, or thruDate the corresponding action will be done); only applicable when the field is a timestamp. A style (HTML class) for the container around the field (table cell, etc). A boolean expression in Groovy. Used to dynamically show or hide a field based on the value of another field (usually a drop-down). If this element is present the field is hidden unless the value of the named field matches the value/from String or is in the value/from Collection. NOTE: currently implemented for qvt only (qapps and other Quasar-Vue Templates) The name of the field (usually a drop-down) used to determine if this field should be visible or not The value the field should match in order for this to be visible, may be a list of comma-separated values Groovy expression that evaluates to a String or Collection Used to format the output of Time/Date/Timestamp objects. With auto-fields-service will inherit from service parameter. In explicit from/thru date mode allow time entry? Used to format the output of Time/Date/Timestamp objects. With auto-fields-service will inherit from service parameter. For dynamic values only (see dynamic-transition). When getting data from the server the value of this field will be passed in the request. When this field changes the value in the dynamic display will be updated. If set to true, a hidden form field is also rendered, with the name of the field and its value. Specifies the string to display, can use the ${} syntax to insert context values; if empty the value of the field will be printed for a default. An expression that evaluates to a Map in the context that will be used in addition to the context when expanding the @text value. Specifies the currency uomId (ISO code) used to format the value. Will only format as currency if this is specified. When currency-unit-field has value, defines whether currency symbol will be hidden or displayed normally. Used to format the output of Number/Time/Date/Timestamp/etc objects. With auto-fields-service will inherit from service parameter. Used to format the output of Number/Time/Date/Timestamp/etc objects for text output. If true the text will be encoded so that it does not interfere with markup of the target output. If specified the text-line will get a default dynamically using depends-on fields and parameters in the parameter-map attribute. Used only with dynamic-transition. A Map to get parameter names and values from for dynamic value requests. This is named to follow the pattern for parameters used elsewhere. Used only with dynamic-transition. Set to true to get dynamic value even if a depends-on field is empty. This is just like display but looks up a description using the Entity Facade; note that if also-hidden is true then it uses the key as the value, not the shown description. Displayed if no record found. If true text will be encoded so that it does not interfere with markup of the target output. The key to mark as selected when there is no current entry value. If true and allow-empty is not true (required, no empty option) don't auto-select the first option, require user to manually select unless there is only 1 option (qvt only as of 2020-12-10) If true submit the form when an option is selected (qvt only as of 2022-02-23) Optional text for submit button. If not specified the field title is used. If there is a message here it will show in a confirmation box when the button is clicked on. For auto-complete only (at least ac-transition set). When getting data from the server the value of this field will be passed in the request. When this field changes the value in the autocomplete text-line will be cleared. Used to parse and format the output of Number/Time/Date/Timestamp/etc objects. With auto-fields-service will inherit from service parameter. A mask for guiding and filtering input, uses RobinHerbots/Inputmask. For a simple mask use '9' for digits, 'a' for letters, and '*' for digits or letters. This may also be an alias for a predefined mask. Can be any valid HTML input type, but use with caution as some types don't make sense for use with text-line and all except 'text' result in native browser behavior that varies by browser and is sometimes unexpected and problematic. If not specified defaults to 'email' or 'url' when applicable based on validations configured (in target service in parameters) or 'text' otherwise. Text displayed to the left of the input box, expanded and localized If specified the text-line will have auto-complete added to it with this transition as the source of the auto-complete options. If true the value of the currently selected option will show next to the autocomplete input box. If true the value enterred in the input box will be used even if no autocomplete option is selected. If specified the text-line will get a default dynamically using depends-on fields and parameters in the parameter-map attribute. A Map to get parameter names and values from for autocomplete and dynamic default requests. This is named to follow the pattern for parameters used elsewhere. Use a WYSIWYG editor, can be an expanded string expression (with ${}), such as 'html' or 'md', support depends on what the active text-area macro does. If applicable for the editor-type a screenThemeId for the CSS (resourceTypeEnumId=STRT_STYLESHEET) files to use for the editor context, defaults to active screen theme. Auto-grow the text area to fit the contents; currently only supported in qvt mode (Vue + Quasar) Look up options for the field using the named entity. The text representing the key. Use the ${} syntax to insert entries from the entity value or from the context. If empty will use the first primary key field name to lookup a value in the context. Actual text shown to the user. Use the ${} syntax to insert variables. If empty defaults to the value of the key. Create options based on data in a List of Maps. The name of the list to iterate through to get values. The text representing the key. Use the ${} syntax to insert entries from a Map in the list or from the context. If empty and the List contains EntityValue instances then will use the first primary key field name to lookup a value in the context, otherwise will use the name of the field to lookup a value in the context. Actual text shown to the user. Use the ${} syntax to insert entries from a Map in the list or from the context. If empty defaults to the value of the key. What the user will see in the widget; defaults to the value of the key attribute. Look up options for the field using a JSON over HTTP call to a server. When getting data from the server the value of this field will be passed in the request. When the field named here changes these options will be updated. The transition in this screen to get the option list from. The field in the result that represents the value (key) for the option. The field in the result that represents the label for the option. Set to true to get options even if a depends-on field is empty. For drop-down if true pass search term to server to filter results. Transition that processes results should limit options. Sort of like an autocomplete and supports the same transitions as text-line with @ac-transition unless pagination (infinite scroll) is needed then transition must return object with options, pageSize, and count properties. Key press debounce delay for server-search. Min term length to search for server-search, can be zero to populate immediately. A Map to get parameter names and values from for search and dynamic default requests. This is named to follow the pattern for parameters used elsewhere. If specified used as the parameter to pass instead of the field name. ================================================ FILE: framework/xsd/xml-screen-3.xsd ================================================ If set to true this screen will be rendered without rendering any parent screens. It can still be referred to as a subscreen of its parent, but when rendered the parent will not run, the rendering will start at this screen. Any non-standalone children will still be treated as normal subscreens. Set this to false to not automatically appear in the parent's subscreens menu based on the directory it is in. If true this screen will automatically be included in the parent's subscreens menu. Icon name, actually an icon style used in an 'i' element in HTML output. Begin a transaction for the screen render if there is not one already in place. Most screens don't need this, but it is useful for greater data consistency in certain cases. The timeout for the screen render transaction, in seconds. Defaults to global transaction timeout default (usually 60s). This value is only used if a screen in the rendered screen path begins a transaction. If multiple screens in the render path have a timeout the highest will be used. This is not intended for interactive screen rendering as the HTTP request or gateway will generally timeout first anyway but is useful for background rendered screens for large reports and such. False by default, meaning that child content is sent to the client as they are and nothing else with it. If true then the child content is included in this screen as if it were a subscreen. If set to false no ArtifactHit or ArtifactHitBin data will be kept for this screen and for any content or transitions under the screen. If set to false this screen will not be saved in the screen/URL history. If specified will be used as the login screen path for this screen and any subscreens, otherwise defaults to "/Login". If set to true arbitrary path elements following this screen's path are allowed. Default is false and an exception will be thrown if there is an extra path element that does not match a subscreen, transition, or content (file) below the screen. The render mode(s) this screen supports. Can be any valid render mode. The default value of "all" will allow all render modes, so specify one or more specific render modes to limit how the screen may be rendered. Default supported modes include: text, html, js, vuet, xsl-fo, xml, and csv. Values can be comma separated to apply to multiple render modes. The render modes where the results of this screen are always the same and the screen render results may be cached (on server and/or client). This applies to all screens that are designed for client rendering when the screen is written to fully support it (filling in data with additional requests). Placeholder screens are automatically server static, ie with just subscreens-panel, subscreens-active, and subscreens-menu elements. Can be any valid render mode. Default supported modes include: text, html, js, vuet, xsl-fo, xml, and csv. Values can be comma separated to apply to multiple render modes. A value of "all" will apply to all render modes. The screen is the basic unit of a user interface defines how data, logic, and visual elements fit together. Screen filenames should be camel-cased and start with an upper-case letter (whereas transitions should start with a lower-case letter). These are the parameters the screen expects or allows to be passed in, and where the calling screen can get them from by default (usually just default to the same from, but can be a static value for default or whatever). Individual transition, transition.*-response and other elements can override where the parameter comes from with their own parameter sub-elements. The root element of a file under the 'screen-extend' directory in a component using a path and filename that matches the path and filename of the screen to extend. The path can match by the path under the 'screen' directory or by the full path following the protocol/scheme of the location URI (ie everything after ://). This is a simple, generic way to insert widget elements into various places in existing screens. Add widgets before or after all elements where name here matches the name or id attributes match. Match against name or id attributes on elements in the base screen (being extended). A location here will override the settings in the moqui-conf.screen-facade.screen-text-output, but will be overridden by a value set with the ScreenRender.macroTemplate() method. Can be anything. Default supported values include: text, html, xsl-fo, xml, and csv. These actions always run when this screen appears in a screen path, including both screen rendering and transition running. One difference between this and the pre-actions element is that this runs before transitions are processed while pre-actions do not. The always-actions also run for all screens in a path while the pre-actions only run for screens that will be rendered. These actions run before any of the screens (this screen or any parent screens) are rendered, allowing you to set parameters used by parent screens or other general reasons. These are the parameters the transition expects or allows to be passed in, and where the calling screen can get them from by default (usually just default to the same from, but can be a static value for default or whatever). These are in addition to the screen.parameter values. Individual transition.*-response and other elements can override where the parameter comes from with their own parameter sub-elements. These are additional path elements after the transition's path element. The values will be added to the web parameters based on the order of these path-elements. This condition is run wherever this transition is referenced in the screen to see if the transition is available (otherwise the button/link/etc is disabled). In most cases the best way to handle input for a transition is with a single service. To do that use this element instead of an actions element. If an actions element is also specified the actions will be run after the service-call. This will automatically have an in-map=true. To get the same effect inside the actions element just use in-map=true. When this transition is followed these actions are run. If a service-call element is also used that will be run before these actions. After the actions are run it goes to the url that this transition goes to (through client-side redirect, dynamic update of a screen area, etc). If there are multiple transition-response sub-elements the first one whose condition evaluates to true will be the one used. If no conditional responses match, the default-response will be used. This response must always be defined and is the response that will be used if there is no error in the actions, and if none of the conditions in conditional responses evaluate to true. If there is an error in evaluating the actions on this transition then the error-response will be used and the transition-response element(s) will be ignored. If there are actions and there is no error-response defined then the default error response will be used. Transition names should be camel-cased and start with a lower-case letter (whereas screen filenames and subscreens-item names start with a upper-case letter). The transition name is used in link and other elements in place of URLs when going to another screen within this application. The transition name will appear briefly as the URL before the redirect is done for the transition response. Begin a transaction for the screen transition action run if there is not one already in place. Declare that this transition does only read operations to skip the check for insecure parameters. If not false (default true) moquiSessionToken (from ec.web.sessionToken) must be passed to this transition for all requests in a session after the first. These parameters will be used when redirecting to the url or other activating of the target screen. Each screen has a list of expected parameters so this is only necessary when you need to override where the parameter value comes from (default defined in the parameter tag under the screen) or to pass additional parameters. These parameters will be used when redirecting to the url or other activating of the target screen. Each screen has a list of expected parameters so this is only necessary when you need to override where the parameter value comes from (default defined in the parameter tag under the screen) or to pass additional parameters. These parameters will be used when redirecting to the url or other activating of the target screen. Each screen has a list of expected parameters so this is only necessary when you need to override where the parameter value comes from (default defined in the parameter tag under the screen) or to pass additional parameters. Go to the screen from the last request (via screen history) unless there is a saved one from some previous request (using the save-current-screen attribute, done automatically for login). If neither available will go to the default screen (just to root with whatever defaults are setup for each subscreen). The URL to follow in response, based on the url-type. The default url-type is "screen-path" which means the value here is a path from the current screen to the desired screen, transition, or sub-screen content. Use "." to represent the current screen, and ".." to represent the parent screen on the runtime screen path. The ".." can be used multiple times, such as "../.." to get to the parent screen of the parent screen (the grand-parent screen). If the screen-path type url starts with a "/" it will be relative to the root screen instead of relative to the current screen. If it starts with a "//" it will be relative to the root screen and a sparse path, meaning that each path item specified will be searched for under the previous and does not have to be a direct subscreen. If the url-type is "plain" then this can be any valid URL (relative on current domain or absolute). URI to another screen, either relative or from server root. See documentation on url attribute for more details. Just like the parameter subelement can be used to specify parameters to pass with the redirect. Save the current screen's path and parameters for future use, generally with the screen-last type of response. Save the current parameters (and request attributes) before doing a redirect so that the screen rendered after the redirect renders in a context similar to the original request to the transition. Declare subscreens for this screen. One subscreen at a time is active, based on the "screen path" used to access this screen. The parent screen (this screen) will be the current element in the screen path and the next screen path element will be the name of the subscreen of this screen to use. If there is no additional element in the screen path or the next element is not a valid subscreen-item.name then the default-item will be the active subscreen. There are four ways to add subscreens to a screen (in order of override): 1. for screens within a single application by directory structure: create a directory in the directory where the parent screen is named the same as the parent screen's filename and put XML Screen files in that directory (name=filename up to .xml, title=screen.default-title, location=parent screen minus filename plus directory and filename for subscreen) 2. for including screens that are part of another application, or shared and not in any application: subscreens-item elements below the screen.subscreens element (this element) 3. for adding screens or changing order and title of screens to an existing application: screen-facade.screen and subscreens-item elements in the Moqui Conf XML file including MoquiConf.xml in a component; this subscreens-item element is much like the subscreens.subscreens-item element 4. for adding screens, removing screens, or changing order and title of screens to an existing application: a record in the moqui.screen.SubscreensItem entity There are two visual elements (widgets) that come from the subscreens, a menu and the active subscreen. Those are included with the widgets using the "subscreens-menu" and "subscreens-active" elements, or the "subscreens-panel" element. The name of the default subscreen-item. Used when then screen-path ends on this screen so we know which subscreen-item to activate. If empty the first subscreen-item will be the default. Groovy condition expression (evaluates to a boolean) used to determine if the specified subscreens item is the one to use by default instead of the on specified in the subscreens.@default-item attribute. The subscreens item to make the default. One way to add a subscreen. This is most commonly used to refer to a subscreen that is located in another application, another part of this application, that is not in any application and is meant to be shared, or is in a different type of location than the parent screen. One subscreens-item is active at a time, meaning that screen is shown and the tab/etc for that screen is highlighted. The name of the subscreens item for use in the screen path. The screen path element following the one for the parent screen of the item will match on this name. Subscreen Item names should be camel-cased and start with a upper-case letter (just like screen filenames start with a upper-case letter). Subscreen location can include various prefixes to support including from a file, http, component, or a content repository. If empty defaults to the value of the name attribute under the current screen (in the directory with the same name as the current screen), and can be a screen or sub-content. If specified this item will be inserted in existing list of subscreens at this index (1-based). If empty this item will be added to the end of the list (after the directory load, before the entity load). If true the sub-screens of the sub-screen may be referenced directly under this screen, skipping the screen path element for the sub-screen. A name for the section, used for reference within the screen. Must be specified and must be unique within the screen. A condition expression, just like the section.condition.expression element but more concise. A name for the section, used for external reference within the screen. The name of the field that contains the list to iterate over. The name of the field that will contain each entry as we iterate through the list. If list points to a Map or List of MapEntry the key will be put where this refers to, the value where the entry attribute refers to. A condition expression, just like the section-iterate.condition.expression element but more concise. Indicate if this section is paginated or not, false by default. A name for the section, used for reference within the screen. Must be specified and must be unique within the screen. Location of the screen containing the section to include. A responsive 12-column grid row. For the concept and one possible implementation see http://getbootstrap.com/css/#grid This panel can have up to five areas: header, left, center, right, footer. Only the center area is required. This can be re-used within the different areas as well, usually just the center area but could be used to split up even the header and footer. If there is an id for the outer container, and each area will have an automatic id as well (with a suffix of: _header, _left, _center, _right, _footer). The contents start out hidden with only a button with the button-text on it. When the button is clicked on a dialog opens to show the contents. When the button is pressed the dialog contents are loaded from the server at the given transition. A Map to get parameter names and values from in addition to the parameter sub-elements. Container contents are immediately loaded from the server at the given transition. Contents may be reloaded based on other actions such as background form submissions. A Map to get parameter names and values from in addition to the parameter sub-elements. An expression that evaluates to a Map in the context that will be used in addition to the context when expanding the @text value. If true text will be encoded so that it does not interfere with markup of the target output. For example, if output is HTML then data presented will be HTML encoded so that all HTML-specific characters are escaped. Icon name, actually an icon style used in an 'i' element in HTML output. Text to put in a badge on the right side If specified and evaluates to false link is not rendered. The parameters are for remote request of sub-nodes for a node when lazy loading and are in the context of the initial rendering of the tree in the screen. If not specified uses actions/${tree.@name} Can be any valid render mode. Default supported modes include: text, html, vuet, xsl-fo, xml, and csv. Values can be comma separated to apply to multiple render modes. A value of "any" will cause it to be used if no other element matches the current output type. This is the template or text file location and can be any location supported by the Resource Facade including file, http, component, content, etc. Interpret the text at the location as an FTL or other template? Supports any template type supported by the Resource Facade. Defaults to true, set to false if you want the text included literally. If true the text will be encoded so that it does not interfere with markup of the target output. Templates ignore this setting and are never encoded. For example, if output is HTML then data presented will be HTML encoded so that all HTML-specific characters are escaped. Defaults to false. If true won't ever put boundary comments before this (for opening ?xml tag, etc). ================================================ FILE: gradle/wrapper/gradle-wrapper.properties ================================================ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists distributionUrl=https\://services.gradle.org/distributions/gradle-9.2.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME zipStorePath=wrapper/dists ================================================ FILE: gradle.properties ================================================ # for options see https://docs.gradle.org/5.6.4/userguide/build_environment.html#sec:gradle_configuration_properties org.gradle.warning.mode=none org.gradle.configuration-cache=false ================================================ FILE: gradlew ================================================ #!/bin/sh # # Copyright © 2015 the original authors. # # Licensed under the Apache License, Version 2.0 (the "License"); # you may not use this file except in compliance with the License. # You may obtain a copy of the License at # # https://www.apache.org/licenses/LICENSE-2.0 # # Unless required by applicable law or agreed to in writing, software # distributed under the License is distributed on an "AS IS" BASIS, # WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. # See the License for the specific language governing permissions and # limitations under the License. # # SPDX-License-Identifier: Apache-2.0 # ############################################################################## # # Gradle start up script for POSIX generated by Gradle. # # Important for running: # # (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is # noncompliant, but you have some other compliant shell such as ksh or # bash, then to run this script, type that shell name before the whole # command line, like: # # ksh Gradle # # Busybox and similar reduced shells will NOT work, because this script # requires all of these POSIX shell features: # * functions; # * expansions «$var», «${var}», «${var:-default}», «${var+SET}», # «${var#prefix}», «${var%suffix}», and «$( cmd )»; # * compound commands having a testable exit status, especially «case»; # * various built-in commands including «command», «set», and «ulimit». # # Important for patching: # # (2) This script targets any POSIX shell, so it avoids extensions provided # by Bash, Ksh, etc; in particular arrays are avoided. # # The "traditional" practice of packing multiple parameters into a # space-separated string is a well documented source of bugs and security # problems, so this is (mostly) avoided, by progressively accumulating # options in "$@", and eventually passing that to Java. # # Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS, # and GRADLE_OPTS) rely on word-splitting, this is performed explicitly; # see the in-line comments for details. # # There are tweaks for specific operating systems such as AIX, CygWin, # Darwin, MinGW, and NonStop. # # (3) This script is generated from the Groovy template # https://github.com/gradle/gradle/blob/HEAD/platforms/jvm/plugins-application/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt # within the Gradle project. # # You can find Gradle at https://github.com/gradle/gradle/. # ############################################################################## # Attempt to set APP_HOME # Resolve links: $0 may be a link app_path=$0 # Need this for daisy-chained symlinks. while APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path [ -h "$app_path" ] do ls=$( ls -ld "$app_path" ) link=${ls#*' -> '} case $link in #( /*) app_path=$link ;; #( *) app_path=$APP_HOME$link ;; esac done # This is normally unused # shellcheck disable=SC2034 APP_BASE_NAME=${0##*/} # Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036) APP_HOME=$( cd -P "${APP_HOME:-./}" > /dev/null && printf '%s\n' "$PWD" ) || exit # Use the maximum available, or set MAX_FD != -1 to use that value. MAX_FD=maximum warn () { echo "$*" } >&2 die () { echo echo "$*" echo exit 1 } >&2 # OS specific support (must be 'true' or 'false'). cygwin=false msys=false darwin=false nonstop=false case "$( uname )" in #( CYGWIN* ) cygwin=true ;; #( Darwin* ) darwin=true ;; #( MSYS* | MINGW* ) msys=true ;; #( NONSTOP* ) nonstop=true ;; esac # Determine the Java command to use to start the JVM. if [ -n "$JAVA_HOME" ] ; then if [ -x "$JAVA_HOME/jre/sh/java" ] ; then # IBM's JDK on AIX uses strange locations for the executables JAVACMD=$JAVA_HOME/jre/sh/java else JAVACMD=$JAVA_HOME/bin/java fi if [ ! -x "$JAVACMD" ] ; then die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi else JAVACMD=java if ! command -v java >/dev/null 2>&1 then die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. Please set the JAVA_HOME variable in your environment to match the location of your Java installation." fi fi # Increase the maximum file descriptors if we can. if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then case $MAX_FD in #( max*) # In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 MAX_FD=$( ulimit -H -n ) || warn "Could not query maximum file descriptor limit" esac case $MAX_FD in #( '' | soft) :;; #( *) # In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked. # shellcheck disable=SC2039,SC3045 ulimit -n "$MAX_FD" || warn "Could not set maximum file descriptor limit to $MAX_FD" esac fi # Collect all arguments for the java command, stacking in reverse order: # * args from the command line # * the main class name # * -classpath # * -D...appname settings # * --module-path (only if needed) # * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables. # For Cygwin or MSYS, switch paths to Windows format before running java if "$cygwin" || "$msys" ; then APP_HOME=$( cygpath --path --mixed "$APP_HOME" ) JAVACMD=$( cygpath --unix "$JAVACMD" ) # Now convert the arguments - kludge to limit ourselves to /bin/sh for arg do if case $arg in #( -*) false ;; # don't mess with options #( /?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath [ -e "$t" ] ;; #( *) false ;; esac then arg=$( cygpath --path --ignore --mixed "$arg" ) fi # Roll the args list around exactly as many times as the number of # args, so each arg winds up back in the position where it started, but # possibly modified. # # NB: a `for` loop captures its iteration list before it begins, so # changing the positional parameters here affects neither the number of # iterations, nor the values presented in `arg`. shift # remove old arg set -- "$@" "$arg" # push replacement arg done fi # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' # Collect all arguments for the java command: # * DEFAULT_JVM_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments, # and any embedded shellness will be escaped. # * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be # treated as '${Hostname}' itself on the command line. set -- \ "-Dorg.gradle.appname=$APP_BASE_NAME" \ -jar "$APP_HOME/gradle/wrapper/gradle-wrapper.jar" \ "$@" # Stop when "xargs" is not available. if ! command -v xargs >/dev/null 2>&1 then die "xargs is not available" fi # Use "xargs" to parse quoted args. # # With -n1 it outputs one arg per line, with the quotes and backslashes removed. # # In Bash we could simply go: # # readarray ARGS < <( xargs -n1 <<<"$var" ) && # set -- "${ARGS[@]}" "$@" # # but POSIX shell has neither arrays nor command substitution, so instead we # post-process each arg (as a line of input to sed) to backslash-escape any # character that might be a shell metacharacter, then use eval to reverse # that process (while maintaining the separation between arguments), and wrap # the whole thing up as a single "set" statement. # # This will of course break if any of these variables contains a newline or # an unmatched quote. # eval "set -- $( printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" | xargs -n1 | sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' | tr '\n' ' ' )" '"$@"' exec "$JAVACMD" "$@" ================================================ FILE: gradlew.bat ================================================ @rem @rem Copyright 2015 the original author or authors. @rem @rem Licensed under the Apache License, Version 2.0 (the "License"); @rem you may not use this file except in compliance with the License. @rem You may obtain a copy of the License at @rem @rem https://www.apache.org/licenses/LICENSE-2.0 @rem @rem Unless required by applicable law or agreed to in writing, software @rem distributed under the License is distributed on an "AS IS" BASIS, @rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. @rem See the License for the specific language governing permissions and @rem limitations under the License. @rem @rem SPDX-License-Identifier: Apache-2.0 @rem @if "%DEBUG%"=="" @echo off @rem ########################################################################## @rem @rem Gradle startup script for Windows @rem @rem ########################################################################## @rem Set local scope for the variables with windows NT shell if "%OS%"=="Windows_NT" setlocal set DIRNAME=%~dp0 if "%DIRNAME%"=="" set DIRNAME=. @rem This is normally unused set APP_BASE_NAME=%~n0 set APP_HOME=%DIRNAME% @rem Resolve any "." and ".." in APP_HOME to make it shorter. for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" @rem Find java.exe if defined JAVA_HOME goto findJavaFromJavaHome set JAVA_EXE=java.exe %JAVA_EXE% -version >NUL 2>&1 if %ERRORLEVEL% equ 0 goto execute echo. 1>&2 echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :findJavaFromJavaHome set JAVA_HOME=%JAVA_HOME:"=% set JAVA_EXE=%JAVA_HOME%/bin/java.exe if exist "%JAVA_EXE%" goto execute echo. 1>&2 echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2 echo. 1>&2 echo Please set the JAVA_HOME variable in your environment to match the 1>&2 echo location of your Java installation. 1>&2 goto fail :execute @rem Setup the command line @rem Execute Gradle "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -jar "%APP_HOME%\gradle\wrapper\gradle-wrapper.jar" %* :end @rem End local scope for the variables with windows NT shell if %ERRORLEVEL% equ 0 goto mainEnd :fail rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of rem the _cmd.exe /c_ return code! set EXIT_CODE=%ERRORLEVEL% if %EXIT_CODE% equ 0 set EXIT_CODE=1 if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE% exit /b %EXIT_CODE% :mainEnd if "%OS%"=="Windows_NT" endlocal :omega ================================================ FILE: settings.gradle ================================================ String[] getDirectoryProjects(String relativePath) { File runtimeDir = file('runtime') if (!runtimeDir.exists()) return new String[0] File compsDir = file('runtime/' + relativePath) if (!compsDir.exists()) return new String[0] return compsDir.listFiles().findAll { it.isDirectory() && it.listFiles().find { it.name == 'build.gradle' } } .collect { "runtime:${relativePath}:${it.getName()}" } as String[] } include 'framework' include getDirectoryProjects('base-component') include getDirectoryProjects('mantle') include getDirectoryProjects('component') include getDirectoryProjects('ecomponent')