diff --git a/.github/patches/coldbox-full-null.patch b/.github/patches/coldbox-full-null.patch new file mode 100644 index 00000000..289b5063 --- /dev/null +++ b/.github/patches/coldbox-full-null.patch @@ -0,0 +1,105 @@ +diff --git a/tests/resources/app/coldbox/system/testing/VirtualApp.cfc b/tests/resources/app/coldbox/system/testing/VirtualApp.cfc +--- a/tests/resources/app/coldbox/system/testing/VirtualApp.cfc ++++ b/tests/resources/app/coldbox/system/testing/VirtualApp.cfc +@@ -94 +94 @@ +- return !isNull( application.cbController ); ++ return application.keyExists( "cbController" ) && !isNull( application.cbController ); +@@ -112 +112 @@ +- if ( !isNull( application.cbController ) ) { ++ if ( application.keyExists( "cbController" ) && !isNull( application.cbController ) ) { +diff --git a/tests/resources/app/coldbox/system/logging/config/LogBoxConfig.cfc b/tests/resources/app/coldbox/system/logging/config/LogBoxConfig.cfc +--- a/tests/resources/app/coldbox/system/logging/config/LogBoxConfig.cfc ++++ b/tests/resources/app/coldbox/system/logging/config/LogBoxConfig.cfc +@@ -64 +64 @@ +- if ( isNull( variables.utility ) ) { ++ if ( !variables.keyExists( "utility" ) || isNull( variables.utility ) ) { +diff --git a/tests/resources/app/coldbox/system/core/util/Util.cfc b/tests/resources/app/coldbox/system/core/util/Util.cfc +--- a/tests/resources/app/coldbox/system/core/util/Util.cfc ++++ b/tests/resources/app/coldbox/system/core/util/Util.cfc +@@ -296 +296 @@ +- if ( isNull( variables.mixerUtil ) ) { ++ if ( !variables.keyExists( "mixerUtil" ) || isNull( variables.mixerUtil ) ) { +diff --git a/tests/resources/app/coldbox/system/logging/Logger.cfc b/tests/resources/app/coldbox/system/logging/Logger.cfc +--- a/tests/resources/app/coldbox/system/logging/Logger.cfc ++++ b/tests/resources/app/coldbox/system/logging/Logger.cfc +@@ -399 +399 @@ +- if ( isNull( local.logEvent ) ) { ++ if ( !local.keyExists( "logEvent" ) || isNull( local.logEvent ) ) { +diff --git a/tests/resources/app/coldbox/system/core/delegates/Env.cfc b/tests/resources/app/coldbox/system/core/delegates/Env.cfc +--- a/tests/resources/app/coldbox/system/core/delegates/Env.cfc ++++ b/tests/resources/app/coldbox/system/core/delegates/Env.cfc +@@ -87 +87 @@ +- if ( isNull( variables.javaSystem ) ) { ++ if ( !variables.keyExists( "javaSystem" ) || isNull( variables.javaSystem ) ) { +diff --git a/tests/resources/app/coldbox/system/web/services/InterceptorService.cfc b/tests/resources/app/coldbox/system/web/services/InterceptorService.cfc +--- a/tests/resources/app/coldbox/system/web/services/InterceptorService.cfc ++++ b/tests/resources/app/coldbox/system/web/services/InterceptorService.cfc +@@ -194 +194 @@ +- if ( !isNull( arguments.interceptData ) ) { ++ if ( arguments.keyExists( "interceptData" ) && !isNull( arguments.interceptData ) ) { +diff --git a/tests/resources/app/coldbox/system/testing/BaseTestCase.cfc b/tests/resources/app/coldbox/system/testing/BaseTestCase.cfc +--- a/tests/resources/app/coldbox/system/testing/BaseTestCase.cfc ++++ b/tests/resources/app/coldbox/system/testing/BaseTestCase.cfc +@@ -162 +162 @@ +- if ( isNull( variables._ranBeforeAll ) ) { ++ if ( !variables.keyExists( "_ranBeforeAll" ) || isNull( variables._ranBeforeAll ) ) { +@@ -172 +172 @@ +- if ( isNull( variables._ranAfterAll ) ) { ++ if ( !variables.keyExists( "_ranAfterAll" ) || isNull( variables._ranAfterAll ) ) { +@@ -784 +784 @@ +- if ( !isNull( arguments.interceptData ) ) { ++ if ( arguments.keyExists( "interceptData" ) && !isNull( arguments.interceptData ) ) { +@@ -847 +847 @@ +- if ( isNull( variables.cbUtil ) ) { ++ if ( !variables.keyExists( "cbUtil" ) || isNull( variables.cbUtil ) ) { +@@ -859 +859 @@ +- if ( isNull( variables.env ) ) { ++ if ( !variables.keyExists( "env" ) || isNull( variables.env ) ) { +diff --git a/tests/resources/app/coldbox/system/ioc/Builder.cfc b/tests/resources/app/coldbox/system/ioc/Builder.cfc +--- a/tests/resources/app/coldbox/system/ioc/Builder.cfc ++++ b/tests/resources/app/coldbox/system/ioc/Builder.cfc +@@ -82 +82 @@ +- if ( isNull( variables.coldboxDSL ) ) { ++ if ( !variables.keyExists( "coldboxDSL" ) || isNull( variables.coldboxDSL ) ) { +@@ -94 +94 @@ +- if ( isNull( variables.cacheBoxDSL ) ) { ++ if ( !variables.keyExists( "cacheBoxDSL" ) || isNull( variables.cacheBoxDSL ) ) { +@@ -106 +106 @@ +- if ( isNull( variables.logBoxDSL ) ) { ++ if ( !variables.keyExists( "logBoxDSL" ) || isNull( variables.logBoxDSL ) ) { +diff --git a/tests/resources/app/coldbox/system/cache/store/ConcurrentStore.cfc b/tests/resources/app/coldbox/system/cache/store/ConcurrentStore.cfc +--- a/tests/resources/app/coldbox/system/cache/store/ConcurrentStore.cfc ++++ b/tests/resources/app/coldbox/system/cache/store/ConcurrentStore.cfc +@@ -261 +261 @@ +- if ( isNull( variables.collections ) ) { ++ if ( !variables.keyExists( "collections" ) || isNull( variables.collections ) ) { +diff --git a/tests/resources/app/coldbox/system/FrameworkSupertype.cfc b/tests/resources/app/coldbox/system/FrameworkSupertype.cfc +--- a/tests/resources/app/coldbox/system/FrameworkSupertype.cfc ++++ b/tests/resources/app/coldbox/system/FrameworkSupertype.cfc +@@ -621 +621 @@ +- if ( isNull( variables.asyncManager ) ) { ++ if ( !variables.keyExists( "asyncManager" ) || isNull( variables.asyncManager ) ) { +@@ -755 +755 @@ +- if ( isNull( variables.cbDateTimeHelper ) ) { ++ if ( !variables.keyExists( "cbDateTimeHelper" ) || isNull( variables.cbDateTimeHelper ) ) { +diff --git a/tests/resources/app/coldbox/system/core/util/Util.cfc b/tests/resources/app/coldbox/system/core/util/Util.cfc +--- a/tests/resources/app/coldbox/system/core/util/Util.cfc ++++ b/tests/resources/app/coldbox/system/core/util/Util.cfc +@@ -124 +124 @@ +- if ( isNull( variables.inetAddress ) ) { ++ if ( !variables.keyExists( "inetAddress" ) || isNull( variables.inetAddress ) ) { +diff --git a/tests/resources/app/coldbox/system/cache/config/CacheBoxConfig.cfc b/tests/resources/app/coldbox/system/cache/config/CacheBoxConfig.cfc +--- a/tests/resources/app/coldbox/system/cache/config/CacheBoxConfig.cfc ++++ b/tests/resources/app/coldbox/system/cache/config/CacheBoxConfig.cfc +@@ -107 +107 @@ +- if ( !isNull( cacheBoxDSL.logBoxConfig ) ) { ++ if ( structKeyExists( cacheBoxDSL, "logBoxConfig" ) && !isNull( cacheBoxDSL.logBoxConfig ) ) { +@@ -112 +112 @@ +- if ( !isNull( cacheBoxDSL.scopeRegistration ) ) { ++ if ( structKeyExists( cacheBoxDSL, "scopeRegistration" ) && !isNull( cacheBoxDSL.scopeRegistration ) ) { +@@ -117 +117 @@ +- if ( !isNull( cacheBoxDSL.caches ) ) { ++ if ( structKeyExists( cacheBoxDSL, "caches" ) && !isNull( cacheBoxDSL.caches ) ) { +@@ -125 +125 @@ +- if ( !isNull( cacheBoxDSL.listeners ) ) { ++ if ( structKeyExists( cacheBoxDSL, "listeners" ) && !isNull( cacheBoxDSL.listeners ) ) { diff --git a/.github/patches/coldbox7-full-null.patch b/.github/patches/coldbox7-full-null.patch new file mode 100644 index 00000000..0c098178 --- /dev/null +++ b/.github/patches/coldbox7-full-null.patch @@ -0,0 +1,51 @@ +diff --git a/tests/resources/app/coldbox/system/core/util/Util.cfc b/tests/resources/app/coldbox/system/core/util/Util.cfc +--- a/tests/resources/app/coldbox/system/core/util/Util.cfc ++++ b/tests/resources/app/coldbox/system/core/util/Util.cfc +@@ -12 +12 @@ +- if ( isNull( variables.engineMappingHelper ) ) { ++ if ( !variables.keyExists( "engineMappingHelper" ) || isNull( variables.engineMappingHelper ) ) { +diff --git a/tests/resources/app/coldbox/system/logging/LogEvent.cfc b/tests/resources/app/coldbox/system/logging/LogEvent.cfc +--- a/tests/resources/app/coldbox/system/logging/LogEvent.cfc ++++ b/tests/resources/app/coldbox/system/logging/LogEvent.cfc +@@ -61 +61 @@ +- if ( isNull( variables.xmlConverter ) ) { ++ if ( !variables.keyExists( "xmlConverter" ) || isNull( variables.xmlConverter ) ) { +@@ -68 +68 @@ +- if ( isNull( variables.util ) ) { ++ if ( !variables.keyExists( "util" ) || isNull( variables.util ) ) { +diff --git a/tests/resources/app/coldbox/system/cache/AbstractCacheBoxProvider.cfc b/tests/resources/app/coldbox/system/cache/AbstractCacheBoxProvider.cfc +--- a/tests/resources/app/coldbox/system/cache/AbstractCacheBoxProvider.cfc ++++ b/tests/resources/app/coldbox/system/cache/AbstractCacheBoxProvider.cfc +@@ -115 +115 @@ +- if ( isNull( variables.utility ) ) { ++ if ( !variables.keyExists( "utility" ) || isNull( variables.utility ) ) { +@@ -428 +428 @@ +- if ( isNull( variables.uuidHelper ) ) { ++ if ( !variables.keyExists( "uuidHelper" ) || isNull( variables.uuidHelper ) ) { +diff --git a/tests/resources/app/coldbox/system/cache/CacheFactory.cfc b/tests/resources/app/coldbox/system/cache/CacheFactory.cfc +--- a/tests/resources/app/coldbox/system/cache/CacheFactory.cfc ++++ b/tests/resources/app/coldbox/system/cache/CacheFactory.cfc +@@ -484 +484 @@ +- if ( isNull( variables.config ) ) { ++ if ( !variables.keyExists( "config" ) || isNull( variables.config ) ) { +diff --git a/tests/resources/app/coldbox/system/remote/ColdboxProxy.cfc b/tests/resources/app/coldbox/system/remote/ColdboxProxy.cfc +--- a/tests/resources/app/coldbox/system/remote/ColdboxProxy.cfc ++++ b/tests/resources/app/coldbox/system/remote/ColdboxProxy.cfc +@@ -342 +342 @@ +- if ( isNull( variables.util ) ) { ++ if ( !variables.keyExists( "util" ) || isNull( variables.util ) ) { +@@ -354 +354 @@ +- if ( isNull( variables.remotingUtil ) ) { ++ if ( !variables.keyExists( "remotingUtil" ) || isNull( variables.remotingUtil ) ) { +diff --git a/tests/resources/app/coldbox/system/web/context/RequestContext.cfc b/tests/resources/app/coldbox/system/web/context/RequestContext.cfc +--- a/tests/resources/app/coldbox/system/web/context/RequestContext.cfc ++++ b/tests/resources/app/coldbox/system/web/context/RequestContext.cfc +@@ -1576 +1576 @@ +- if ( isNull( variables.privateContext.response ) ) { ++ if ( !variables.privateContext.keyExists( "response" ) || isNull( variables.privateContext.response ) ) { +diff --git a/modules/str/modules/cbjavaloader/models/javaloader/JavaLoader.cfc b/modules/str/modules/cbjavaloader/models/javaloader/JavaLoader.cfc +--- a/modules/str/modules/cbjavaloader/models/javaloader/JavaLoader.cfc ++++ b/modules/str/modules/cbjavaloader/models/javaloader/JavaLoader.cfc +@@ -570 +570 @@ +- returntype="string" ++ returntype="void" diff --git a/.github/patches/mementifier-full-null.patch b/.github/patches/mementifier-full-null.patch new file mode 100644 index 00000000..64087f41 --- /dev/null +++ b/.github/patches/mementifier-full-null.patch @@ -0,0 +1,34 @@ +diff --git a/modules/mementifier/interceptors/Mementifier.cfc b/modules/mementifier/interceptors/Mementifier.cfc +--- a/modules/mementifier/interceptors/Mementifier.cfc ++++ b/modules/mementifier/interceptors/Mementifier.cfc +@@ -89,2 +89,3 @@ +- var dateMask = isNull( this.memento.dateMask ) ? variables.settings.dateMask : this.memento.dateMask; +- var timeMask = isNull( this.memento.timeMask ) ? variables.settings.timeMask : this.memento.timeMask; ++ var entityMemento = structKeyExists( arguments.entity, "memento" ) ? arguments.entity.memento : {}; ++ var dateMask = !entityMemento.keyExists( "dateMask" ) || isNull( entityMemento.dateMask ) ? variables.settings.dateMask : entityMemento.dateMask; ++ var timeMask = !entityMemento.keyExists( "timeMask" ) || isNull( entityMemento.timeMask ) ? variables.settings.timeMask : entityMemento.timeMask; +@@ -150,12 +151,12 @@ +- "autoCastBooleans" : isNull( this.memento.autoCastBooleans ) ? variables.$mementifierSettings.autoCastBooleans : this.memento.autoCastBooleans, +- "dateMask" : isNull( this.memento.dateMask ) ? variables.$mementifierSettings.dateMask : this.memento.dateMask, +- "defaults" : isNull( this.memento.defaults ) ? {} : this.memento.defaults, +- "defaultIncludes" : isNull( this.memento.defaultIncludes ) ? [] : this.memento.defaultIncludes, +- "defaultExcludes" : isNull( this.memento.defaultExcludes ) ? [] : this.memento.defaultExcludes, +- "iso8601Format" : isNull( this.memento.iso8601Format ) ? variables.$mementifierSettings.iso8601Format : this.memento.iso8601Format, +- "mappers" : isNull( this.memento.mappers ) ? {} : this.memento.mappers, +- "neverInclude" : isNull( this.memento.neverInclude ) ? [] : this.memento.neverInclude, +- "ormAutoIncludes" : isNull( this.memento.ormAutoIncludes ) ? variables.$mementifierSettings.ormAutoIncludes : this.memento.ormAutoIncludes, +- "profiles" : isNull( this.memento.profiles ) ? {} : this.memento.profiles, +- "timeMask" : isNull( this.memento.timeMask ) ? variables.$mementifierSettings.timeMask : this.memento.timeMask, +- "trustedGetters" : isNull( this.memento.trustedGetters ) ? variables.$mementifierSettings.trustedGetters : this.memento.trustedGetters ++ "autoCastBooleans" : !this.memento.keyExists( "autoCastBooleans" ) || isNull( this.memento.autoCastBooleans ) ? variables.$mementifierSettings.autoCastBooleans : this.memento.autoCastBooleans, ++ "dateMask" : !this.memento.keyExists( "dateMask" ) || isNull( this.memento.dateMask ) ? variables.$mementifierSettings.dateMask : this.memento.dateMask, ++ "defaults" : !this.memento.keyExists( "defaults" ) || isNull( this.memento.defaults ) ? {} : this.memento.defaults, ++ "defaultIncludes" : !this.memento.keyExists( "defaultIncludes" ) || isNull( this.memento.defaultIncludes ) ? [] : this.memento.defaultIncludes, ++ "defaultExcludes" : !this.memento.keyExists( "defaultExcludes" ) || isNull( this.memento.defaultExcludes ) ? [] : this.memento.defaultExcludes, ++ "iso8601Format" : !this.memento.keyExists( "iso8601Format" ) || isNull( this.memento.iso8601Format ) ? variables.$mementifierSettings.iso8601Format : this.memento.iso8601Format, ++ "mappers" : !this.memento.keyExists( "mappers" ) || isNull( this.memento.mappers ) ? {} : this.memento.mappers, ++ "neverInclude" : !this.memento.keyExists( "neverInclude" ) || isNull( this.memento.neverInclude ) ? [] : this.memento.neverInclude, ++ "ormAutoIncludes" : !this.memento.keyExists( "ormAutoIncludes" ) || isNull( this.memento.ormAutoIncludes ) ? variables.$mementifierSettings.ormAutoIncludes : this.memento.ormAutoIncludes, ++ "profiles" : !this.memento.keyExists( "profiles" ) || isNull( this.memento.profiles ) ? {} : this.memento.profiles, ++ "timeMask" : !this.memento.keyExists( "timeMask" ) || isNull( this.memento.timeMask ) ? variables.$mementifierSettings.timeMask : this.memento.timeMask, ++ "trustedGetters" : !this.memento.keyExists( "trustedGetters" ) || isNull( this.memento.trustedGetters ) ? variables.$mementifierSettings.trustedGetters : this.memento.trustedGetters diff --git a/.github/patches/testbox-full-null.patch b/.github/patches/testbox-full-null.patch new file mode 100644 index 00000000..ec53024c --- /dev/null +++ b/.github/patches/testbox-full-null.patch @@ -0,0 +1,63 @@ +diff --git a/testbox/system/coverage/CoverageService.cfc b/testbox/system/coverage/CoverageService.cfc +--- a/testbox/system/coverage/CoverageService.cfc ++++ b/testbox/system/coverage/CoverageService.cfc +@@ -175 +175 @@ +- if ( isNull( opts.coverageTresholds ) ) { ++ if ( !structKeyExists( opts, "coverageTresholds" ) || isNull( opts.coverageTresholds ) ) { +@@ -178 +178 @@ +- if ( isNull( opts.coverageTresholds.good ) ) { ++ if ( !structKeyExists( opts.coverageTresholds, "good" ) || isNull( opts.coverageTresholds.good ) ) { +@@ -181 +181 @@ +- if ( isNull( opts.coverageTresholds.bad ) ) { ++ if ( !structKeyExists( opts.coverageTresholds, "bad" ) || isNull( opts.coverageTresholds.bad ) ) { +diff --git a/testbox/system/TestBox.cfc b/testbox/system/TestBox.cfc +--- a/testbox/system/TestBox.cfc ++++ b/testbox/system/TestBox.cfc +@@ -408 +408 @@ +- if ( !isNull( url.testBundles ) ) { ++ if ( structKeyExists( url, "testBundles" ) && !isNull( url.testBundles ) ) { +@@ -411 +411 @@ +- if ( !isNull( url.testSuites ) ) { ++ if ( structKeyExists( url, "testSuites" ) && !isNull( url.testSuites ) ) { +@@ -414 +414 @@ +- if ( !isNull( url.testSpecs ) ) { ++ if ( structKeyExists( url, "testSpecs" ) && !isNull( url.testSpecs ) ) { +@@ -417 +417 @@ +- if ( !isNull( url.testMethod ) ) { ++ if ( structKeyExists( url, "testMethod" ) && !isNull( url.testMethod ) ) { +@@ -259 +259 @@ +- if ( isNull( variables.env ) ) { ++ if ( !structKeyExists( variables, "env" ) || isNull( variables.env ) ) { +diff --git a/testbox/system/util/Util.cfc b/testbox/system/util/Util.cfc +--- a/testbox/system/util/Util.cfc ++++ b/testbox/system/util/Util.cfc +@@ -203 +203 @@ +- if ( isNull( variables.engineMappingHelper ) ) { ++ if ( !structKeyExists( variables, "engineMappingHelper" ) || isNull( variables.engineMappingHelper ) ) { +diff --git a/testbox/system/util/Env.cfc b/testbox/system/util/Env.cfc +--- a/testbox/system/util/Env.cfc ++++ b/testbox/system/util/Env.cfc +@@ -87 +87 @@ +- if ( isNull( variables.javaSystem ) ) { ++ if ( !structKeyExists( variables, "javaSystem" ) || isNull( variables.javaSystem ) ) { +diff --git a/testbox/system/BaseSpec.cfc b/testbox/system/BaseSpec.cfc +--- a/testbox/system/BaseSpec.cfc ++++ b/testbox/system/BaseSpec.cfc +@@ -1627 +1627 @@ +- if ( isNull( variables.$cbMockData ) ) { ++ if ( !structKeyExists( variables, "$cbMockData" ) || isNull( variables.$cbMockData ) ) { +@@ -1640 +1640 @@ +- if ( isNull( variables.$utility ) ) { ++ if ( !structKeyExists( variables, "$utility" ) || isNull( variables.$utility ) ) { +@@ -1653 +1653 @@ +- if ( isNull( variables.$env ) ) { ++ if ( !structKeyExists( variables, "$env" ) || isNull( variables.$env ) ) { +@@ -1668 +1668 @@ +- if ( isNull( this.$mockbox ) ) { ++ if ( !structKeyExists( this, "$mockbox" ) || isNull( this.$mockbox ) ) { +diff --git a/testbox/system/runners/BDDRunner.cfc b/testbox/system/runners/BDDRunner.cfc +--- a/testbox/system/runners/BDDRunner.cfc ++++ b/testbox/system/runners/BDDRunner.cfc +@@ -159 +159 @@ +- isNull( thisSuite ) ? {} : thisSuite ++ !structKeyExists( local, "thisSuite" ) || isNull( local.thisSuite ) ? {} : local.thisSuite diff --git a/.github/workflows/cron.yml b/.github/workflows/cron.yml index be47a606..92825024 100644 --- a/.github/workflows/cron.yml +++ b/.github/workflows/cron.yml @@ -11,40 +11,51 @@ jobs: strategy: fail-fast: false matrix: - cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang-cfml@1"] + cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] coldbox: ["coldbox@^7", "coldbox@^8"] - experimental: [ false ] + experimental: [false] + fullNull: ["true", "false"] + exclude: + - cfengine: "boxlang@1" + coldbox: "coldbox@^7" + - cfengine: "adobe@2021" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2023" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2025" + coldbox: "coldbox@^8" + fullNull: "true" include: - cfengine: "lucee@be" coldbox: "coldbox@^7" experimental: true + fullNull: "true" - cfengine: "lucee@be" coldbox: "coldbox@^8" experimental: true + fullNull: "true" - cfengine: "lucee@be" coldbox: "coldbox@be" experimental: true + fullNull: "true" - cfengine: "adobe@be" coldbox: "coldbox@^7" experimental: true - - cfengine: "adobe@be" - coldbox: "coldbox@^8" - experimental: true - - cfengine: "adobe@be" - coldbox: "coldbox@be" - experimental: true - - cfengine: "boxlang@1" - coldbox: "coldbox@^8" - experimental: true + fullNull: "true" - cfengine: "boxlang@1" coldbox: "coldbox@be" experimental: true + fullNull: "true" - cfengine: "boxlang@be" coldbox: "coldbox@^8" experimental: true + fullNull: "true" - cfengine: "boxlang@be" coldbox: "coldbox@be" experimental: true + fullNull: "true" services: mysql: image: mysql:5.7 @@ -74,11 +85,18 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/mementifier-full-null.patch box config set modules.commandbox-dotenv.checkEnvPreServerStart=false box install ${{ matrix.coldbox }} --noSave + git apply --unidiff-zero .github/patches/coldbox-full-null.patch + if [ "${{ matrix.coldbox }}" = "coldbox@^7" ]; then + git apply --unidiff-zero .github/patches/coldbox7-full-null.patch + fi - name: Start server env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick @@ -90,9 +108,11 @@ jobs: - name: Run TestBox Tests env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick DB_USER: quick DB_PASSWORD: quick - run: box testbox run \ No newline at end of file + continue-on-error: ${{ matrix.experimental }} + run: box testbox run diff --git a/.github/workflows/pr.yml b/.github/workflows/pr.yml index c636f8b3..93f2ac0e 100644 --- a/.github/workflows/pr.yml +++ b/.github/workflows/pr.yml @@ -19,8 +19,31 @@ jobs: strategy: fail-fast: false matrix: - cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang-cfml@1"] + cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] coldbox: ["coldbox@^7", "coldbox@^8"] + experimental: [false] + fullNull: ["true", "false"] + exclude: + - cfengine: "boxlang@1" + coldbox: "coldbox@^7" + - cfengine: "adobe@2021" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2023" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2025" + coldbox: "coldbox@^8" + fullNull: "true" + include: + - cfengine: "adobe@be" + coldbox: "coldbox@^7" + experimental: true + fullNull: "true" + - cfengine: "boxlang@be" + coldbox: "coldbox@^8" + experimental: true + fullNull: "true" services: mysql: image: mysql:5.7 @@ -50,10 +73,18 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/mementifier-full-null.patch box config set modules.commandbox-dotenv.checkEnvPreServerStart=false + box install ${{ matrix.coldbox }} --noSave + git apply --unidiff-zero .github/patches/coldbox-full-null.patch + if [ "${{ matrix.coldbox }}" = "coldbox@^7" ]; then + git apply --unidiff-zero .github/patches/coldbox7-full-null.patch + fi - name: Start server env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick @@ -65,11 +96,13 @@ jobs: - name: Run TestBox Tests env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick DB_USER: quick DB_PASSWORD: quick + continue-on-error: ${{ matrix.experimental }} run: box testbox run format: @@ -97,4 +130,4 @@ jobs: - name: Commit Format Changes uses: stefanzweifel/git-auto-commit-action@v5.2.0 with: - commit_message: Apply cfformat changes \ No newline at end of file + commit_message: Apply cfformat changes diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 8c7a0fcb..cc348c77 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -14,8 +14,21 @@ jobs: strategy: fail-fast: false matrix: - cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang-cfml@1"] + cfengine: ["lucee@5", "lucee@6", "adobe@2021", "adobe@2023", "adobe@2025", "boxlang@1", "boxlang-cfml@1"] coldbox: ["coldbox@^7", "coldbox@^8"] + fullNull: ["true", "false"] + exclude: + - cfengine: "boxlang@1" + coldbox: "coldbox@^7" + - cfengine: "adobe@2021" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2023" + coldbox: "coldbox@^8" + fullNull: "true" + - cfengine: "adobe@2025" + coldbox: "coldbox@^8" + fullNull: "true" services: mysql: image: mysql:5.7 @@ -45,10 +58,18 @@ jobs: - name: Install dependencies run: | box install + git apply --unidiff-zero .github/patches/testbox-full-null.patch + git apply --unidiff-zero .github/patches/mementifier-full-null.patch box config set modules.commandbox-dotenv.checkEnvPreServerStart=false + box install ${{ matrix.coldbox }} --noSave + git apply --unidiff-zero .github/patches/coldbox-full-null.patch + if [ "${{ matrix.coldbox }}" = "coldbox@^7" ]; then + git apply --unidiff-zero .github/patches/coldbox7-full-null.patch + fi - name: Start server env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick @@ -60,6 +81,7 @@ jobs: - name: Run TestBox Tests env: + FULL_NULL: ${{ matrix.fullNull }} DB_HOST: localhost DB_PORT: ${{ job.services.mysql.ports[3306] }} DB_NAME: quick diff --git a/.gitignore b/.gitignore index 4f1afa2d..aa35f9b5 100644 --- a/.gitignore +++ b/.gitignore @@ -5,13 +5,7 @@ /modules .vscode -!.engine/ -.engine/* -!.engine/WEB-INF/ -.engine/WEB-INF/* -!.engine/WEB-INF/lib -.engine/WEB-INF/lib/* -!.engine/WEB-INF/lib/h2-1.4.196.jar +/.engine/ .env .tmp diff --git a/ModuleConfig.cfc b/ModuleConfig.cfc index 67eff730..0b6cc09b 100644 --- a/ModuleConfig.cfc +++ b/ModuleConfig.cfc @@ -12,6 +12,8 @@ component { "defaultQueryOptions" : {}, "preventDuplicateJoins" : true, "preventLazyLoading" : false, + "automaticTimestamps" : true, + "refreshOnSaveFallback" : true, "lazyLoadingViolationCallback" : ( entity, relationName ) => { throw( type = "QuickLazyLoadingException", @@ -35,6 +37,7 @@ component { "quickInstanceReady", "quickPreLoad", "quickPostLoad", + "quickPostReplicate", "quickPreSave", "quickPostSave", "quickPreInsert", @@ -42,11 +45,16 @@ component { "quickPreUpdate", "quickPostUpdate", "quickPreDelete", - "quickPostDelete" + "quickPostDelete", + "quickRelationshipLoaded" ] }; binder.map( "quick.models.BaseEntity" ).to( "#moduleMapping#.models.BaseEntity" ); + binder + .map( "EntityDefinitionRegistry@quick" ) + .to( "#moduleMapping#.models.EntityDefinitionRegistry" ) + .asSingleton(); binder.getInjector().registerDSL( "quickService", "#moduleMapping#.dsl.QuickServiceDSL" ); } @@ -59,6 +67,16 @@ component { .initArg( name = "preventDuplicateJoins", value = settings.preventDuplicateJoins ) .initArg( name = "defaultOptions", value = settings.defaultQueryOptions ) .initArg( name = "utils", dsl = "QueryUtils@qb" ) + .initArg( name = "returnFormatterRegistry", ref = "ReturnFormatterRegistry@qb" ) + .initArg( + name = "validateDuplicateSelectColumns", + dsl = "coldbox:moduleSettings:qb:validateDuplicateSelectColumns" + ) + .initArg( + name = "validateQueryExecuteReturnType", + dsl = "coldbox:moduleSettings:qb:validateQueryExecuteReturnType" + ) + .initArg( name = "collectQueryLog", dsl = "coldbox:moduleSettings:qb:collectQueryLog" ) .initArg( name = "sqlCommenter", ref = "ColdBoxSQLCommenter@qb" ) .initArg( name = "returnFormat", value = "array" ); @@ -74,6 +92,9 @@ component { } function onUnload() { + if ( wirebox.containsInstance( "EntityDefinitionRegistry@quick" ) ) { + wirebox.getInstance( "EntityDefinitionRegistry@quick" ).clear(); + } var cacheBox = wirebox.getCachebox(); if ( cacheBox.cacheExists( settings.metadataCache.name ) ) { cacheBox.getCache( settings.metadataCache.name ).clearAll(); diff --git a/README.md b/README.md index 3f5457d0..f167b2e2 100644 --- a/README.md +++ b/README.md @@ -93,6 +93,83 @@ component { Now that you've seen an example, [dig in to what you can do](https://quick.ortusbooks.com/) with Quick! +### Caching queries + +Quick passes query options through to `queryExecute`, so applications can use the query cache provided by their CFML engine. This works with collection queries and primary-key lookups: + +```javascript +var users = getInstance( "User" ).get( + options = { cachedWithin : createTimeSpan( 0, 0, 5, 0 ) } +); + +var user = getInstance( "User" ).find( + rc.id, + { cachedWithin : createTimeSpan( 0, 0, 5, 0 ) } +); +``` + +An entity can also configure defaults for every query by assigning `_queryOptions` in its pseudo-constructor: + +```javascript +component extends="quick.models.BaseEntity" { + + variables._queryOptions = { + cachedWithin : createTimeSpan( 0, 0, 5, 0 ) + }; + +} +``` + +Query caching stores database results, not live Quick entities or loaded relationships. Cache lifetime and invalidation are managed by the CFML engine, so use short lifetimes for data that Quick or another process may update. For application-specific invalidation or distributed caching, cache entity mementos in CacheBox at the service layer and rehydrate them through Quick's public APIs. + +### Testing with model factories + +Quick includes Laravel-inspired model factories under `quick.resources.testing`. Define application factories outside of your production model code: + +```javascript +// tests/resources/factories/UserFactory.cfc +component extends="quick.resources.testing.Factory" { + + struct function definition() { + return { + username : "factory-#lCase( createUUID() )#", + firstName : "Factory", + lastName : "User" + }; + } + + any function administrator() { + return state( { type : "admin" } ); + } + +} +``` + +Create a manager in your test base class and expose a short `factory()` helper: + +```javascript +variables.factoryManager = new quick.resources.testing.FactoryManager( + wirebox = getWireBox(), + factoryPath = "tests.resources.factories" +); + +any function factory( required string name ) { + return variables.factoryManager.factory( arguments.name ); +} +``` + +Factories support default definitions, explicit and named states, counts, sequences, attribute closures, and `afterMaking` and `afterCreating` callbacks. `make()` returns unsaved Quick entities, while `create()` persists through the entity's normal `save()` lifecycle: + +```javascript +var admin = factory( "User" ).administrator().create(); +var users = factory( "User" ).count( 3 ).create(); +var unsavedUser = factory( "User" ).make( { firstName : "Override" } ); +``` + +Factories do not manage database transactions. Integration tests should start a transaction around each test and roll it back in `finally`, ensuring both passing and failing tests leave the database unchanged. + +All factory implementation classes are isolated beneath `resources/testing`; production deployment tooling may exclude that directory. Quick does not load or register these classes during normal module startup. + ### Tests and Contributing To run the tests, first clone this repo and run a `box install`. diff --git a/box.json b/box.json index 6fc74b66..827af5af 100644 --- a/box.json +++ b/box.json @@ -20,22 +20,23 @@ "shortDescription":"A ColdBox ORM Engine", "description":"A ColdBox ORM Engine", "scripts":{ - "format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --overwrite", - "format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc --verbose", - "format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,tests/specs/**/*.cfc", + "format":"cfformat run dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/performance/**/*.cfc,tests/performance/*.cfm,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --overwrite", + "format:check":"cfformat check dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/performance/**/*.cfc,tests/performance/*.cfm,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc --verbose", + "format:watch":"cfformat watch dsl/**/*.cfc,extras/**/*.cfc,models/**/*.cfc,resources/testing/**/*.cfc,tests/performance/**/*.cfc,tests/performance/*.cfm,tests/resources/factories/**/*.cfc,tests/specs/**/*.cfc", + "performance":"task run taskFile=tests/performance/Run.cfc", "generateAPIDocs":"rm .tmp --recurse --force && docbox generate mapping=quick excludes=test|/modules|ModuleConfig|QuickCollection strategy-outputDir=.tmp/apidocs strategy-projectTitle=Quick", "install:2021":"cfpm install document,feed,mysql,zip", "bx-modules:install":"install bx-compat-cfml@be,bx-esapi,bx-mysql" }, "type":"modules", "dependencies":{ - "qb":"^13.0.12", + "qb":"14.0.0-beta.5", "str":"^4.0.0", "mementifier":"^3.0.0" }, "devDependencies":{ "coldbox":"^8.0.0", - "testbox":"^6.0.0", + "testbox":"^7.0.0", "cfcollection":"^3.6.4", "cfmigrations":"^5.0.0" }, diff --git a/docs/performance-architecture-plan.md b/docs/performance-architecture-plan.md new file mode 100644 index 00000000..469b1a8e --- /dev/null +++ b/docs/performance-architecture-plan.md @@ -0,0 +1,617 @@ +# Quick performance architecture plan + +## Status and scope + +- Branch: `codex/quick-performance-audit` +- Production-code head under test: `883f718f4e7c592cebb4ec02a93ed7358f5d47e8` +- Baseline branch: `next` at `fbcf9687d0048f0fc222a4dcb26513720b479fc8` +- Audit date: 2026-08-28 +- Runtimes: Lucee 6.2.8.20, Adobe ColdFusion 2021.0.22, and BoxLang + 1.17.0+58 +- Scope: architecture analysis, benchmark extensions, and an implementation + plan; no production optimization is implemented by this document + +This is the second performance pass. The first pass accepted three isolated +optimizations and established that the remaining large gains require changes to +Quick's internal architecture. This plan investigates those underpinnings, +especially metadata compilation, metadata caching, entity construction, +hydration, builders, and relationships. + +All expected percentages below are targets for the named operation. They are +not additive and are not promises for an entire application request. Retained +heap percentages require profiler confirmation before they can be claimed. + +## Executive findings + +1. Quick's metadata cache mixes two different lifetimes. Authoritative entity + definitions, the BaseEntity function index, discriminations, and qualified + columns derived from runtime overlays and table names all occupy the same + CacheBox cache. The default limit is 300 objects. Runtime-derived key churn + can therefore evict a mapping definition and expose part of a 12.37-21.67 ms + full-cold metadata path. +2. The warmed CacheBox lookup itself is 3.7-4.8 times slower than reading the + definition already bound to an entity. This is a small operation in absolute + terms, but it shows that CacheBox is the wrong hot-path abstraction for an + immutable process-lifetime mapping definition. +3. Quick retains the engines' complete inherited and local metadata graphs in + every cached definition. The representative 19-attribute `User` definition + serializes to 208,205-355,563 characters and includes 217-331 inherited + function descriptions. Quick's hot path needs only a normalized subset. +4. Entity allocation is largely independent of mapping width. Moving from the + narrow entity to the wide entity adds only 84-119 us, while total + construction is 290-618 us. CFML component creation, DI, generated methods, + mementifier setup, and rebinding definition fields dominate the row data. +5. Hydration, not the local database, dominates the measured database path. A + hydrated row is 62 times the raw-row cost on BoxLang, 82 times on Lucee, and + 129 times on Adobe ColdFusion in this local fixture. Network and production + database latency will reduce those ratios, but not the allocation pressure. +6. Builder and relationship construction remain high-value redesign targets. + Builder creation costs 0.70-2.17 ms. A `hasMany` construction costs + 2.89-4.26 ms and allocates about 1.60 MB on Lucee or 4.34 MB on BoxLang. +7. BoxLang magnifies allocation-heavy paths: a clean dirty check allocates + about 3.05 MB, an attribute snapshot 2.67 MB, and a builder 2.17 MB. The + architecture should reduce transient structs, arrays, closures, and + defensive copies rather than add engine-specific shortcuts. + +## Measurement method + +Each engine ran five complete warmed benchmark passes on the same Apple Silicon +machine and local MySQL database. A pass used 10 warmup iterations, 11 samples, +30 iterations per sample, and 1,000 database rows. The reported value is the +median of the five per-run medians. + +The database scenarios use three timed queries per sample after untimed fixture +setup. `metadata.cold_compile` uses one warmup and seven one-operation samples; +it clears Quick's entire metadata cache and constructs an entity through +WireBox inside the measured callback. It therefore represents the full cold +path, including cache clearing, BaseEntity index rebuilding, metadata +compilation, and normal entity construction. It is not an isolated reflection +timer. Allocation is current-thread allocation and is available on Lucee and +BoxLang, but not on this Adobe ColdFusion runtime. + +One separate directional retained-heap run per engine held 100 live objects and +used three post-GC samples. Engine object layouts and garbage collectors differ, +so retained figures are suitable for same-engine before/after comparisons only. + +### Runtime matrix + +| Runtime | Version | JVM | Allocation counter | Errors | +| --- | --- | --- | --- | ---: | +| Lucee | 6.2.8.20 | Eclipse Adoptium 21.0.12.1 | Available | 0 | +| Adobe ColdFusion | 2021.0.22+330451 | Eclipse Adoptium 11.0.32.1 | Unavailable | 0 | +| BoxLang | 1.17.0+58 | Eclipse Adoptium 21.0.12.1 | Available | 0 | + +Adobe ColdFusion 2021 is the lowest Adobe version in Quick's current CI matrix. +The installed engine was patch 22 of that product line. BoxLang used its +Jakarta-compatible Runwar runtime; the same benchmark workload and logical +operation counts were preserved on all three engines. + +## Cross-engine results + +Wall time is microseconds per logical operation. Database values are per row. +Allocation is KiB per operation and is omitted where the runtime cannot expose +the JVM thread-allocation counter. + +| Scenario | Lucee 6 wall | ACF 2021 wall | BoxLang wall | Lucee 6 alloc | BoxLang alloc | +| --- | ---: | ---: | ---: | ---: | ---: | +| Entity: instantiate wide | 374.87 us | 618.00 us | 491.08 us | 333.57 KiB | 580.85 KiB | +| Entity: instantiate narrow | 290.39 us | 499.41 us | 373.49 us | 283.58 KiB | 482.87 KiB | +| Entity: narrow shallow boundary | 262.43 us | 397.62 us | 369.21 us | 267.64 KiB | 426.76 KiB | +| Entity: hydrate | 397.47 us | 777.56 us | 672.93 us | 410.06 KiB | 1,026.16 KiB | +| Entity: hydrate batch of 100, per entity | 425.11 us | 759.41 us | 682.45 us | 409.95 KiB | 1,024.29 KiB | +| Attribute: read | 12.30 us | 34.51 us | 25.58 us | 9.82 KiB | 59.80 KiB | +| Attribute: assign | 8.31 us | 24.04 us | 17.83 us | 6.25 KiB | 43.51 KiB | +| Runtime overlay: deep lookup | 2.30 us | 6.15 us | 3.95 us | 2.06 KiB | 10.80 KiB | +| Entity: attribute snapshot | 572.10 us | 1,392.39 us | 1,188.05 us | 458.66 KiB | 2,666.04 KiB | +| Entity: clean dirty check | 594.77 us | 1,493.86 us | 1,358.23 us | 492.48 KiB | 3,054.52 KiB | +| Entity: memento | 161.77 us | 138.88 us | 571.12 us | 110.51 KiB | 622.53 KiB | +| Builder: instantiate | 697.41 us | 2,169.78 us | 1,788.74 us | 617.63 KiB | 2,174.84 KiB | +| Builder: clone | 896.43 us | 2,096.43 us | 1,878.99 us | 758.95 KiB | 2,563.98 KiB | +| Builder: compose common SQL | 1,142.86 us | 2,990.16 us | 3,175.00 us | 870.45 KiB | 3,773.27 KiB | +| Relationship: construct `hasMany` | 2,889.57 us | 4,262.20 us | 4,157.24 us | 1,600.35 KiB | 4,342.53 KiB | +| Metadata: CacheBox definition lookup | 2.14 us | 7.09 us | 4.75 us | 1.42 KiB | 11.34 KiB | +| Metadata: bound definition access | 0.53 us | 1.47 us | 1.30 us | 0.34 KiB | 3.63 KiB | +| Metadata: cached qualified columns | 8.10 us | 14.08 us | 17.88 us | 7.99 KiB | 35.70 KiB | +| Metadata: full cold path | 12,372.54 us | 21,095.13 us | 21,667.88 us | 4,204.91 KiB | 29,111.55 KiB | +| Database: raw result | 4.68 us | 4.75 us | 7.39 us | 1.79 KiB | 6.55 KiB | +| Database: hydrated result | 385.35 us | 611.97 us | 454.64 us | 295.20 KiB | 550.99 KiB | + +### Metadata shape diagnostic + +The same `User` mapping has 19 attributes, 19 columns, one cast, one declared +virtual attribute, and 16 normalized top-level metadata keys on every engine. +The retained raw engine metadata differs: + +| Diagnostic | Lucee 6 | ACF 2021 | BoxLang | +| --- | ---: | ---: | ---: | +| Serialized metadata characters | 355,563 | 208,205 | 316,776 | +| Inherited metadata functions | 330 | 331 | 217 | +| Inherited metadata properties | 58 | 58 | 58 | +| Local metadata functions | 74 | 74 | 36 | +| Local metadata properties | 20 | 20 | 20 | + +Serialized character length is a portable shape diagnostic, not a retained-byte +measurement. It nevertheless demonstrates that Quick keeps a large, +engine-specific reflection graph after it has already compiled the normalized +attributes, columns, casts, and relationship names it uses at runtime. + +### Directional retained heap + +| Live object | Lucee 6 | ACF 2021 | BoxLang | +| --- | ---: | ---: | ---: | +| Unloaded wide entity | 163,071 B | 62,693 B | 56,095 B | +| Normally initialized narrow entity | 134,362 B | 51,645 B | 49,636 B | +| Builder | 173,864 B | 89,736 B | 94,008 B | + +These values must not be used to rank engines. Future proposals should compare +candidate and baseline on the same engine and confirm material retained-heap +movement with JFR, a heap dump, or another object-retention profiler. + +## Architecture diagnosis + +### 1. Cache lifetime and eviction do not match metadata semantics + +`ModuleConfig.cfc` creates `quickMeta` with no timeout and `maxObjects=300`. +`BaseEntity.metadataInspection()` stores mapping definitions in it, but the same +cache also stores: + +- the BaseEntity method-name index; +- discriminated-child definitions; and +- qualified-column arrays keyed by mapping, runtime attribute overlay, and + table-name hashes. + +Mapping definitions are authoritative process-lifetime data. Qualified columns +are bounded derived views of an entity definition and query/table overlay. They +should not compete for the same eviction budget. Applications using persistent +runtime attributes, tenant table names, aliases, or many mappings can create +more derived keys than the default capacity even though the number of entity +classes is stable. + +The likely production symptom is tail latency, not a large change in the +median. The full cold path costs 12-22 ms in this representative model, but the +benchmark also clears shared entries and constructs the entity, so it is an +upper bound rather than the isolated cost of one definition eviction. Phase 0 +must add selective-eviction and cache-clear controls before assigning an exact +production spike to eviction. If several mappings are reconstructed together, +the request also creates large temporary metadata graphs and garbage-collection +pressure. + +### 2. The cached value is a reflection document, not a runtime definition + +Metadata compilation first retains `getInheritedMetadata()` and +`getMetadata()`, then derives the smaller maps and arrays Quick needs. The raw +documents stay attached because a few runtime paths and the public `get_Meta()` +shape still read engine annotations such as datasource, grammar, discriminator, +and inheritance fields. + +This couples Quick's hot representation to three different engines' reflection +formats. It also makes an apparently shared definition large and mutable. The +correct boundary is a compact, engine-neutral, immutable `EntityDefinition`, +with an optional compatibility view for callers that inspect legacy metadata. + +### 3. Entity instances rebind and copy definition data + +`metadataInspection()` binds the shared metadata and then copies or aliases its +table, names, attribute maps, column maps, cast maps, function-name array, +inheritance flags, and grammar into many instance variables. Declared virtual +attributes are copied into a new array for each entity before runtime overlay +attributes are added. + +This field layout makes existing code convenient, but prevents a cheap state +carrier and encourages defensive collection copies. A row should ideally own +one immutable definition reference, one shared runtime-services reference, and +only its mutable entity state. + +### 4. Hydration repeats decisions that belong to a definition and row shape + +Hydration repeatedly resolves row keys against aliases, columns, virtual +attributes, casts, setters, discrimination, and runtime overlays. A correct +plan cannot be keyed by mapping name alone: result shape, overlay version, +child mapping, null behavior, custom casts, and custom setters are observable. + +The first-pass hydration-plan experiment was correctly abandoned because the +current metadata has no stable definition/overlay version. A compact immutable +definition is the prerequisite that makes a bounded hydration plan safe. + +### 5. Builders and relationships combine immutable configuration with mutable state + +Every builder allocates a Quick component plus qb state even though grammar, +formatters, table definition, scopes, and many options are identical for a +mapping. Relationship methods then create related entities/builders and execute +arbitrary user CFML before applying keys and constraints. That flexibility is +part of Quick's API and prevents transparent caching of relationship objects. + +The safe approach is to share immutable query seeds and introduce optional +declarative relationship descriptors. Existing arbitrary relationship methods +remain the fallback. + +## Proposed architecture + +### P0: harden the measurement and compatibility contracts + +Keep the four audit scenarios added in this pass: + +- `metadata.cache_lookup`; +- `metadata.definition_access`; +- `metadata.qualified_columns_cached`; and +- `metadata.cold_compile`. + +Add tests that are intentionally absent from the microbenchmark: + +1. Concurrently request one cold mapping from 32 threads and prove it compiles + exactly once. +2. Fill the derived cache with more than 300 overlay/table variants and prove a + warmed definition does not recompile. +3. Reinitialize and unload the module, then prove definitions and derived views + are cleared. +4. Exercise a custom `metadataCache` name/provider to lock down the existing + configuration contract. +5. Capture same-engine heap/JFR evidence for 10, 100, and 500 mapping + definitions before changing their representation. +6. Split the full-cold scenario into selective definition eviction, cache-clear + control, shared BaseEntity-index rebuild, and ordinary entity construction + so the compile cost is attributable. + +Expected improvement: none directly. This establishes the gates needed to make +and attribute the architectural changes safely. + +### P1: introduce an `EntityDefinitionRegistry` + +Create a Quick-owned singleton registry with single-flight compilation per +mapping. Its core definition map should be process-local and non-evicting until +module reinitialization/unload because the mapping set is bounded by application +code, not requests. + +Separate three lifetimes: + +1. authoritative mapping definitions; +2. bounded derived views owned by a definition; and +3. request/entity runtime overlays. + +Initially, preserve the `metadataCache` setting through a compatibility adapter +and deprecation path rather than silently ignoring custom providers. A provider +may remain useful for legacy/raw metadata or precompiled manifests, but a +remote/distributed cache must not be required for hot definition reads. + +Targets: + +- 65-80% lower definition-lookup wall time; +- 70-90% lower definition-lookup allocation on Lucee and BoxLang; +- 0-3% lower total warmed request time in normal workloads; and +- prevent definition eviction and the cold-path work it triggers; the exact + isolated tail reduction will be set by the Phase 0 split benchmark. + +Confidence is high for the lookup target because bound access is already +73-79% faster than CacheBox access. End-to-end gain is deliberately small +because `newEntity()` already passes a shared definition to child instances. + +### P1: partition and bound derived metadata + +Move qualified columns, discriminations, and future hydration plans under the +owning definition. Use a small bounded LRU or explicit shape limit per mapping; +8-16 variants is a reasonable prototype value, not a final default. Keys must +include the immutable definition version, runtime-overlay version, table/alias, +and other behavior-affecting options. + +When a limit is reached, evict only a derived view. Never evict the owning +definition. Add counters for definition compile, derived hit/miss/eviction, and +current shapes so application profiling can distinguish useful caching from +cardinality explosions. + +Targets: + +- no definition recompilation during a 10,000-variant churn test; +- bounded derived-cache memory per mapping; +- under 3% warmed throughput improvement in typical applications; and +- large tail-latency improvement for high-cardinality table/overlay users. + +### P1: provide immutable internal views + +Keep public methods that promise caller-owned arrays or structs defensive, but +add internal accessors for immutable definition-owned columns, keys, virtual +attributes, and relationship-name sets. `retrieveQualifiedColumns()` currently +allocates a new result array even after the cached calculation is found. + +Targets for cached qualified-column access: + +- 50-75% lower wall time; +- 60-85% lower allocation; and +- 1-5% lower builder/query setup cost where the view is repeatedly consumed. + +### P2: compile a compact immutable `EntityDefinition` + +Normalize engine metadata once into an explicitly versioned structure that +contains only runtime inputs: + +- mapping, full name, entity name, table, key, and inheritance/discriminator; +- attribute-by-alias and attribute-by-column indexes; +- casts, virtual attributes, and non-persistent properties; +- a case-insensitive relationship/member-name set; +- datasource, grammar, soft-delete, read-only, and query defaults; and +- stable fingerprints for definition and derived-plan invalidation. + +Do not retain the complete inherited and local engine reflection documents in +the hot definition. Preserve public `get_Meta()` behavior through a lazily +materialized compatibility sidecar during migration. The sidecar must return +the same keys and preserve current isolation semantics. A later major version +can expose the portable definition directly and deprecate engine-shaped raw +metadata. + +Targets: + +- 40-70% lower retained heap for warmed metadata definitions; +- 3-8% lower entity setup allocation from fewer aliases/copies; and +- stable behavior and definition fingerprints across all supported engines. + +The retained target is an estimate based on the raw document size and must be +validated with heap histograms. Cold compilation allocation will not fall by the +same amount until reflection itself is avoided by the optional manifest phase. + +### P2: split entity definition, state, and runtime services + +Replace the large set of rebound definition variables with three explicit +references: + +1. `EntityDefinition`: immutable mapping behavior; +2. `EntityState`: data, original state, relationships, cast cache, dirty state, + and other per-row mutation; and +3. `EntityRuntime`: shared WireBox/query/interceptor/string services. + +Allocate optional state containers on first use only when the public contract +permits it. This is different from the first pass's piecemeal lazy-container +experiments: the carrier supplies a single ownership boundary and removes +component-variable/map churn together. + +Targets: + +- 15-30% lower entity construction/hydration wall time; +- 20-40% lower thread allocation; +- 10-25% lower retained heap per live entity; and +- no mutable state shared between entity instances. + +This phase is the prerequisite for reconsidering a safe component factory. It +must not reintroduce the state leaks that invalidated shallow duplication. + +### P2: cache bounded hydration plans + +Compile a plan from `(definition version, row shape, runtime overlay version, +child mapping, null mode)` to ordered write operations. A plan can pre-resolve: + +- source column to attribute alias; +- ignored and virtual columns; +- cast and setter dispatch; +- discriminator selection; and +- original-state recording. + +Custom casts and setters remain instance calls. Plan creation must fall back to +the existing path for an uncacheable dynamic overlay or shape. A per-definition +bound prevents arbitrary projections from causing unbounded growth. + +Targets for full hydration: + +- Lucee: 5-10% lower wall, 10-20% lower allocation; +- Adobe ColdFusion: 10-20% lower wall; +- BoxLang: 15-25% lower wall, 25-40% lower allocation; and +- no more than 5% regression on any supported engine/scenario. + +These engine-specific targets reflect the measured map/array allocation cost, +but remain hypotheses until a definition-versioned prototype is benchmarked. + +### P2: split immutable query seeds from builder state + +Compile one `QuerySeed` per definition containing immutable table/grammar, +formatter registry, model defaults, global-scope descriptors, and normalized +options. A new builder allocates only qb's mutable query state plus overrides. +Cloning must retain the current deep isolation of mutable arrays, maps, joins, +unions, eager loads, and callbacks. + +Targets: + +- 20-35% lower builder construction wall time; +- 25-40% lower builder allocation; and +- 10-20% lower relationship construction time as a downstream effect. + +### P3: add opt-in declarative `RelationshipDefinition` values + +Quick cannot safely infer or cache arbitrary CFML relationship methods. Add an +annotation/DSL or generated descriptor for the common declarative cases while +retaining legacy method invocation as the fallback. A definition can share the +related mapping, relation type, local/foreign keys, and static options; each +call still owns its parent, query state, and dynamic constraints. + +Store relationship names as a case-insensitive set instead of scanning the +function-name array. Do not treat every non-BaseEntity method as a relationship +when a descriptor is available. + +Targets for descriptor-backed relationships: + +- 20-40% lower relationship construction wall time; +- 20-45% lower allocation; and +- unchanged behavior for legacy relationship methods. + +### P3: redesign dirty tracking around canonical state writes + +The first pass proved that directly comparing `_data` and original values does +not preserve Quick's missing-key, null, partial-select, save, refresh, and custom +setter semantics. Instead, route canonical state mutations through one writer +that updates a dirty bitset or changed-name set after accessor synchronization. + +`isDirty()` can then read the index while snapshots remain available for APIs +that explicitly request them. Reset, save, refresh, replication, custom setters, +and direct generated-accessor mutation all need contract tests. + +Targets: + +- 70-90% lower clean `isDirty()` wall time; +- 80-95% lower allocation per dirty check; and +- identical missing/null/partial-query semantics on all engines. + +### P3: collaborate with mementifier on compiled projections + +BoxLang's memento path is 571.12 us and 622.53 KiB per operation, far above the +same path on Lucee and Adobe. Much of this behavior belongs to the mementifier +dependency. Propose an immutable compiled projection/configuration API that +Quick can store on `EntityDefinition`, while retaining per-entity mutable public +memento configuration when applications customize it. + +Target on BoxLang: 30-50% lower memento wall time and allocation, with no more +than 5% regression on Lucee or Adobe ColdFusion. + +### P4: add optional precompiled entity manifests + +Provide a build/startup task that emits engine-neutral definitions for known +entity mappings. At runtime, validate a source/configuration fingerprint and +use the manifest; otherwise fall back to reflection compilation. This must be +optional because applications can create mappings dynamically. + +Targets for cold definition creation: + +- 60-85% lower wall time; +- 70-90% lower allocation on engines that expose it; and +- deterministic invalidation when source or Quick's definition schema changes. + +This primarily improves application startup, first-request latency, reinit, and +cache-recovery tails. Its target must be evaluated with the split compile +benchmark, not the current full-cold total, and it should not be sold as a +warmed per-row optimization. + +### P4: reconsider a state-safe entity factory + +Once entity definition, state, and runtime services have explicit ownership, +prototype/factory construction can avoid repeating safe component setup while +allocating fresh mutable state. Use a portable factory interface with an +engine-specific optimized implementation only when the engine can prove +isolation; otherwise use normal WireBox construction. + +Targets: + +- 30-50% lower construction/hydration wall time versus this audit's head; +- 35-55% lower thread allocation; and +- zero shared mutable query, relationship, cast, original-data, or runtime + overlay state. + +The target is supported by the first pass's fast but unsafe shallow-duplicate +prototype. No shallow duplication should ship before all isolation tests pass. + +## Lightweight result boundary + +Quick will keep `asQuery()` as the lightweight result path. It already returns +aliased row data without allocating full entities, lifecycle state, +relationships, dirty tracking, or mementifier configuration. This architecture +work will not add a second record or DTO result type. + +Applying entity casts to `asQuery()` is explicitly deferred. Custom casts can +depend on an entity instance and user-defined caster behavior, so cast-aware raw +results need a separate API and contract rather than being coupled to the +entity-hydration redesign. + +## Delivery sequence + +### Phase 0: contracts and observability + +- Merge the metadata benchmark scenarios and Adobe-compatible selector logic. +- Decompose full hydration into construction, existing-entity row binding, + post-load lifecycle work, and 10/100/1,000-entity batch behavior. +- Split full-cache cold compilation from selective mapping eviction and trivial + CacheBox mutation overhead. +- Add concurrent compile, cache churn, reinit, custom provider, and heap tests. +- Capture profiler baselines for 10, 100, and 500 mappings. + +Exit gate: reproducible metrics and tests fail against the known shared-cache +eviction behavior. + +### Phase 1: registry and cache separation + +- Add `EntityDefinitionRegistry` behind existing entity construction. +- Partition derived views and add bounded cardinality/metrics. +- Add immutable internal collection views. + +Exit gate: no recompile under churn, compile-once concurrency, clean reinit, +and no engine regression over 5%. + +### Phase 2: portable definition and hydration/query plans + +- Introduce `EntityDefinition` plus lazy legacy metadata compatibility view. +- Version definitions and overlays. +- Add bounded hydration plans and immutable query seeds. + +Exit gate: public metadata/entity behavior is unchanged; at least one primary +engine meets the target and no supported engine regresses over 5%. + +### Phase 3: state carrier and declarative extensions + +- Move mutable row state into `EntityState`. +- Add descriptor-backed relationships with legacy fallback. +- Introduce canonical dirty-state tracking. +- Prototype mementifier compiled projections with the dependency owner. + +Exit gate: isolation, lifecycle, null, partial selection, custom cast/setter, +inheritance, and relationship suites are green across engines. + +### Phase 4: startup and factory ceiling + +- Add optional entity manifests. +- Prototype the state-safe entity factory behind an opt-in feature flag. +- Run real-application load and heap tests before considering a default change. + +Exit gate: measured construction/hydration gain of at least 20%, allocation +gain of at least 25%, no state leak, and no engine regression over 5%. + +## Compatibility and acceptance gates + +Every retained change must preserve: + +- the public `get_Meta()` shape and non-mutation behavior; +- simple and structured custom `metadataCache` configuration; +- module reinit and unload clearing semantics; +- persistent runtime attributes, virtual attributes, table aliases, and query + overrides; +- inheritance, discrimination, composite keys, and null support modes; +- custom casts, generated and custom setters, lifecycle events, and mementos; +- builder clone isolation and relationship method flexibility; and +- Lucee 5/6, Adobe ColdFusion 2021/2023/2025, BoxLang native, and BoxLang CFML + compatibility behavior covered by Quick's CI matrix. + +Performance acceptance for each candidate: + +1. Use five warmed runs and compare the median of medians on the same engine, + JVM, database, heap, and machine. +2. Require at least 10% wall improvement, or a smaller repeatable wall change + corroborated by a material allocation reduction. +3. Reject a candidate that regresses any supported engine's affected scenario + by more than 5% unless the product contract explicitly accepts the tradeoff. +4. Confirm retained-memory claims with a heap profiler. +5. Run the complete functional suite on every supported engine family before a + production commit is accepted. + +## Expected aggregate outcomes + +Do not sum the phase targets; several improvements remove the same work. + +| Architecture reached | Expected entity-heavy wall reduction | Expected allocation reduction | Main benefit | +| --- | ---: | ---: | --- | +| Registry and cache partition | 0-3% warmed median | 0-5% warmed allocation | Prevents definition eviction, removes associated cold tails, and bounds cache growth | +| Compact definition and immutable views | 3-10% in definition-heavy query setup | 10-25% in affected setup paths; 40-70% metadata retained heap | Smaller, portable metadata model | +| Hydration plan, query seed, relationship descriptors | 15-30% in entity-heavy workloads | 20-40% | Reuses mapping/shape decisions | +| State carrier and state-safe factory | 30-50% construction/hydration | 35-55% | Removes repeated component/state setup | + +For an application request, multiply the relevant Quick-path improvement by +the fraction of request time and allocation actually attributable to Quick. +Database-bound requests will see a smaller wall-time percentage; large hydrated +result sets and serialization-heavy endpoints should see a larger allocation +and garbage-collection benefit. + +## Recommendation + +Start with `EntityDefinitionRegistry` and cache partitioning, not the factory. +It repairs a correctness-of-lifetime problem, removes cold-tail risk, and +creates the versioned immutable boundary required by every higher-value idea. +Then implement the compact definition and prove the lazy legacy metadata view. +Only after that should Quick retry hydration plans, query seeds, dirty-state +indexes, or state-safe construction. + +The immediate measurable win will be modest in warmed medians, but it changes +the architecture from “engine reflection cached beside request variants” to +“portable immutable definitions with bounded derived plans.” That is the +foundation needed to pursue the credible 30-50% construction/hydration ceiling +without weakening Quick's public behavior. diff --git a/docs/performance-architecture-results.md b/docs/performance-architecture-results.md new file mode 100644 index 00000000..4306ba51 --- /dev/null +++ b/docs/performance-architecture-results.md @@ -0,0 +1,228 @@ +# Performance architecture execution results + +This document records the disposition of each candidate from +`performance-architecture-plan.md`. Production changes are retained only after +cross-engine functional verification and repeatable benchmark improvement. + +## Phase 1: registry and cache separation + +Status: accepted. + +Quick entity definitions now live in a process-local, non-evicting +`EntityDefinitionRegistry`. Qualified-column and discrimination views use +separate bounded buckets, so request-shaped variants cannot evict authoritative +mapping definitions. Compilation is single-flight and the registry is cleared +with the module lifecycle. + +The configured CacheBox cache is still created and cleared for configuration +compatibility, but it is no longer the authoritative entity-definition store. +This deliberately avoids treating a remote cache as a safe cross-deployment +definition cache; entity source and configuration changes have no portable +version contract yet. + +Median of seven warmed runs for the affected operations: + +| Runtime | Registry lookup vs CacheBox wall | Registry lookup allocation | Cached qualified columns wall | Cached qualified columns allocation | +| --- | ---: | ---: | ---: | ---: | +| Lucee 6 | -51.6% | -52.2% | -4.9% | -1.1% | +| Adobe ColdFusion 2021 | -53.8% | unavailable | -22.5% | unavailable | +| BoxLang | -61.6% | -56.5% | -5.9% | -3.8% | + +An initial implementation passed the registry through every `init()` call. It +was abandoned after the performance gate measured hydration regressions of +46.5% on Adobe ColdFusion and 28.4% on BoxLang. The accepted implementation +resolves the registry lazily only for definition and derived-view work. A second +derived-view prototype allocated a closure on every cache hit; it was also +abandoned after Adobe's qualified-column benchmark regressed by 16.5%. The +accepted lookup-first implementation produced the improvements above. + +Functional gate: + +- Lucee 6: 621 passed, 0 failed, 0 errors. +- Adobe ColdFusion 2021: 620 passed, 0 failed, 0 errors. +- BoxLang: 622 passed, 0 failed, 0 errors. + +### Bounded row-shape hydration plans + +Status: abandoned. + +A prototype cached ordered `[source, alias, column]` operations by mapping and +row shape. It improved existing-entity binding by 14.0-26.7%, but a fresh +entity had to resolve the registry through WireBox before using the plan. +BoxLang full hydration regressed 10.3% for one entity and 10.1% for a batch of +100, with about 2% more allocation. The prototype was reverted. + +Revisit this only after entities receive a cheap definition-owned plan reference +without adding constructor arguments. Passing registry state through every +`init()` was already rejected in Phase 1 for larger cross-engine regressions. + +### Immutable query seed + +Status: abandoned. + +A safe first slice let `BaseEntity.newQuery()` consume the shared qualified +column array directly while preserving the public defensive-copy behavior. +Builder construction changed between +1.0% and -3.2%, with less than 1% lower +allocation. Relationship construction regressed 5.5% on BoxLang, so the slice +did not meet the gate and was reverted. + +The remaining seed inputs belong largely to qb's mutable `QueryBuilder` state. +Sharing them from Quick without a qb-supported immutable seed/snapshot boundary +would risk clone and query-state isolation for little demonstrated gain. + +## Phase 3: state and declarative extensions + +### Explicit `EntityState` carrier + +Status: abandoned for this compatibility line. + +The current mutable fields are observable through generated accessors and are +used across reset, clone, replication, save, relationship, cast, and query +flows. Wrapping the same maps in another struct would add indirection without +removing component allocation. The earlier isolated lazy-container prototype +confirmed this: wide construction regressed 2.6% and allocation was unchanged. +A useful carrier must land together with a compact definition and a prepared +factory in a major internal rewrite; the compact-definition prerequisite was +not met in this pass. + +### Declarative relationship definitions + +Status: abandoned as a transparent optimization. + +Existing relationship methods are arbitrary user CFML: they accept arguments, +run conditional code, return custom subclasses, and mutate constraints. Quick +cannot replace that invocation with a descriptor without introducing a new +opt-in public DSL and maintaining the method path as fallback. That can be a +separate feature proposal, but it is not a behavior-preserving performance +change for this branch. + +### Indexed dirty tracking + +Status: abandoned. + +The earlier direct-state prototype failed ten functional tests covering +partial selections, nulls, excluded attributes, saves, and refreshes. Current +hash comparison preserves missing-key state that a simple changed-name set +does not. The accepted single-pass assignment work already improves clean dirty +checks; a replacement still requires a canonical write model across generated +and custom setters before it can be timed safely. + +### Compiled memento projections + +Status: abandoned pending a mementifier API. + +Mementifier owns its mutable public configuration and date formatters. Removing +eager setup improved wide construction only 3.1% and narrow construction 0.3%, +then broke `getMemento()`. Sharing the mutable configuration would leak changes +between entities. Quick should revisit this only if mementifier exposes an +immutable compiled projection/configuration boundary. + +## Phase 4: startup and construction ceiling + +### Precompiled entity manifests + +Status: abandoned until a portable definition contract exists. + +A manifest can safely contain only the normalized definition. The current +public metadata contract also exposes engine-specific inherited/local +reflection graphs, and Phase 2 could not remove them. Emitting those graphs +would make a manifest engine-specific and stale across engine or source +changes; omitting them would change `get_Meta()` and lifecycle payloads. A +manifest tool therefore has no safe value until the compact-definition/public +metadata boundary is versioned. + +### State-safe prepared entity factory + +Status: abandoned. + +The earlier shallow-prototype measurement demonstrated the performance ceiling +but also shared relationship-constraint, cast-cache, and query-option state +between entities. The new registry fixes definition lifetime, not mutable +component ownership. Because the explicit state carrier was not accepted, the +factory still cannot prove isolation and no unsafe factory code is retained. + +## Lightweight result boundary + +`asQuery()` remains Quick's lightweight fallback and continues applying column +aliases without allocating entities. No DTO/record result type was added. +Applying casts to `asQuery()` remains intentionally deferred because custom +casts can require an entity instance and need a separate public contract. + +## Final cross-engine measurements + +The final committed production head was measured with five complete warmed +runs per engine, 10 warmup iterations, 11 samples, 30 iterations per sample, +and 1,000 database rows. Values below are median-of-run-medians in microseconds +per logical operation (database values are per row). All fifteen runs completed +with zero benchmark errors. + +| Scenario | Lucee 6.2.2 | ACF 2021.0.22 | BoxLang 1.16.0 | +| --- | ---: | ---: | ---: | +| Entity construction | 298.73 us | 473.48 us | 440.24 us | +| Full entity hydration | 416.56 us | 600.56 us | 543.37 us | +| Existing-entity row binding | 44.39 us | 107.36 us | 106.60 us | +| Batch-100 hydration, per entity | 335.44 us | 594.11 us | 553.19 us | +| Registry definition lookup | 1.01 us | 2.93 us | 1.70 us | +| Cached qualified columns | 7.68 us | 10.90 us | 14.84 us | +| Builder construction | 714.60 us | 1,505.78 us | 1,532.91 us | +| `hasMany` construction | 2,322.54 us | 3,989.17 us | 3,844.40 us | +| Database raw row | 3.38 us | 4.20 us | 6.09 us | +| Database hydrated row | 282.05 us | 486.42 us | 390.80 us | + +Final thread-allocation medians were 33.35 KiB for Lucee row binding and +254.26 KiB for BoxLang row binding. Full hydration allocated 367.73 KiB on +Lucee and 853.59 KiB on BoxLang. Adobe ColdFusion did not expose the JVM thread +allocation counter. Retained-heap reduction is not claimed: raw metadata remains +part of the public compatibility shape. Memory improvements in this pass are +allocation reductions and bounded derived-cache cardinality. + +Final functional verification: + +- Lucee 6.2.2.91: 621 passed, 0 failed, 0 errors, 3 skipped. +- Adobe ColdFusion 2021.0.22: 620 passed, 0 failed, 0 errors, 4 skipped. +- BoxLang 1.16.0+57: 622 passed, 0 failed, 0 errors, 2 skipped. +- Concurrent access compiles a definition once. +- Derived churn remains bounded without evicting its owning definition. +- Clearing CacheBox does not force definition recompilation. + +## Phase 2: metadata representation and hydration + +### Compact metadata definition + +Status: abandoned for this compatibility line. + +Quick's public `get_Meta()` contract exposes the raw inherited and local engine +metadata graphs. The same value is included in the public `preLoad` lifecycle +event. Discarding those graphs would either change observable behavior or force +reflection and compatibility reconstruction during ordinary queries, trading +retained memory for unpredictable hot-path latency. A portable compact +definition should therefore be introduced with an explicit public metadata +contract in a future major version, not hidden behind the current accessor. + +### Single-pass row binding + +Status: accepted. + +Row binding previously resolved every source key through `hasAttribute()`, +then repeated alias and column resolution while casting and assigning it. The +accepted path resolves the attribute definition once and reuses its canonical +name and column. + +Median of five warmed runs: + +| Runtime | Existing-entity bind wall | Existing-entity bind allocation | Single hydrate wall | Batch 10 wall | Batch 100 wall | Batch 1,000 wall | +| --- | ---: | ---: | ---: | ---: | ---: | ---: | +| Lucee 6 | -52.4% | -51.9% | -19.7% | -24.6% | -0.5% | -20.6% | +| Adobe ColdFusion 2021 | -39.1% | unavailable | +4.0% | -8.3% | -2.1% | -20.9% | +| BoxLang | -43.6% | -40.6% | -32.5% | -32.7% | -19.9% | -11.8% | + +Lucee hydration allocation fell 9.0% at every measured batch size. BoxLang +hydration allocation fell 16.9%. Adobe's one-entity wall result is within the +5% regression gate and changes to an improvement as soon as the path handles a +batch; its 1,000-row result improves 20.9%. + +Functional gate: + +- Lucee 6: 621 passed, 0 failed, 0 errors. +- Adobe ColdFusion 2021: 620 passed, 0 failed, 0 errors. +- BoxLang: 622 passed, 0 failed, 0 errors. diff --git a/docs/performance-improvement-plan.md b/docs/performance-improvement-plan.md new file mode 100644 index 00000000..78039eff --- /dev/null +++ b/docs/performance-improvement-plan.md @@ -0,0 +1,550 @@ +# Quick performance improvement plan + +## Status and scope + +- Branch: `codex/quick-performance-audit` +- Baseline: `next` at `fbcf9687d0048f0fc222a4dcb26513720b479fc8` +- Audit date: 2026-08-28 +- Baseline runtime: Lucee 6.2.8.20, Java 21.0.10, Apple Silicon, local MySQL +- Compatibility smoke: BoxLang 1.17.0+58 on Java 21.0.12.1 + +This branch adds a repeatable performance framework and this plan. It does not +retain any production optimization experiments. Expected percentages below are +wall-time reductions, allocation reductions, or retained-heap reductions for +the named operation. They are not additive and are not promises for an entire +application request. + +## Executive summary + +Quick's dominant local cost is entity construction and the map, function, and +string allocation it triggers in the CFML engine. A warmed, wide `User` entity +allocated about 338 KB before hydration. A local 1,000-row query allocated about +1.8 KB per raw row versus 302 KB per hydrated row. Because the database is +local, hydrated rows were about 92 times slower than raw rows; production +database latency will reduce that ratio, but not the memory pressure. + +The most actionable findings are: + +1. A duplicate cast and repeated alias/column resolution in `assignAttribute()` + costs 14-18% in attribute-state operations. A temporary implementation + measured 18.4% lower assignment time and 21.5% lower assignment allocation. +2. `retrieveAttributesData()` performs a full accessor synchronization and + state copy. It accounts for 93.5% of a clean `isDirty()` call's time and + 93.9% of its allocation. +3. `QuickBuilder.getEntities()` clones the configured query for every hydrated + result set, even when no virtual projection needs it. Deferring that clone + reduced a warmed one-row hydrated query by 22.4% and its allocation by 20.3% + in a temporary experiment. The gain is below 1% for a 1,000-row result because + the clone occurs once per result set. +4. Normal post-DI setup adds 18.9% wall time over Quick's internal shallow + construction boundary for a narrow entity. Deferring memento and no-listener + lifecycle state until first use should recover 10-16% of entity construction + time for entities that are never serialized. +5. Lucee shallow component duplication demonstrates a much larger ceiling: + 36.6-48.2% lower construction time and 51.3% lower allocation. It is not safe + today because mutable instance caches and options leak between the prototype + and clone. A state-safe factory is the highest-upside, highest-risk project. + +A reasonable target for the low- and medium-risk work is 15-30% less Quick CPU +time and 15-30% less allocation in entity-heavy workloads. A state-safe +instantiation redesign could move the total to 35-50% less entity construction +and hydration time. End-to-end application improvement depends on the fraction +of request time spent in Quick. + +## Performance framework + +The opt-in suite is under `tests/performance` and is deliberately separate from +the functional TestBox suite. + +It provides: + +- untimed warmup followed by multiple measured samples; +- median, p95, standard deviation, and operations per second; +- current-thread CPU time and allocated bytes through JVM management beans; +- directional post-GC retained-heap probes with a reference-array control; +- JSON output with runtime and configuration metadata; +- raw-versus-hydrated database measurements with fixture work outside the timed + region and transaction rollback; +- a comparison task with separate wall-time, allocation, and retained-memory + regression thresholds; and +- 14 CPU/object scenarios, 2 optional database scenarios, and 5 retained-memory + scenarios. + +Run it against an initialized Quick test server with: + +```bash +box run-script performance +``` + +Compare two results with: + +```bash +box task run taskFile=tests/performance/Compare.cfc \ + :baseline=tests/results/baseline.json \ + :candidate=tests/results/candidate.json \ + :maxWallRegressionPercent=10 \ + :maxAllocationRegressionPercent=10 \ + :maxRetainedRegressionPercent=10 +``` + +The old `EntityCreationSpec.cfc` mixed fixture setup into two single timings and +printed them during the functional suite. It is replaced by the opt-in harness +so performance results do not make normal tests slow or flaky. + +### Measurement rules + +1. Compare the same engine, engine version, JVM, heap, database, power mode, and + machine. +2. Run each revision at least twice in a fresh server process and discard the + first run. For a claimed improvement, use at least five warmed runs and + report the median of their medians. +3. Treat wall-time changes below 10% as noise unless CPU and allocation evidence + agree across runs. Allocation counts were more repeatable than wall time in + this audit. +4. Confirm retained-heap movement with JFR or another heap profiler. The harness + estimate is directional, not an object-size API. +5. Keep profiler runs separate from timing runs. JFR sampling materially slowed + these microbenchmarks, so its timing output was not used. + +## Current baseline + +These are warmed medians from the second expanded baseline. Allocation is bytes +on the benchmark thread per logical operation. + +| Scenario | Median wall time | Allocated bytes | +| --- | ---: | ---: | +| Wide entity construction | 361.04 us/entity | 338,021/entity | +| Narrow entity construction | 285.96 us/entity | 290,133/entity | +| Narrow internal shallow boundary | 240.56 us/entity | 273,741/entity | +| Wide entity construction plus hydration | 432.38 us/entity | 405,693/entity | +| Batch hydration, 100 wide entities | 356.81 us/entity | 404,613/entity | +| Attribute read | 13.82 us/read | 11,936/read | +| Attribute assignment | 10.00 us/write | 8,301/write | +| Attribute-state snapshot | 566.50 us/snapshot | 496,185/snapshot | +| Clean `isDirty()` | 605.88 us/check | 528,353/check | +| Default memento | 151.69 us/memento | 108,163/memento | +| Builder construction | 678.11 us/builder | 622,013/builder | +| Configured builder clone | 806.78 us/clone | 776,021/clone | +| Builder plus common SQL composition | 1,151.11 us/query | 876,597/query | +| `hasMany` relationship construction | 2,234.38 us/relation | 1,615,485/relation | +| Local database raw result, 1,000 rows | 3.19 us/row | 1,845/row | +| Local database hydrated result, 1,000 rows | 292.82 us/row | 302,273/row | + +Directional retained-heap medians were: + +| Live object | Approximate retained bytes | +| --- | ---: | +| Unloaded wide entity | 159,977 | +| Hydrated wide entity | 161,811 | +| Normally initialized narrow entity | 134,043 | +| Internal shallow narrow entity | 127,568 | +| Builder | 172,762 | + +One unloaded-entity retained sample was an outlier. The median is shown, and +allocation/JFR evidence should drive decisions before retained-heap estimates. + +## Profiler findings + +A 30-second JFR profile recorded 4,970 allocation samples and 64 garbage +collections. One implausible request-cleanup allocation sample above 10 MB was +excluded from the weighted allocation summary. + +The leading sampled allocation types were `byte[]` (9.85%), Lucee concurrent-map +entries (8.62%), concurrent-map entry arrays (8.01%), `Object[]` (7.35%), +`String` (6.74%), `HashMap.Node` (5.92%), and `LinkedHashMap.Entry` (4.97%). Stack +traces repeatedly reached `BaseEntity` and concrete entity component +initialization, property accessor creation, `ComponentImpl.registerUDF`, and +mementifier interception. This agrees with the harness: collection and function +metadata churn, not the row copy alone, dominates hydration. + +## Prioritized improvements + +The percentages in this table are scoped to the affected operation. “Measured” +means a temporary implementation or an existing decomposition produced the +number. “Target” is the acceptance range for a future implementation. + +| Priority | Improvement | Expected wall-time reduction | Expected memory reduction | Evidence and confidence | +| --- | --- | ---: | ---: | --- | +| P0 | Cast once and resolve alias/column once in `assignAttribute()` | 14-18% for reads, writes, snapshots, and dirty checks | 14-21% allocated bytes for those operations | Measured; high | +| P0 | Lazily clone the refresh query only when returned rows contain virtual data | 15-25% for one-row/tiny hydrated results; less than 1% at 1,000 rows | 15-22% allocation for one-row/tiny results; less than 1% at 1,000 rows | Measured; high | +| P0 | Replace full snapshot/sort/hash dirty checks with direct, metadata-ordered state comparison | 55-75% for clean `isDirty()` | 60-80% allocation per check | Snapshot is 93.5% of wall and 93.9% of allocation; medium-high | +| P0 | Skip the transformation array copy when no entity transformers or memento conversion are configured | 0-3% for large result handling | Less than 1% in entity-heavy results | Static path analysis; medium | +| P1 | Lazily prepare memento and no-listener lifecycle state | 10-16% entity construction when no memento is requested | 5-6% allocation and 4-5% retained heap per narrow entity | Full versus internal shallow boundary; medium-high | +| P1 | Cache a hydration plan per entity metadata/result shape | 5-12% entity hydration | 5-10% hydration allocation | Repeated `hasAttribute`, alias, column, and cast resolution; medium | +| P1 | Remove dead per-entity state and copy-on-write lazy containers | 2-5% entity construction | 1-4% allocation and retained heap | Static analysis; medium | +| P1 | Pre-normalize and share immutable memento defaults; lazily create date formatters | 15-30% per `getMemento()` | 15-30% allocation per memento | 108 KB/memento plus mementifier source analysis; medium | +| P1 | Lazily allocate builder arrays/maps and cache normalized eager-load graphs | 3-8% builder setup; 10-30% of eager-load setup | 2-8% builder allocation | Static analysis against 622 KB builders; medium-low | +| P2 | Avoid constructing a related entity, builder, and relationship when only an unloaded default/capability is needed | 20-40% for default relationship initialization; 5-15% for normal relationship construction | 10-25% for those paths | 2.23 ms and 1.62 MB per `hasMany`; medium-low | +| P2 | Add a lazy materialized index for deep runtime-attribute overlay chains | 20-50% for lookups at overlay depth above five; no expected default-path gain | 10-30% for repeated deep overlay lookups | Complexity analysis; low until a deep-overlay benchmark is added | +| P3 | Introduce a state-safe entity factory/prototype architecture | 30-45% entity construction and hydration | 35-50% allocation; target 15-30% retained heap | Unsafe prototype measured 36.6-48.2% wall and 51.3% allocation; high upside, high risk | + +### P0: remove redundant work + +#### 1. Single-pass attribute assignment + +`assignAttribute()` currently resolves the alias and column repeatedly and calls +`castValueForSetter()` twice for a normal value. The experimental single-pass +version measured: + +| Operation | Wall-time change | Allocation change | +| --- | ---: | ---: | +| Attribute read | -14.5% | -14.9% | +| Attribute assignment | -18.4% | -21.5% | +| Attribute snapshot | -14.4% | -14.8% | +| Clean dirty check | -14.1% | -13.9% | + +Implement this first because it is small and reduces the cost of later dirty +tracking work. Preserve null handling, cast-cache semantics, loaded-key guards, +Quick-entity key extraction, custom setters, and runtime attributes. + +#### 2. Lazy refresh-query cloning + +`getEntities()` currently clones `variables.qb` before it knows whether a row +contains a virtual projection. `loadEntity()` only stores that clone for virtual +data. Detect the projection from the consistent result shape, clone at most once, +and pass no refresh query otherwise. + +For a one-row local query, the second experimental run changed: + +- total hydrated wall time: 2.176 ms to 1.688 ms (-22.4%); +- total allocation: 1,189,616 to 948,096 bytes (-20.3%); +- hydration surcharge over the matching raw query: -57.4% wall and -43.9% + allocation. + +Keep clone behavior unchanged for virtual attributes, discriminated children, +`fresh()`, and `refresh()`. + +#### 3. Direct dirty comparison + +`isDirty()` calls `retrieveAttributesData()`, which synchronizes every generated +accessor by routing it through `assignAttribute()`, builds a new struct, sorts +keys, builds a string, and hashes it. The same broad work happens for a +single-attribute dirty check. + +Build and cache a metadata-ordered comparison plan. Read each current value from +the accessor-backed variables scope when present, otherwise from `_data`, and +compare its null/value state with `_originalAttributes` without materializing a +full output struct. Cache the original normalized comparison state at hydration +and update or invalidate it only at existing state-transition points. + +Do not introduce a dirty set that misses direct generated setter calls. Tests +must cover direct setters, custom setters/getters, casts, nulls, aliases, reset, +replicate, refresh, save, composite keys, and runtime attributes. + +### P1: make optional state genuinely optional + +#### 4. Lazy memento and event preparation + +Every normal entity builds memento defaults from all attributes. Mementifier then +injects helper UDFs and creates two `SimpleDateFormat` instances per decorated +entity. Many entities are never serialized. + +Prototype a Quick-owned lazy gateway that preserves `getMemento()` and custom +memento overrides while deferring normalized defaults and formatters until the +first serialization. Cache immutable defaults in entity metadata; copy only +when instance-level memento configuration is mutated. Separately verify whether +the interceptor service can expose a safe no-listener fast path. Never cache a +negative listener result if listeners can be registered dynamically. + +The narrow full-versus-shallow boundary supports a target of 10-16% lower +construction time, 5-6% lower allocation, and 4-5% lower retained heap when no +memento is requested. First-use serialization may shift, rather than erase, some +of this cost and must remain within 5% of the current memento benchmark. + +#### 5. Lazy/copy-on-write instance containers + +Audit `assignDefaultProperties()` and `QuickBuilder.init()` field by field: + +- `_globalScopeExclusions`, `_applyingGlobalScopes`, and + `_globalScopesApplied` are unused on `BaseEntity`; +- `_nullValueArgumentSentinel` can be replaced with `arguments.keyExists()`; +- `_withoutRelationshipConstraints` eagerly creates a Java `HashSet`; +- relationship data, relationship-loaded flags, cast caches, caster caches, and + runtime overlay containers start empty for every entity; +- several empty metadata containers are immediately replaced by cached metadata; +- `_virtualAttributes` copies the declared metadata array even when the entity + never adds a runtime virtual attribute; and +- builders eagerly allocate eager-load, transformer, memento-settings, + global-scope, and alias containers plus a fallback lazy-loading closure. + +Use explicit lazy getters and copy-on-write before mutation. Do not share mutable +empty structs or arrays. Generated accessors and existing introspection methods +must continue to return the documented empty value. + +#### 6. Cached hydration plans + +`populateAttributes()` performs repeated attribute existence, alias, column, and +cast lookup for every row. Compile an immutable plan from cached entity metadata +and the result column shape, then apply it to each entity in the caller thread. +Invalidate only when a runtime attribute overlay changes the effective shape. + +This should be developed after the single-pass setter work. Target 5-12% lower +hydration time and 5-10% lower hydration allocation without changing event, +cast, null, or original-state behavior. + +### P2: reduce relationship and eager-load setup + +`hasMany()` construction currently creates the related entity, its builder, the +relationship component, and constraints. Cache immutable relationship +capabilities in entity metadata so new-entity default initialization can return +the correct empty collection/null value without constructing the query graph. +Create the builder on the first query-mutating or execution method. + +Also cache `denestEagerLoads()` output until `_eagerLoad` changes. Add dedicated +benchmarks for one relation, multiple independent relations, nested relations, +parameter-limit chunking, raw mode, memento mode, and empty results before +claiming an end-to-end gain. + +The opt-in parallel eager-loading work exists on a separate remote feature +branch, not on `next`. Benchmark it independently after hydration allocation is +reduced. Parallel I/O can improve wall time for independent, latency-bound +relations but can increase peak memory and database pressure; it is not a +substitute for the allocation work in this plan. + +### P3: state-safe entity instantiation + +A temporary Lucee experiment used `duplicate( prototype, false ).resetToNew()`. +It was 36.6-48.2% faster and allocated 51.3% less than WireBox construction. +Simple attribute data was isolated, but the relationship-constraint set, cast +cache, and query options were shared. That reproduces the class of correctness +problem that caused Quick's earlier shallow-duplicate factory to be removed. + +Do not restore shallow duplication directly. First define every field as one of: + +- immutable mapping metadata that may be shared; +- dependency/function state that may be shared only if the engine guarantees it; +- mutable instance state that must be newly allocated; or +- optional mutable state that starts absent and is allocated on first mutation. + +Then prototype either: + +1. an engine-specific factory that shallow-copies immutable component structure + and installs a fresh explicit state carrier, or +2. a broader entity-definition/entity-state split that keeps mapping and + function metadata out of each row object. + +The acceptance target is 30-45% lower construction/hydration wall time and +35-50% lower allocation while proving no cross-instance state leaks. Keep the +WireBox path as the fallback on engines where the optimized factory is not +provably safe. + +## Delivery sequence + +Use small PRs so each percentage can be accepted or rejected independently: + +1. Benchmark framework and audit plan. +2. Single-pass attribute assignment and lookup. +3. Direct dirty comparison. +4. Lazy refresh-query clone and no-op result transformation. +5. Lazy memento/lifecycle preparation. +6. Lazy/copy-on-write entity and builder containers. +7. Cached hydration plan. +8. Relationship capability metadata and lazy builders. +9. State-safe entity factory prototype behind an internal feature flag. + +Each PR should include its focused regression tests, before/after JSON from the +same runtime, at least five warmed measurements, allocation evidence, and the +full functional matrix result. Revert an optimization whose gain does not clear +the noise threshold unless it materially simplifies memory behavior. + +## Verification matrix and gates + +Functional behavior must remain identical across: + +- Lucee 5 and 6; +- Adobe ColdFusion 2021, 2023, and 2025; +- BoxLang 1 in native and CFML compatibility modes; +- full-null-support on and off; +- inherited/discriminated entities and composite keys; +- declared and runtime attributes, aliases, all cast types, and null values; +- custom getters/setters and direct generated accessors; +- lifecycle methods, Quick interception points, custom dispatched events, and + `withoutFiringEvents()`; +- default and overridden mementos, profiles, nested relationships, date masks, + and timezones; +- refresh queries with virtual projections; +- new, loaded, replicated, reset, saved, refreshed, and deleted entities; and +- new-entity relationship defaults, eager loading, lazy-loading prevention, and + relationship-loaded hooks. + +Performance acceptance gates: + +- no functional TestBox failures; +- no public API or serialized-shape change; +- no cross-instance mutable-state leak; +- no greater than 5% regression in an unaffected core benchmark; +- claimed wall-time gain above 10% across warmed runs, or corroborating CPU and + allocation evidence for a smaller hot-path gain; +- no allocation or retained-memory regression unless explicitly justified; and +- a follow-up JFR showing no new high-pressure allocation class or increased GC + frequency for the same workload. + +A future CI performance job should use a pinned, dedicated runner and preserve +the JSON artifacts. Shared hosted runners are suitable for smoke execution, not +hard wall-time gates. + +## Expected aggregate impact + +Do not sum the individual rows. They overlap heavily. + +- P0 only: target 10-20% less Quick CPU and allocation in state-heavy code, with + up to 25% lower latency for tiny hydrated result sets. +- P0 plus P1: target 15-30% less entity-heavy wall time and allocation, and + 5-15% less retained heap depending on memento, casts, and relationships. +- With a successful P3 factory: target 35-50% less entity construction/hydration + time and 35-55% less allocation. A local 1,000-row hydrated query should target + 25-45% lower total wall time. + +For an application request, apply Amdahl's law. If Quick accounts for 40% of the +request and the affected Quick work becomes 30% faster, the expected request +improvement is about 12%, not 30%. + +## Evaluation log + +### 2026-08-28: single-pass attribute assignment — accepted + +Five warmed Lucee 6.2.8.20 runs compared the branch baseline with the candidate +using 10 warmups, 15 samples, 50 iterations, and the median of run medians. + +| Scenario | Wall-time change | Allocation change | +| --- | ---: | ---: | +| Attribute assignment | -20.66% | -21.65% | +| Attribute read | -16.13% | -15.01% | +| Attribute snapshot | -15.42% | -15.04% | +| Clean `isDirty()` | -14.43% | -14.15% | + +The full Lucee suite passed with 616 passes, 0 failures, 0 errors, and 3 skips. +The implementation was retained because it clears both the wall-time and +allocation gates while preserving entity-key assignment behavior. + +### 2026-08-28: lazy refresh-query clone — accepted + +Five warmed runs measured database hydration with one row and 1,000 rows. The +one-row median fell from 2.700 ms to 1.937 ms (-28.26%) and allocation fell from +1,186,120 to 947,168 bytes (-20.15%). At 1,000 rows, per-row time fell 5.03% +while allocation was effectively unchanged (-0.08%), confirming that the +removed clone is a fixed per-result-set cost. The existing `loadEntity()` +virtual-data guard remains responsible for cloning when refresh state is +actually required. + +### 2026-08-28: direct dirty comparison — abandoned + +A prototype compared `_data` directly with `_originalAttributes` after syncing +generated accessors. It caused 10 Lucee suite failures involving partially +selected, null, excluded, saved, and refreshed state. The hash path's exact key +presence semantics are part of current behavior, so the prototype was reverted +without a commit. A future attempt needs a canonical metadata-ordered state +model plus explicit cross-engine null/missing-value tests before timing. + +### 2026-08-28: no-op transformation copy — abandoned + +Skipping the result-array copy when no entity transformers were configured did +not clear the gate. Across five warmed 1,000-row runs, hydrated time changed +from 265.502 to 268.957 us/row (+1.30%) and allocation fell only 0.16%. The +candidate was reverted without a commit. + +### 2026-08-28: lazy relationship-constraint container — abandoned + +Deferring the per-entity `HashSet` was measured across five warmed wide and +narrow construction runs. Wide construction regressed 2.57% with 0.05% more +allocation; narrow construction improved only 0.50% with allocation unchanged. +The extra branches did not recover measurable memory, so the candidate was +reverted without a commit. + +### 2026-08-28: lazy deep runtime-attribute index — accepted + +A new benchmark resolves the oldest attribute in a ten-node runtime overlay. +Across five warmed runs, median lookup time fell from 4.709 to 2.202 us +(-53.23%) and allocation fell from 4,112 to 2,064 bytes (-49.80%). The declared +attribute-read control changed by +1.13% with allocation unchanged. The index +is per entity, appears only after traversal reaches six nodes, and is invalidated +when another runtime attribute is registered. The full Lucee suite passed with +617 passes, 0 failures, 0 errors, and 3 skips. + +### 2026-08-28: lazy memento/lifecycle preparation — abandoned + +A prototype omitted eager mementifier setup. Across five warmed runs, wide +construction improved 3.13% with 2.86% less allocation; narrow construction +improved only 0.29% with 4.30% less allocation. It missed the wall-time gate and +immediately broke `getMemento()` because the injected mementifier expects the +public `this.memento` configuration to exist. `instanceReady` also remains an +observable lifecycle contract and cannot be deferred. The prototype was +reverted without a commit. + +### 2026-08-28: cached hydration plan — abandoned on this branch + +The proposed plan would need to cache alias, column, virtual, cast, and setter +decisions by both entity metadata version and result shape. Quick entities can +add runtime attributes after construction, child discrimination can select a +different mapping per row, and custom casts/setters remain instance behavior. +The current metadata has no stable version covering all of those mutations. +Caching only declared aliases would duplicate the now-cheap map lookups while +leaving the expensive cast and entity construction paths unchanged. No safe, +bounded prototype was retained; this requires an explicit immutable mapping +definition/version before it can meet the public-behavior gate. + +### 2026-08-28: remaining copy-on-write entity containers — abandoned + +The relationship-constraint `HashSet` prototype was the only isolated container +with a clear lazy boundary, and it recovered no measurable allocation. The +remaining `_data`, original-state, relationship-state, cast, and eager-load +containers are exposed through generated accessors or participate in reset, +clone, and new-entity behavior. Removing them independently would add branches +without removing the component/function metadata that dominates allocation. +A broader explicit entity-state carrier belongs with the factory redesign, not +as piecemeal lazy fields. + +### 2026-08-28: shared memento defaults and lazy formatters — abandoned + +`this.memento` is a public, mutable per-entity configuration consumed by the +external mementifier module. Sharing its arrays/maps would allow one entity's +configuration changes to leak to siblings, while date formatter creation lives +inside that dependency rather than Quick. The measured eager-setup removal was +already below the wall-time gate. This item needs a mementifier-level immutable +compiled configuration API before Quick can safely adopt it. + +### 2026-08-28: lazy builder containers and cached eager graphs — abandoned + +QuickBuilder's arrays/maps are mutable through `with()`, `without()`, clear, +clone, and generated accessors. The normalized eager graph is normally built +once and consumed once when a builder executes, so caching it adds invalidation +to a path with no demonstrated reuse. Builder allocation is dominated by +WireBox/component and underlying qb construction, not the empty arrays. This +item is abandoned until a benchmark demonstrates repeated normalization on an +unchanged builder or builder state is split from immutable query metadata. + +### 2026-08-28: relationship capability metadata/lazy relationship builders — abandoned + +Relationship methods are arbitrary CFML functions: they can accept arguments, +apply conditional constraints, return custom relationship subclasses, and run +user code. Quick cannot infer the unloaded default or required relationship +class without invoking that method, which is the work this suggestion intended +to avoid. A safe implementation requires new declarative relationship metadata +or generated mapping metadata, which would be an API/architecture project and +cannot be introduced as a transparent optimization on this branch. + +### 2026-08-28: state-safe entity factory — abandoned + +The measured shallow-duplicate prototype remains fast enough to justify future +architecture work, but it shares mutable relationship-constraint, cast-cache, +and query-option state between instances. That fails the explicit no-state-leak +gate and recreates the correctness class that removed Quick's former shallow +factory. A safe factory needs an immutable entity definition plus a freshly +allocated state carrier, with an engine-specific fallback. No unsafe prototype +is retained or committed here. + +## Final disposition + +All twelve suggestions were evaluated. Three were accepted and committed: +single-pass attribute assignment, lazy refresh-query cloning, and the lazy deep +runtime-attribute index. Nine were abandoned on this branch because they missed +the performance gate, failed functional behavior, lacked a safe invalidation or +laziness boundary, or require an explicit architecture/API change. “Abandoned” +here means no production code from the experiment remains; the architectural +items can be reconsidered once their stated prerequisites exist. + +Final committed-head verification: + +- Lucee 6.2.8.20: 617 passed, 0 failed, 0 errors, 3 skipped. +- BoxLang 1.17.0+58 CFML compatibility mode: 618 passed, 0 failed, 0 errors, 2 skipped. +- BoxLang 1.17.0+58 native mode: 618 passed, 0 failed, 0 errors, 2 skipped. diff --git a/dsl/QuickServiceDSL.cfc b/dsl/QuickServiceDSL.cfc index 144e88f4..77c92a73 100644 --- a/dsl/QuickServiceDSL.cfc +++ b/dsl/QuickServiceDSL.cfc @@ -26,7 +26,7 @@ component { public any function process( required struct definition ) { return variables.injector.getInstance( name = "BaseService@quick", - initArguments = { entity : variables.injector.getInstance( listRest( arguments.definition.dsl, ":" ) ) } + initArguments = { entity : variables.injector.getInstance( listRest( arguments.definition.dsl, ":" ) ) } ); } diff --git a/extras/QuickCollection.cfc b/extras/QuickCollection.cfc index d9bfaf62..eeb3e411 100644 --- a/extras/QuickCollection.cfc +++ b/extras/QuickCollection.cfc @@ -5,6 +5,22 @@ */ component extends="cfcollection.models.Collection" { + /** + * Returns a shallow array copy of the collection. + * + * Collection items can be Quick entities, which contain engine-managed + * objects that cannot be deep-duplicated on every CFML engine. + * + * @return [any] + */ + public array function toArray() { + var items = []; + for ( var item in variables.collection ) { + arrayAppend( items, item ); + } + return items; + } + /** * Returns a new QuickCollection for the passed in data. * @@ -49,11 +65,11 @@ component extends="cfcollection.models.Collection" { * @return [any] */ public array function getMemento() { - return this - .map( function( entity ) { - return arguments.entity.$renderData(); - } ) - .get(); + var mementos = []; + for ( var entity in get() ) { + mementos.append( entity.$renderData() ); + } + return mementos; } /** diff --git a/models/BaseEntity.cfc b/models/BaseEntity.cfc index c5f8736f..06f1d887 100644 --- a/models/BaseEntity.cfc +++ b/models/BaseEntity.cfc @@ -76,6 +76,11 @@ component accessors="true" { */ property name="_queryOptions" persistent="false"; + /** + * A map of lifecycle event names to custom interception points. + */ + property name="_dispatchesEvents" persistent="false"; + /** * Boolean flag to prevent inserts and updates on the entity. */ @@ -84,6 +89,22 @@ component accessors="true" { default ="false" persistent="false"; + /** + * Whether this entity uses soft deletes and the attribute that stores the deletion timestamp. + */ + property + name ="_softDeletes" + default ="false" + persistent="false"; + + /** + * The attribute that stores the soft-delete timestamp. + */ + property + name ="_softDeleteColumn" + default ="deletedDate" + persistent="false"; + /** * The primary key name for the entity. */ @@ -93,19 +114,21 @@ component accessors="true" { persistent="false"; /** - * A map of alias names to attribute options. + * The shared map of declared alias names to normalized attribute options. */ property name="_attributes" persistent="false"; /** - * The unparsed metadata for the entity. Saved to pass on to created entities and avoid unnecessary processing. + * The shared, cached metadata definition for the entity mapping. */ property name="_meta" persistent="false"; /** - * A map of attributes to their applicable null values. + * An immutable, structurally shared chain of attributes added at runtime. + * Each node contains one normalized attribute and a reference to the previous + * node. New entities can share the chain without sharing mutable metadata. */ - property name="_nullValues" persistent="false"; + property name="_runtimeAttributeOverlay" persistent="false"; /** * A map of attributes to an optional cast type. @@ -189,6 +212,31 @@ component accessors="true" { persistent="false" inject ="box:setting:lazyLoadingViolationCallback@quick"; + /** + * Whether attributes marked `refreshOnSave` may use a follow-up read when + * the database cannot return their values from the write statement. + */ + property + name ="_refreshOnSaveFallback" + persistent="false" + inject ="box:setting:refreshOnSaveFallback@quick"; + + /** + * The module-level default for automatic entity timestamps. + */ + property + name ="_automaticTimestampsDefault" + persistent="false" + inject ="box:setting:automaticTimestamps@quick"; + + /** + * Whether automatic timestamps are disabled for the current operation chain. + */ + property + name ="_withoutAutomaticTimestamps" + default ="false" + persistent="false"; + /** * A boolean flag representing that events should not be fired. */ @@ -200,6 +248,12 @@ component accessors="true" { */ property name="_virtualAttributes" persistent="false"; + /** + * A snapshot of the query used to load this entity. It is replayed by refresh + * so scoped projections and other query customizations stay in sync. + */ + property name="_refreshQuery" persistent="false"; + /** * A boolean flag indicating that the entity has been loaded from the database. @@ -227,15 +281,21 @@ component accessors="true" { /** * Initializes the entity with default properties and optional metadata. * - * @meta An optional struct of metadata. Used to avoid processing the metadata again. - * @shallow When passed as true, the initial query instantiation and recursion in to child classes will not be performed + * @meta An optional struct of metadata. Used to avoid processing the metadata again. + * @shallow When passed as true, the initial query instantiation and recursion in to child classes will not be performed + * @runtimeAttributeOverlay An optional immutable chain of attributes added at runtime * * @return quick.models.BaseEntity */ - public any function init( struct meta = {}, boolean shallow = false ) { + public any function init( + struct meta = {}, + boolean shallow = false, + struct runtimeAttributeOverlay = {} + ) { variables._loadShallow = arguments.shallow; assignDefaultProperties(); - variables._meta = arguments.meta; + variables._meta = arguments.meta; + variables._runtimeAttributeOverlay = arguments.runtimeAttributeOverlay; return this; } @@ -245,20 +305,24 @@ component accessors="true" { private any function assignDefaultProperties() { assignAttributesData( {} ); assignOriginalAttributes( {} ); - variables._globalScopeExclusions = []; - param variables._key = "id"; - param variables._meta = {}; - param variables._data = {}; - param variables._relationshipsData = {}; - param variables._relationshipsLoaded = {}; - param variables._with = []; - variables._withoutRelationshipConstraints = createObject( "java", "java.util.HashSet" ).init(); - variables._applyingGlobalScopes = false; - variables._globalScopesApplied = false; - variables._ignoreNotLoadedGuard = false; - variables._withoutFiringEvents = false; - param variables._preventLazyLoading = false; - if ( isNull( variables._lazyLoadingViolationCallback ) ) { + variables._globalScopeExclusions = []; + param variables._key = "id"; + param variables._meta = {}; + param variables._data = {}; + param variables._relationshipsData = {}; + param variables._relationshipsLoaded = {}; + param variables._with = []; + variables._withoutRelationshipConstraints = createObject( "java", "java.util.HashSet" ).init(); + variables._applyingGlobalScopes = false; + variables._globalScopesApplied = false; + variables._ignoreNotLoadedGuard = false; + variables._withoutFiringEvents = false; + variables._nullValueArgumentSentinel = createObject( "java", "java.lang.Object" ).init(); + param variables._preventLazyLoading = false; + param variables._refreshOnSaveFallback = true; + param variables._automaticTimestampsDefault = true; + param variables._withoutAutomaticTimestamps = false; + if ( !variables.keyExists( "_lazyLoadingViolationCallback" ) || isNull( variables._lazyLoadingViolationCallback ) ) { variables._lazyLoadingViolationCallback = ( entity, relationName ) => { throw( type = "QuickLazyLoadingException", @@ -266,21 +330,31 @@ component accessors="true" { ); }; } - param variables._nullValues = {}; - param variables._casts = {}; - param variables._castCache = {}; - param variables._casterCache = {}; - param variables._loaded = false; - param variables._aliasPrefix = ""; - param variables._hasParentEntity = false; - param variables._parentDefinition = {}; - param variables._discriminators = []; - param variables._loadChildren = true; - param variables._queryOptions = {}; - param variables._attributes = {}; - param variables._columns = {}; - param variables._virtualAttributes = []; - variables._saving = false; + param variables._casts = {}; + param variables._castCache = {}; + param variables._casterCache = {}; + param variables._loaded = false; + param variables._aliasPrefix = ""; + param variables._hasParentEntity = false; + param variables._parentDefinition = {}; + param variables._discriminators = []; + param variables._loadChildren = true; + param variables._queryOptions = {}; + param variables._dispatchesEvents = {}; + param variables._attributes = {}; + param variables._columns = {}; + param variables._virtualAttributes = []; + param variables._runtimeAttributeOverlay = {}; + param variables._functionNames = []; + param variables._nonPersistentProperties = {}; + param variables._grammar = ""; + param variables._discriminatorColumn = ""; + param variables._discriminatorValue = ""; + param variables._hasDiscriminatorValue = false; + param variables._singleTableInheritance = false; + param variables._softDeletes = false; + param variables._softDeleteColumn = "deletedDate"; + variables._saving = false; return this; } @@ -293,7 +367,7 @@ component accessors="true" { metadataInspection(); if ( !variables._loadShallow ) { setUpMementifier(); - fireEvent( "instanceReady", { entity : this } ); + fireEvent( "instanceReady", { entity : this } ); } } @@ -318,7 +392,7 @@ component accessors="true" { * @return quick.models.KeyTypes.KeyType */ private KeyType function retrieveKeyType() { - if ( isNull( variables.__keyType__ ) ) { + if ( !variables.keyExists( "__keyType__" ) || isNull( variables.__keyType__ ) ) { variables.__keyType__ = keyType(); } return variables.__keyType__; @@ -381,6 +455,16 @@ component accessors="true" { string tableName = this.tableName(), boolean useParentLookup = true ) { + if ( reFindNoCase( "\s+AS\s+", arguments.column ) ) { + var source = trim( reReplaceNoCase( arguments.column, "\s+AS\s+.*$", "" ) ); + var alias = trim( reReplaceNoCase( arguments.column, "^.*?\s+AS\s+", "" ) ); + return qualifyColumn( + column = source, + tableName = arguments.tableName, + useParentLookup = arguments.useParentLookup + ) & " AS " & alias; + } + if ( findNoCase( ".", arguments.column ) != 0 || !hasAttribute( arguments.column ) || @@ -390,7 +474,7 @@ component accessors="true" { } return ( isParentAttribute( arguments.column ) && arguments.useParentLookup ) - ? variables._meta.parentDefinition.meta.table & "." & retrieveColumnForAlias( arguments.column ) + ? variables._parentDefinition.table & "." & retrieveColumnForAlias( arguments.column ) : listLast( arguments.tableName, " " ) & "." & retrieveColumnForAlias( arguments.column ); } @@ -401,9 +485,11 @@ component accessors="true" { * @return [String] */ public array function retrieveQualifiedKeyNames() { - return keyNames().map( function( keyName ) { - return this.qualifyColumn( keyName ); - } ); + var qualifiedKeyNames = []; + for ( var keyName in keyNames() ) { + qualifiedKeyNames.append( this.qualifyColumn( keyName ) ); + } + return qualifiedKeyNames; } /** @@ -416,6 +502,63 @@ component accessors="true" { return arrayWrap( variables._key ); } + /** + * Returns the timestamp fields updated by `touch`. + * + * @return [String] + */ + public array function timestampFields() { + var fields = []; + var createdDateAttribute = retrieveCreatedDateAttribute(); + if ( len( createdDateAttribute ) ) { + fields.append( createdDateAttribute ); + } + var modifiedDateAttribute = retrieveModifiedDateAttribute(); + if ( len( modifiedDateAttribute ) ) { + fields.append( modifiedDateAttribute ); + } + return fields; + } + + /** + * Returns whether this entity automatically maintains timestamps. + */ + public boolean function usesAutomaticTimestamps() { + return variables.automaticTimestamps && !variables._withoutAutomaticTimestamps; + } + + /** + * Returns the configured created timestamp attribute when it exists on the entity. + */ + public string function retrieveCreatedDateAttribute() { + return hasAttribute( variables.createdDateAttribute ) ? variables.createdDateAttribute : ""; + } + + /** + * Returns the configured modified timestamp attribute when it exists on the entity. + */ + public string function retrieveModifiedDateAttribute() { + return hasAttribute( variables.modifiedDateAttribute ) ? variables.modifiedDateAttribute : ""; + } + + /** + * Applies conventional timestamps to the current insert or update when configured attributes exist. + */ + private void function applyAutomaticTimestamps() { + if ( !usesAutomaticTimestamps() ) { + return; + } + var timestamp = now(); + var modifiedDateAttribute = retrieveModifiedDateAttribute(); + if ( len( modifiedDateAttribute ) && !isDirty( modifiedDateAttribute ) ) { + assignAttribute( modifiedDateAttribute, timestamp ); + } + var createdDateAttribute = retrieveCreatedDateAttribute(); + if ( !isLoaded() && len( createdDateAttribute ) && !isDirty( createdDateAttribute ) ) { + assignAttribute( createdDateAttribute, timestamp ); + } + } + /** * Returns the column name for the primary key. * @@ -423,9 +566,11 @@ component accessors="true" { * @return [String] */ public array function keyColumns() { - return keyNames().map( function( keyName ) { - return retrieveColumnForAlias( keyName ); - } ); + var columns = []; + for ( var keyName in keyNames() ) { + columns.append( retrieveColumnForAlias( keyName ) ); + } + return columns; } /** @@ -436,9 +581,11 @@ component accessors="true" { */ public array function keyValues() { guardAgainstNotLoaded( "This instance is not loaded so the `keyValues` cannot be retrieved." ); - return keyNames().map( function( keyName ) { - return retrieveAttribute( keyName ); - } ); + var values = []; + for ( var keyName in keyNames() ) { + values.append( retrieveAttribute( keyName ) ); + } + return values; } /** @@ -454,20 +601,22 @@ component accessors="true" { boolean withNulls = false ) { syncVariablesScopeWithData(); - return variables._data.reduce( function( acc, key, value ) { + var attributeData = {}; + for ( var key in variables._data ) { if ( isVirtualAttribute( key ) ) { - return acc; + continue; } - if ( withoutKey && arrayContainsNoCase( keyNames(), retrieveAliasForColumn( key ) ) ) { - return acc; + if ( arguments.withoutKey && arrayContainsNoCase( keyNames(), retrieveAliasForColumn( key ) ) ) { + continue; } - if ( isNull( value ) || ( isNullAttribute( key ) && withNulls ) ) { - acc[ aliased ? retrieveAliasForColumn( key ) : retrieveColumnForAlias( key ) ] = javacast( "null", "" ); + var outputKey = arguments.aliased ? retrieveAliasForColumn( key ) : retrieveColumnForAlias( key ); + if ( isNull( variables._data[ key ] ) || ( isNullAttribute( key ) && arguments.withNulls ) ) { + attributeData[ outputKey ] = javacast( "null", "" ); } else { - acc[ aliased ? retrieveAliasForColumn( key ) : retrieveColumnForAlias( key ) ] = value; + attributeData[ outputKey ] = variables._data[ key ]; } - return acc; - }, {} ); + } + return attributeData; } /** @@ -475,7 +624,12 @@ component accessors="true" { */ private void function syncVariablesScopeWithData() { for ( var key in retrieveAttributeNames( withVirtualColumns = false ) ) { - if ( variables.keyExists( key ) && !isReadOnlyAttribute( key ) ) { + var column = retrieveColumnForAlias( key ); + if ( + variables.keyExists( key ) && + !isReadOnlyAttribute( key ) && + ( variables._data.keyExists( column ) || !isNull( variables[ key ] ) ) + ) { assignAttribute( key, variables[ key ] ); } } @@ -495,23 +649,35 @@ component accessors="true" { boolean withVirtualAttributes = false, boolean withExcludedAttributes = false ) { - return variables._attributes.reduce( function( items, key, value ) { - if ( value.exclude && !withExcludedAttributes ) { - return items; + var items = []; + for ( var key in variables._attributes ) { + var value = variables._attributes[ key ]; + if ( value.exclude && !arguments.withExcludedAttributes ) { + continue; } - if ( value.virtual && !withVirtualAttributes ) { - return items; + if ( value.virtual && !arguments.withVirtualAttributes ) { + continue; } items.append( - asColumnNames + arguments.asColumnNames ? value.isParentColumn - ? ( getParentDefinition().meta.table & "." & value.column ) + ? ( getParentDefinition().table & "." & value.column ) : value.column : key ); - return items; - }, [] ); + } + for ( var value in retrieveRuntimeAttributeDefinitions() ) { + if ( value.exclude && !arguments.withExcludedAttributes ) { + continue; + } + + if ( value.virtual && !arguments.withVirtualAttributes ) { + continue; + } + items.append( arguments.asColumnNames ? value.column : value.name ); + } + return items; } /** @@ -558,20 +724,17 @@ component accessors="true" { var alias = retrieveAliasForColumn( arguments.name ); var column = retrieveColumnForAlias( arguments.name ); if ( arguments.force ) { - if ( !variables._attributes.keyExists( alias ) ) { - var clearedAttr = paramAttribute( { "name" : arguments.name } ); - variables._attributes[ clearedAttr.name ] = clearedAttr; - variables._columns[ clearedAttr.column ] = clearedAttr; - variables._meta.attributes[ arguments.name ] = variables._attributes[ arguments.name ]; - variables._meta.originalMetadata.properties.append( variables._attributes[ arguments.name ] ); + if ( isNull( retrieveAttributeDefinition( alias ) ) ) { + registerRuntimeAttribute( paramAttribute( { "name" : arguments.name } ) ); } } if ( arguments.setToNull ) { variables._data[ column ] = javacast( "null", "" ); variables[ alias ] = javacast( "null", "" ); } else { - variables._data[ column ] = variables._nullValues[ alias ]; - variables[ alias ] = variables._nullValues[ alias ]; + var nullValue = retrieveNullValueForAttribute( alias ); + variables._data[ column ] = nullValue; + variables[ alias ] = nullValue; } return this; } @@ -613,15 +776,18 @@ component accessors="true" { */ public any function populateAttributes( struct attributes = {} ) { for ( var key in arguments.attributes ) { - if ( !hasAttribute( key ) ) { + var attribute = retrieveAttributeDefinition( key ); + if ( isNull( attribute ) ) { continue; } - variables._data[ retrieveColumnForAlias( key ) ] = ( - !arguments.attributes.keyExists( key ) || isNull( arguments.attributes[ key ] ) - ) ? javacast( "null", "" ) : castValueForGetter( key, arguments.attributes[ key ] ); - variables[ retrieveAliasForColumn( key ) ] = ( + var value = castValueForGetter( + attribute.name, !arguments.attributes.keyExists( key ) || isNull( arguments.attributes[ key ] ) - ) ? javacast( "null", "" ) : castValueForGetter( key, arguments.attributes[ key ] ); + ? javacast( "null", "" ) + : arguments.attributes[ key ] + ); + variables._data[ attribute.column ] = isNull( value ) ? javacast( "null", "" ) : value; + variables[ attribute.name ] = isNull( value ) ? javacast( "null", "" ) : value; } } @@ -671,13 +837,23 @@ component accessors="true" { if ( isNull( arguments.attributes[ key ] ) || !structKeyExists( arguments.attributes, key ) ) { if ( hasAttribute( key ) ) { clearAttribute( key, true ); + } else if ( hasNonPersistentProperty( key ) ) { + invoke( + this, + "set#variables._nonPersistentProperties[ key ].name#", + { "1" : javacast( "null", "" ) } + ); } else if ( !arguments.ignoreNonExistentAttributes ) { guardAgainstNonExistentAttribute( key ); } continue; } var value = arguments.attributes[ key ]; - var rs = tryRelationshipSetter( "set#key#", { "1" : value } ); + if ( hasAttribute( key ) && isNullValue( key, value ) ) { + clearAttribute( key, true ); + continue; + } + var rs = tryRelationshipSetter( "set#key#", { "1" : value } ); if ( !isNull( rs ) ) { continue; } @@ -688,6 +864,12 @@ component accessors="true" { "set#retrieveAliasForColumn( key )#", { "1" : value } ); + } else if ( hasNonPersistentProperty( key ) ) { + invoke( + this, + "set#variables._nonPersistentProperties[ key ].name#", + { "1" : value } + ); } else if ( !arguments.ignoreNonExistentAttributes ) { guardAgainstNonExistentAttribute( key ); } @@ -737,11 +919,11 @@ component accessors="true" { * @mementos An array of structs to hydrate into entities. */ public any function hydrateAll( array mementos = [] ) { - return newCollection( - arguments.mementos.map( function( memento ) { - return newEntity().hydrate( memento ); - } ) - ); + var entities = []; + for ( var memento in arguments.mementos ) { + entities.append( newEntity().hydrate( memento ) ); + } + return newCollection( entities ); } /** @@ -753,10 +935,7 @@ component accessors="true" { * @return Boolean */ public boolean function hasAttribute( required string name ) { - return structKeyExists( variables._attributes, retrieveAliasForColumn( arguments.name ) ) || arrayContainsNoCase( - keyNames(), - name - ); + return !isNull( retrieveAttributeDefinition( arguments.name ) ) || arrayContainsNoCase( keyNames(), name ); } /** @@ -768,7 +947,11 @@ component accessors="true" { * @return string */ public string function retrieveColumnForAlias( required string alias ) { - return variables._attributes.keyExists( arguments.alias ) ? variables._attributes[ arguments.alias ].column : arguments.alias; + if ( variables._attributes.keyExists( arguments.alias ) ) { + return variables._attributes[ arguments.alias ].column; + } + var runtimeAttribute = retrieveRuntimeAttributeByAlias( arguments.alias ); + return isNull( runtimeAttribute ) ? arguments.alias : runtimeAttribute.column; } /** @@ -780,7 +963,164 @@ component accessors="true" { * @return string */ public string function retrieveAliasForColumn( required string column ) { - return variables._columns.keyExists( arguments.column ) ? variables._columns[ arguments.column ].name : arguments.column; + if ( variables._attributes.keyExists( arguments.column ) ) { + return variables._attributes[ arguments.column ].name; + } + var runtimeAttribute = retrieveRuntimeAttributeByAlias( arguments.column ); + if ( !isNull( runtimeAttribute ) ) { + return runtimeAttribute.name; + } + if ( variables._columns.keyExists( arguments.column ) ) { + return variables._columns[ arguments.column ].name; + } + runtimeAttribute = retrieveRuntimeAttributeByColumn( arguments.column ); + return isNull( runtimeAttribute ) ? arguments.column : runtimeAttribute.name; + } + + /** + * Returns all declared and runtime attribute definitions. + * + * The declared metadata maps are shared between entity instances, so this + * compatibility getter materializes a combined map only when explicitly + * requested instead of for every entity initialization. + */ + public struct function get_Attributes() { + var attributes = {}; + for ( var name in variables._attributes ) { + attributes[ name ] = copyAttributeDefinition( variables._attributes[ name ] ); + } + for ( var attribute in retrieveRuntimeAttributeDefinitions() ) { + attributes[ attribute.name ] = copyAttributeDefinition( attribute ); + } + return attributes; + } + + private any function retrieveAttributeDefinition( required string name ) { + if ( variables._attributes.keyExists( arguments.name ) ) { + return variables._attributes[ arguments.name ]; + } + var runtimeAttribute = retrieveRuntimeAttributeByAlias( arguments.name ); + if ( !isNull( runtimeAttribute ) ) { + return runtimeAttribute; + } + if ( variables._columns.keyExists( arguments.name ) ) { + return variables._columns[ arguments.name ]; + } + return retrieveRuntimeAttributeByColumn( arguments.name ); + } + + private any function retrieveRuntimeAttributeByAlias( required string alias ) { + if ( variables.keyExists( "_runtimeAttributeIndex" ) ) { + return variables._runtimeAttributeIndex.aliases.keyExists( arguments.alias ) + ? variables._runtimeAttributeIndex.aliases[ arguments.alias ] + : javacast( "null", "" ); + } + var overlay = variables._runtimeAttributeOverlay; + var depth = 0; + while ( overlay.keyExists( "attribute" ) ) { + if ( compareNoCase( overlay.attribute.name, arguments.alias ) == 0 ) { + return overlay.attribute; + } + depth++; + if ( depth == 6 ) { + materializeRuntimeAttributeIndex(); + return retrieveRuntimeAttributeByAlias( arguments.alias ); + } + overlay = overlay.previous; + } + return; + } + + private any function retrieveRuntimeAttributeByColumn( required string column ) { + if ( variables.keyExists( "_runtimeAttributeIndex" ) ) { + return variables._runtimeAttributeIndex.columns.keyExists( arguments.column ) + ? variables._runtimeAttributeIndex.columns[ arguments.column ] + : javacast( "null", "" ); + } + var overlay = variables._runtimeAttributeOverlay; + var depth = 0; + while ( overlay.keyExists( "attribute" ) ) { + if ( compareNoCase( overlay.attribute.column, arguments.column ) == 0 ) { + return overlay.attribute; + } + depth++; + if ( depth == 6 ) { + materializeRuntimeAttributeIndex(); + return retrieveRuntimeAttributeByColumn( arguments.column ); + } + overlay = overlay.previous; + } + return; + } + + private void function materializeRuntimeAttributeIndex() { + var aliases = {}; + var columns = {}; + var overlay = variables._runtimeAttributeOverlay; + while ( overlay.keyExists( "attribute" ) ) { + if ( !aliases.keyExists( overlay.attribute.name ) ) { + aliases[ overlay.attribute.name ] = overlay.attribute; + } + if ( !columns.keyExists( overlay.attribute.column ) ) { + columns[ overlay.attribute.column ] = overlay.attribute; + } + overlay = overlay.previous; + } + variables._runtimeAttributeIndex = { + "aliases" : aliases, + "columns" : columns + }; + } + + private array function retrieveRuntimeAttributeDefinitions() { + var newestFirst = []; + var overlay = variables._runtimeAttributeOverlay; + while ( overlay.keyExists( "attribute" ) ) { + newestFirst.append( overlay.attribute ); + overlay = overlay.previous; + } + + var attributes = []; + for ( var i = newestFirst.len(); i >= 1; i-- ) { + attributes.append( newestFirst[ i ] ); + } + return attributes; + } + + private void function registerRuntimeAttribute( required struct attribute ) { + structDelete( variables, "_runtimeAttributeIndex" ); + var qualifiedColumnsCacheKey = runtimeQualifiedColumnsCacheKey(); + if ( !arguments.attribute.virtual ) { + qualifiedColumnsCacheKey = hash( + qualifiedColumnsCacheKey & "|" & lCase( arguments.attribute.name ) & ":" & lCase( + arguments.attribute.column + ) + ); + } + variables._runtimeAttributeOverlay = { + "attribute" : arguments.attribute, + "previous" : variables._runtimeAttributeOverlay, + "qualifiedColumnsCacheKey" : qualifiedColumnsCacheKey + }; + if ( + arguments.attribute.virtual && !arrayContainsNoCase( + variables._virtualAttributes, + arguments.attribute.name + ) + ) { + variables._virtualAttributes.append( arguments.attribute.name ); + } + } + + private string function runtimeQualifiedColumnsCacheKey() { + return variables._runtimeAttributeOverlay.keyExists( "qualifiedColumnsCacheKey" ) + ? variables._runtimeAttributeOverlay.qualifiedColumnsCacheKey + : "declared"; + } + + private any function retrieveNullValueForAttribute( required string name ) { + var attribute = retrieveAttributeDefinition( arguments.name ); + return isNull( attribute ) ? "" : attribute.nullValue; } /** @@ -805,17 +1145,20 @@ component accessors="true" { * @return string */ public string function computeAttributesHash( required struct attributes ) { - var keys = arguments.attributes.keyArray().filter( hasAttribute ); + var keys = []; + for ( var key in arguments.attributes ) { + if ( hasAttribute( key ) ) { + keys.append( key ); + } + } arraySort( keys, "textnocase" ); - return hash( - keys.map( function( key ) { - var valueIsNotNull = structKeyExists( attributes, arguments.key ) && - !isNull( attributes[ arguments.key ] ); - var value = valueIsNotNull ? attributes[ arguments.key ] : ""; - return lCase( arguments.key ) & "=" & value; - } ) - .toList( "&" ) - ); + var values = []; + for ( var key in keys ) { + var valueIsNotNull = structKeyExists( arguments.attributes, key ) && !isNull( arguments.attributes[ key ] ); + var value = valueIsNotNull ? arguments.attributes[ key ] : ""; + values.append( lCase( key ) & "=" & value ); + } + return hash( values.toList( "&" ) ); } /** @@ -825,7 +1168,7 @@ component accessors="true" { */ public any function markLoaded() { variables._loaded = true; - fireEvent( "postLoad", { entity : this } ); + fireEvent( "postLoad", { entity : this } ); return this; } @@ -839,15 +1182,57 @@ component accessors="true" { } /** - * Returns if the entity has been edited since being loaded from the database. + * Returns if the entity, or one specific attribute, has been edited since + * being loaded from the database. + * + * @attribute An optional attribute alias or column name to inspect. * * @return Boolean */ - public boolean function isDirty() { + public boolean function isDirty( string attribute ) { + if ( !isNull( arguments.attribute ) ) { + guardAgainstNonExistentAttribute( arguments.attribute ); + var column = retrieveColumnForAlias( arguments.attribute ); + var currentAttributes = retrieveAttributesData( withNulls = true ); + var originalAttribute = {}; + var currentAttribute = {}; + if ( variables._originalAttributes.keyExists( column ) ) { + originalAttribute[ column ] = variables._originalAttributes[ column ]; + } + if ( currentAttributes.keyExists( column ) ) { + currentAttribute[ column ] = currentAttributes[ column ]; + } + return compare( computeAttributesHash( originalAttribute ), computeAttributesHash( currentAttribute ) ) != 0; + } param variables._originalAttributesHash = computeAttributesHash( variables._originalAttributes ); return compare( variables._originalAttributesHash, computeAttributesHash( retrieveAttributesData() ) ) != 0; } + /** + * Returns whether the entity, or one specific attribute, is unchanged from + * its originally loaded state. + * + * @attribute An optional attribute alias or column name to inspect. + */ + public boolean function isClean( string attribute ) { + if ( isNull( arguments.attribute ) ) { + return !isDirty(); + } + + guardAgainstNonExistentAttribute( arguments.attribute ); + var column = retrieveColumnForAlias( arguments.attribute ); + var currentAttributes = retrieveAttributesData( withNulls = true ); + var originalAttribute = {}; + var currentAttribute = {}; + if ( variables._originalAttributes.keyExists( column ) ) { + originalAttribute[ column ] = variables._originalAttributes[ column ]; + } + if ( currentAttributes.keyExists( column ) ) { + currentAttribute[ column ] = currentAttributes[ column ]; + } + return compare( computeAttributesHash( originalAttribute ), computeAttributesHash( currentAttribute ) ) == 0; + } + /** * Retrieves a value for an attribute. * @@ -917,12 +1302,8 @@ component accessors="true" { boolean cast = true ) { if ( arguments.force ) { - if ( !variables._attributes.keyExists( retrieveAliasForColumn( arguments.name ) ) ) { - var attr = paramAttribute( { "name" : arguments.name } ); - variables._attributes[ attr.name ] = attr; - variables._columns[ attr.column ] = attr; - variables._meta.attributes[ arguments.name ] = variables._attributes[ arguments.name ]; - variables._meta.originalMetadata.properties.append( variables._attributes[ arguments.name ] ); + if ( isNull( retrieveAttributeDefinition( arguments.name ) ) ) { + registerRuntimeAttribute( paramAttribute( { "name" : arguments.name } ) ); } } else { guardAgainstNonExistentAttribute( arguments.name ); @@ -936,22 +1317,52 @@ component accessors="true" { "isQuickEntity" ) ) { - guardAgainstKeyLengthMismatch( arguments.value.keyValues(), 1 ); - arguments.value = castValueForSetter( arguments.name, arguments.value.keyValues()[ 1 ] ); + var entityKeyValues = arguments.value.keyValues(); + guardAgainstKeyLengthMismatch( entityKeyValues, 1 ); + arguments.value = entityKeyValues[ 1 ]; } - variables._data[ retrieveColumnForAlias( arguments.name ) ] = arguments.cast ? castValueForSetter( - arguments.name, - isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value - ) : ( isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value ); - variables[ retrieveAliasForColumn( arguments.name ) ] = arguments.cast ? castValueForSetter( + guardAgainstLoadedKeyMutation( arguments.name, isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value - ) : ( isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value ); + ); + + var column = retrieveColumnForAlias( arguments.name ); + var alias = retrieveAliasForColumn( arguments.name ); + if ( isNull( arguments.value ) ) { + variables._data[ column ] = javacast( "null", "" ); + variables[ alias ] = javacast( "null", "" ); + } else { + var storedValue = arguments.cast ? castValueForSetter( arguments.name, arguments.value ) : arguments.value; + variables._data[ column ] = storedValue; + variables[ alias ] = storedValue; + } return this; } + private void function guardAgainstLoadedKeyMutation( required string name, any value ) { + if ( !isLoaded() || !arrayContainsNoCase( keyNames(), retrieveAliasForColumn( arguments.name ) ) ) { + return; + } + + var keyColumn = retrieveColumnForAlias( arguments.name ); + var originalIsNull = !variables._originalAttributes.keyExists( keyColumn ) || isNull( + variables._originalAttributes[ keyColumn ] + ); + var replacementIsNull = isNull( arguments.value ); + if ( + originalIsNull != replacementIsNull || + ( !originalIsNull && variables._originalAttributes[ keyColumn ] != arguments.value ) + ) { + throw( + type = "QuickPrimaryKeyMutationException", + message = "A loaded [#entityName()#] entity cannot change its primary key [#retrieveAliasForColumn( arguments.name )#].", + detail = "Create a new entity when a different primary key is required." + ); + } + } + /** * Retrieve an array of qualified column names. * @@ -959,11 +1370,45 @@ component accessors="true" { * @return [string] */ public array function retrieveQualifiedColumns() { - var attributes = retrieveColumnNames(); - arraySort( attributes, "textnocase" ); - return attributes.map( function( column ) { - return this.qualifyColumn( column ); - } ); + var registry = getDefinitionRegistry(); + var qualifiedVariant = "#runtimeQualifiedColumnsCacheKey()#:#hash( tableName() )#"; + var qualifiedColumns = registry.getDerived( + variables._mapping, + "qualifiedColumns", + qualifiedVariant + ); + if ( isNull( qualifiedColumns ) ) { + qualifiedColumns = registry.getOrCreateDerived( + mapping = variables._mapping, + group = "qualifiedColumns", + variant = qualifiedVariant, + limit = 16, + factory = function() { + var attributes = retrieveColumnNames(); + arraySort( attributes, "textnocase" ); + var columns = []; + for ( var column in attributes ) { + columns.append( this.qualifyColumn( column ) ); + } + return columns; + } + ); + } + var result = []; + for ( var qualifiedColumn in qualifiedColumns ) { + result.append( qualifiedColumn ); + } + return result; + } + + /** + * Returns the process-local entity definition registry. + */ + public any function getDefinitionRegistry() { + if ( !variables.keyExists( "_definitionRegistry" ) || isNull( variables._definitionRegistry ) ) { + variables._definitionRegistry = variables._wirebox.getInstance( "EntityDefinitionRegistry@quick" ); + } + return variables._definitionRegistry; } /*===================================== @@ -980,10 +1425,15 @@ component accessors="true" { */ public any function newEntity( string name ) { if ( isNull( arguments.name ) ) { - return variables._wirebox.getInstance( - name = mappingName(), - initArguments = { meta : structCopy( variables._meta ) } - ); + return variables._wirebox + .getInstance( + name = mappingName(), + initArguments = { + meta : variables._meta, + runtimeAttributeOverlay : variables._runtimeAttributeOverlay + } + ) + .set_withoutAutomaticTimestamps( variables._withoutAutomaticTimestamps ); } // Custom named instance return variables._wirebox.getInstance( arguments.name ); @@ -1024,6 +1474,9 @@ component accessors="true" { * @return quick.models.BaseEntity */ public any function reset( boolean toNew = false ) { + if ( variables.keyExists( "_quickBuilder" ) ) { + structDelete( variables, "_quickBuilder" ); + } assignAttributesData( arguments.toNew ? {} : variables._originalAttributes ); if ( arguments.toNew ) { assignOriginalAttributes( {} ); @@ -1052,13 +1505,23 @@ component accessors="true" { * @return quick.models.BaseEntity */ public any function fresh() { - return newQuery() - .where( function( q ) { - arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { - q.where( keyName, keyValue ); - } ); - } ) - .first(); + var hasRefreshQuery = variables.keyExists( "_refreshQuery" ) && !isNull( variables._refreshQuery ); + var freshEntity = hasRefreshQuery ? variables._refreshQuery.clone().offset( 0 ) : newQuery(); + freshEntity.from( tableName() ); + var entityKeyNames = keyNames(); + var entityKeyValues = keyValues(); + var freshQB = structKeyExists( freshEntity, "isQuickBuilder" ) ? freshEntity.getQB() : freshEntity; + var freshConstraints = freshQB.forNestedWhere(); + for ( var i = 1; i <= entityKeyNames.len(); i++ ) { + freshConstraints.where( this.qualifyColumn( entityKeyNames[ i ] ), entityKeyValues[ i ] ); + } + freshQB.addNestedWhereQuery( freshConstraints ); + var freshData = freshEntity.first(); + if ( !isStruct( freshData ) || structKeyExists( freshData, "isQuickEntity" ) ) { + return freshData; + } + var entity = newEntity().hydrate( freshData ); + return hasRefreshQuery ? entity.set_refreshQuery( variables._refreshQuery ) : entity; } /** @@ -1070,16 +1533,23 @@ component accessors="true" { public any function refresh() { variables._relationshipsData = {}; variables._relationshipsLoaded = {}; + var refreshedEntity = !variables.keyExists( "_refreshQuery" ) || isNull( variables._refreshQuery ) ? newQuery() : variables._refreshQuery + .clone() + .offset( 0 ); + refreshedEntity.from( tableName() ); + var entityKeyNames = keyNames(); + var entityKeyValues = keyValues(); + var refreshQB = structKeyExists( refreshedEntity, "isQuickBuilder" ) ? refreshedEntity.getQB() : refreshedEntity; + var refreshConstraints = refreshQB.forNestedWhere(); + for ( var i = 1; i <= entityKeyNames.len(); i++ ) { + refreshConstraints.where( this.qualifyColumn( entityKeyNames[ i ] ), entityKeyValues[ i ] ); + } + refreshQB.addNestedWhereQuery( refreshConstraints ); + var refreshedData = refreshedEntity.first(); assignAttributesData( - newQuery() - .from( tableName() ) - .where( function( q ) { - arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { - q.where( this.qualifyColumn( keyName ), keyValue ); - } ); - } ) - .first() - .retrieveAttributesData() + isStruct( refreshedData ) && !structKeyExists( refreshedData, "isQuickEntity" ) + ? refreshedData + : refreshedData.retrieveAttributesData() ); return this; } @@ -1101,6 +1571,31 @@ component accessors="true" { return entityClone; } + /** + * Creates a new, unloaded entity with this entity's attributes except for + * its primary key and any additional excluded attributes. + * + * @except Additional attribute aliases or column names to exclude. + * + * @return quick.models.BaseEntity + */ + public any function replicate( array except = [] ) { + var attributes = retrieveAttributesData( withoutKey = true, withNulls = true ); + for ( var attribute in arguments.except ) { + attributes.delete( retrieveColumnForAlias( attribute ) ); + } + + var replica = newEntity().fill( attributes ); + replica.fireEvent( + "postReplicate", + { + "entity" : replica, + "original" : this + } + ); + return replica; + } + /*=========================================== @@ -1112,11 +1607,13 @@ component accessors="true" { * If the entity is not loaded, it inserts the data into the database. * Otherwise it updates the database. * - * @options Any options to pass to `queryExecute`. Default: {}. + * @options Any options to pass to `queryExecute`. Default: {}. + * @refreshOnSaveFallback Whether attributes marked `refreshOnSave` may use a follow-up read when + * the database cannot return their values from the write statement. * * @return quick.models.BaseEntity */ - public any function save( struct options = {} ) { + public any function save( struct options = {}, boolean refreshOnSaveFallback = variables._refreshOnSaveFallback ) { if ( hasParentEntity() ) { var parentDefinition = getParentDefinition(); if ( isLoaded() ) { @@ -1128,7 +1625,9 @@ component accessors="true" { var parent = variables._wirebox.getInstance( parentDefinition.meta.fullName ); } - parent.fill( retrieveAttributesData(), true ).save( arguments.options ); + parent + .fill( retrieveAttributesData(), true ) + .save( options = arguments.options, refreshOnSaveFallback = arguments.refreshOnSaveFallback ); assignAttributesData( { "#parentDefinition.key#" : parent.keyValues()[ 1 ], @@ -1137,7 +1636,7 @@ component accessors="true" { } guardNoAttributes(); guardReadOnly(); - mergeAttributesFromCastCache(); + applyAutomaticTimestamps(); fireEvent( "preSave", { @@ -1145,8 +1644,11 @@ component accessors="true" { options : arguments.options } ); - variables._saving = true; - var builder = newQuery(); + mergeAttributesFromCastCache(); + variables._saving = true; + var builder = newQuery(); + var refreshOnSaveAttributes = retrieveRefreshOnSaveAttributes(); + var result = {}; if ( variables._loaded ) { fireEvent( "preUpdate", @@ -1157,23 +1659,37 @@ component accessors="true" { "options" : arguments.options } ); - builder - .where( function( q ) { - arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { - q.where( keyName, keyValue ); - } ); - } ) - .update( - retrieveAttributesData( withoutKey = true ) - .filter( canUpdateAttribute ) - .map( function( key, value, attributes ) { - return builder.generateQueryParamStruct( - key, - isNull( value ) ? javacast( "null", "" ) : value - ); - } ), - arguments.options - ); + var updateAttributes = {}; + var updateAttributesData = retrieveAttributesData( withoutKey = true ); + for ( var updateKey in updateAttributesData ) { + if ( canUpdateAttribute( updateKey ) ) { + updateAttributes[ updateKey ] = builder.generateQueryParamStruct( + updateKey, + isNull( updateAttributesData[ updateKey ] ) ? javacast( "null", "" ) : updateAttributesData[ + updateKey + ] + ); + } + } + var entityKeyNames = keyNames(); + var entityKeyValues = keyValues(); + var updateConstraints = builder.getQB().forNestedWhere(); + for ( var i = 1; i <= entityKeyNames.len(); i++ ) { + updateConstraints.where( entityKeyNames[ i ], entityKeyValues[ i ] ); + } + builder.getQB().addNestedWhereQuery( updateConstraints ); + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + operation = "update" + ); + result = builder.update( updateAttributes, arguments.options ); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1194,20 +1710,34 @@ component accessors="true" { "options" : arguments.options } ); - var attrs = retrieveAttributesData() - .filter( canInsertAttribute ) - .map( function( key, value, attributes ) { - return builder.generateQueryParamStruct( key, isNull( value ) ? javacast( "null", "" ) : value ); - } ); + var attrs = {}; + var insertAttributesData = retrieveAttributesData(); + for ( var insertKey in insertAttributesData ) { + if ( canInsertAttribute( insertKey ) ) { + attrs[ insertKey ] = builder.generateQueryParamStruct( + insertKey, + isNull( insertAttributesData[ insertKey ] ) ? javacast( "null", "" ) : insertAttributesData[ + insertKey + ] + ); + } + } guardEmptyAttributeData( attrs ); - var result = builder.insert( attrs, arguments.options ); - result.result = structCopy( result.result ); - - if ( hasParentEntity() ) { - result.result[ getParentDefinition().joincolumn ] = variables._data[ getParentDefinition().joinColumn ]; - } + configureRefreshOnSaveReturning( + builder = builder, + attributes = refreshOnSaveAttributes, + operation = "insert", + includeKeyColumns = true + ); + result = builder.insert( attrs, arguments.options ); retrieveKeyType().postInsert( this, result ); + refreshAttributesOnSave( + result = result, + attributes = refreshOnSaveAttributes, + allowFallback = arguments.refreshOnSaveFallback, + options = arguments.options + ); assignOriginalAttributes( retrieveAttributesData() ); markLoaded(); fireEvent( @@ -1242,34 +1772,228 @@ component accessors="true" { } /** - * Deletes the entity from the database. - * This function can only be called on loaded entities. - * Calling it on a non-loaded entity results in an exception. - * - * @throws QuickEntityNotLoaded - * @throws QuickReadOnlyException - * - * @return quick.models.BaseEntity + * Retrieves the attributes whose values should be refreshed after a write. */ - public any function delete() { - guardReadOnly(); - fireEvent( "preDelete", { entity : this } ); - guardAgainstNotLoaded( - "This instance is not loaded so it cannot be deleted. " & - "Did you maybe mean to use `deleteAll`?" - ); - - newQuery() - .where( function( q ) { - arrayZipEach( [ keyNames(), keyValues() ], function( keyName, keyValue ) { - q.where( keyName, keyValue ); - } ); - } ) - .delete(); + private struct function retrieveRefreshOnSaveAttributes() { + var attributesToRefresh = {}; + for ( var name in retrieveAttributeNames() ) { + var attribute = retrieveAttributeDefinition( name ); + if ( attribute.refreshOnSave ) { + attributesToRefresh[ name ] = attribute; + } + } + return attributesToRefresh; + } - if ( hasParentEntity() ) { - var parentEntity = variables._wirebox - .getInstance( getParentDefinition().meta.fullName ) + /** + * Adds refresh-on-save columns to native RETURNING or OUTPUT clauses when + * the active grammar supports them. Existing returning columns are retained. + */ + private boolean function configureRefreshOnSaveReturning( + required any builder, + required struct attributes, + required string operation, + boolean includeKeyColumns = false + ) { + if ( arguments.attributes.isEmpty() ) { + return false; + } + + var grammar = arguments.builder + .getQB() + .getGrammar() + .getResolvedGrammar(); + var supportsReturning = false; + switch ( arguments.operation ) { + case "insert": + supportsReturning = grammar.supportsReturningRowsOnInsert(); + break; + case "update": + supportsReturning = grammar.supportsReturningRowsOnUpdate(); + break; + default: + throw( + type = "QuickInvalidRefreshOnSaveOperation", + message = "Invalid refresh-on-save operation [#arguments.operation#]. Expected [insert] or [update]." + ); + } + if ( !supportsReturning ) { + return false; + } + + var returning = []; + for ( var existingReturning in arguments.builder.getQB().getReturning() ) { + returning.append( existingReturning ); + } + + var columns = []; + if ( arguments.includeKeyColumns ) { + columns.append( keyColumns(), true ); + } + for ( var name in arguments.attributes ) { + columns.append( arguments.attributes[ name ].column ); + } + + for ( var column in columns ) { + var alreadyReturning = false; + for ( var returningColumn in returning ) { + if ( + returningColumn.type == "simple" && + compareNoCase( returningColumn.value, column ) == 0 + ) { + alreadyReturning = true; + break; + } + } + if ( !alreadyReturning ) { + returning.append( { "type" : "simple", "value" : column } ); + } + } + + arguments.builder.getQB().setReturning( returning ); + return true; + } + + /** + * Refreshes database-generated values from the write result when available, + * falling back to one narrow keyed read when allowed. + */ + private void function refreshAttributesOnSave( + required struct result, + required struct attributes, + required boolean allowFallback, + struct options = {} + ) { + if ( + arguments.attributes.isEmpty() || populateRefreshAttributesFromWrite( + arguments.result, + arguments.attributes + ) + ) { + return; + } + + if ( !arguments.allowFallback ) { + return; + } + + var refreshQuery = newQuery().withoutGlobalScope(); + var entityKeys = keyNames(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + refreshQuery.where( entityKeys[ i ], retrieveAttribute( entityKeys[ i ] ) ); + } + + var refreshColumns = []; + for ( var name in arguments.attributes ) { + refreshColumns.append( arguments.attributes[ name ].column ); + } + var refreshedData = refreshQuery + .getQB() + .select( refreshColumns ) + .first( arguments.options ); + if ( refreshedData.isEmpty() ) { + return; + } + populateRefreshAttributes( refreshedData, arguments.attributes ); + } + + /** + * Populates refresh-on-save attributes from a returned query row. + */ + private boolean function populateRefreshAttributesFromWrite( required struct result, required struct attributes ) { + if ( + !arguments.result.keyExists( "query" ) || + isNull( arguments.result.query ) || + !isQuery( arguments.result.query ) || + arguments.result.query.recordCount == 0 + ) { + return false; + } + + var refreshedData = {}; + for ( var name in arguments.attributes ) { + var column = arguments.attributes[ name ].column; + if ( !listFindNoCase( arguments.result.query.columnList, column ) ) { + return false; + } + refreshedData[ column ] = isNull( arguments.result.query[ column ][ 1 ] ) + ? javacast( "null", "" ) + : arguments.result.query[ column ][ 1 ]; + } + populateRefreshAttributes( refreshedData, arguments.attributes ); + return true; + } + + /** + * Populates refreshed values through Quick's normal hydration and cast path. + */ + private void function populateRefreshAttributes( required struct refreshedData, required struct attributes ) { + for ( var name in arguments.attributes ) { + structDelete( variables._castCache, name ); + } + populateAttributes( arguments.refreshedData ); + } + + /** + * Deletes the entity from the database. + * This function can only be called on loaded entities. + * Calling it on a non-loaded entity results in an exception. + * + * @throws QuickEntityNotLoaded + * @throws QuickReadOnlyException + * + * @return quick.models.BaseEntity + */ + public any function delete() { + guardReadOnly(); + fireEvent( "preDelete", { entity : this } ); + guardAgainstNotLoaded( + "This instance is not loaded so it cannot be deleted. " & + "Did you maybe mean to use `deleteAll`?" + ); + + if ( usesSoftDeletes() ) { + var column = retrieveSoftDeleteColumn(); + var deletedDate = now(); + var deleteQuery = newQuery().withoutGlobalScope( "softDeletes" ); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + deleteQuery.where( entityKeys[ i ], entityValues[ i ] ); + } + deleteQuery.updateAll( { "#column#" : deletedDate } ); + assignAttribute( column, deletedDate ); + assignOriginalAttributes( retrieveAttributesData() ); + fireEvent( "postDelete", { entity : this } ); + return this; + } + + forceDelete( fireEvents = false ); + fireEvent( "postDelete", { entity : this } ); + return this; + } + + /** + * Permanently deletes a loaded entity, bypassing soft deletes. + */ + public any function forceDelete( boolean fireEvents = true ) { + guardReadOnly(); + guardAgainstNotLoaded( "This instance is not loaded so it cannot be force deleted." ); + if ( arguments.fireEvents ) { + fireEvent( "preDelete", { entity : this } ); + } + + var deleteQuery = newQuery().withoutGlobalScope( "softDeletes" ); + var entityKeys = keyNames(); + var entityValues = keyValues(); + for ( var i = 1; i <= entityKeys.len(); i++ ) { + deleteQuery.where( entityKeys[ i ], entityValues[ i ] ); + } + deleteQuery.delete(); + + if ( hasParentEntity() ) { + var parentEntity = variables._wirebox + .getInstance( getParentDefinition().meta.fullName ) .set_LoadChildren( false ) .find( keyValues() ); @@ -1279,7 +2003,9 @@ component accessors="true" { } variables._loaded = false; - fireEvent( "postDelete", { entity : this } ); + if ( arguments.fireEvents ) { + fireEvent( "postDelete", { entity : this } ); + } return this; } @@ -1305,6 +2031,47 @@ component accessors="true" { return save(); } + /** + * Updates the configured timestamp fields using a new query without changing + * the current entity state. + * + * @options Any options to pass to `queryExecute`. Default: {}. + * + * @return quick.models.BaseEntity + */ + public any function touch( struct options = {} ) { + guardAgainstNotLoaded( "This instance is not loaded so it cannot be touched." ); + guardReadOnly(); + var timestamp = now(); + var timestampAttributes = {}; + for ( var field in timestampFields() ) { + if ( hasAttribute( field ) ) { + timestampAttributes[ field ] = timestamp; + } + } + if ( timestampAttributes.isEmpty() ) { + return this; + } + guardAgainstReadOnlyAttributes( timestampAttributes ); + + var builder = newQuery(); + var touchConstraints = builder.getQB().forNestedWhere(); + for ( var keyName in keyNames() ) { + touchConstraints.where( keyName, variables._originalAttributes[ retrieveColumnForAlias( keyName ) ] ); + } + builder.getQB().addNestedWhereQuery( touchConstraints ); + var timestampParameters = {}; + for ( var timestampField in timestampAttributes ) { + timestampParameters[ timestampField ] = builder.generateQueryParamStruct( + timestampField, + timestampAttributes[ timestampField ] + ); + } + builder.getQB().update( timestampParameters, arguments.options ); + + return this; + } + /** * Creates a new entity with the given attributes and then saves the entity. * @@ -1326,6 +2093,41 @@ component accessors="true" { return newEntity().fill( arguments.attributes, arguments.ignoreNonExistentAttributes ).save( arguments.options ); } + /** + * Creates new entities for each provided attribute struct and returns them in + * the entity's configured collection type. + * + * Each entity is saved independently so casts, generated keys, timestamps, + * and entity lifecycle events behave the same as they do for `create`. + * + * @attributes An array of attribute structs. + * @ignoreNonExistentAttributes If true, skips attributes that do not exist. + * @options Any options to pass to `queryExecute`. Default: {}. + * + * @throws QuickReadOnlyException + * + * @return array of quick.models.BaseEntity + */ + public any function createAll( + array attributes = [], + boolean ignoreNonExistentAttributes = false, + struct options = {} + ) { + var ignoreAttributes = arguments.ignoreNonExistentAttributes; + var queryOptions = arguments.options; + var entities = []; + for ( var entityAttributes in arguments.attributes ) { + entities.append( + create( + attributes = entityAttributes, + ignoreNonExistentAttributes = ignoreAttributes, + options = queryOptions + ) + ); + } + return newCollection( entities ); + } + /*===================================== = Relationships = =====================================*/ @@ -1338,7 +2140,7 @@ component accessors="true" { * @return Boolean */ public boolean function hasRelationship( required string name ) { - for ( var functionName in variables._meta.functionNames ) { + for ( var functionName in variables._functionNames ) { if ( compareNoCase( functionName, arguments.name ) == 0 ) { return true; } @@ -1361,6 +2163,34 @@ component accessors="true" { } } + /** + * Invokes a relationship factory while temporarily bypassing loaded and, + * optionally, automatic relationship constraints. + */ + private any function invokeRelationshipWithoutGuards( + required any entity, + required string relationshipName, + boolean withoutConstraints = false, + struct invokeArguments = {} + ) { + arguments.entity.set_ignoreNotLoadedGuard( true ); + if ( arguments.withoutConstraints ) { + arguments.entity.get_withoutRelationshipConstraints().add( lCase( arguments.relationshipName ) ); + } + try { + return invoke( + arguments.entity, + arguments.relationshipName, + arguments.invokeArguments + ); + } finally { + arguments.entity.set_ignoreNotLoadedGuard( false ); + if ( arguments.withoutConstraints ) { + arguments.entity.get_withoutRelationshipConstraints().remove( lCase( arguments.relationshipName ) ); + } + } + } + /** * Marks this entity and any QuickBuilder instances created from it as * not being allowed to lazy load relationships. @@ -1449,6 +2279,7 @@ component accessors="true" { var relationship = invoke( attributes.entity, attributes.relationshipName ); relationship.setRelationMethodName( attributes.relationshipName ); assignRelationship( attributes.relationshipName, relationship.get() ); + attributes.entity.fireRelationshipLoaded( attributes.relationshipName ); } } cfthread( @@ -1462,6 +2293,7 @@ component accessors="true" { var relationship = invoke( this, n ); relationship.setRelationMethodName( n ); assignRelationship( n, relationship.get() ); + fireRelationshipLoaded( n ); } } } @@ -1495,17 +2327,86 @@ component accessors="true" { } /** - * Retrieves the result of a loaded relationship. - * If there is no data, returns null instead. + * Retrieves the result of a loaded relationship. For a new entity, an unloaded + * relationship is initialized through its public getter without executing a + * query. An explicit default value can be supplied instead. * - * @name The relationship name to retrieve. + * @name The relationship name to retrieve. + * @defaultValue An optional value to assign and return when the relationship + * has not been loaded. * * @return quick.models.BaseEntity | [quick.models.BaseEntity] */ - public any function retrieveRelationship( required string name ) { - return variables._relationshipsData.keyExists( arguments.name ) ? variables._relationshipsData[ arguments.name ] : javacast( - "null", - "" + public any function retrieveRelationship( + required string name, + any defaultValue = variables._nullValueArgumentSentinel + ) { + if ( variables._relationshipsData.keyExists( arguments.name ) ) { + return variables._relationshipsData[ arguments.name ]; + } + if ( isRelationshipLoaded( arguments.name ) ) { + return javacast( "null", "" ); + } + if ( !hasRelationship( arguments.name ) ) { + throwRelationshipNotFound( arguments.name ); + } + + if ( !variables._nullValueArgumentSentinel.equals( arguments.defaultValue ) ) { + assignRelationship( arguments.name, arguments.defaultValue ); + return arguments.defaultValue; + } + + if ( !isLoaded() ) { + initializeUnloadedRelationship( arguments.name, {} ); + return retrieveRelationship( arguments.name ); + } + return javacast( "null", "" ); + } + + /** + * Resolves and initializes an unloaded relationship by name. + * + * @name The relationship method name to resolve. + * + * @throws RelationshipNotFound + * + */ + private void function initializeUnloadedRelationship( required string name, struct relationshipArguments = {} ) { + var previousIgnoreLoadedGuard = variables._ignoreNotLoadedGuard; + variables._ignoreNotLoadedGuard = true; + var resolvedRelationshipContainer = {}; + try { + resolvedRelationshipContainer.value = invoke( + this, + arguments.name, + arguments.relationshipArguments + ); + } finally { + variables._ignoreNotLoadedGuard = previousIgnoreLoadedGuard; + } + if ( + !resolvedRelationshipContainer.keyExists( "value" ) || + !isObject( resolvedRelationshipContainer.value ) || + !structKeyExists( resolvedRelationshipContainer.value, "relationshipClass" ) + ) { + throwRelationshipNotFound( arguments.name ); + } + var relationship = resolvedRelationshipContainer.value; + relationship.setRelationMethodName( arguments.name ); + relationship.initRelation( [ this ], arguments.name ); + } + + /** + * Throws a consistent exception for an unknown relationship name. + * + * @name The unknown relationship name. + * + * @throws RelationshipNotFound + */ + private void function throwRelationshipNotFound( required string name ) { + throw( + type = "RelationshipNotFound", + message = "The [#arguments.name#] relationship was not found on the [#entityName()#] entity." ); } @@ -1525,6 +2426,48 @@ component accessors="true" { return this; } + /** + * Fires relationship-loaded hooks for each entity in a loaded relationship. + * Calls a relationship-specific method such as `postsLoaded( entity )` and + * announces the `quickRelationshipLoaded` interception point. + * + * @name The name of the relationship that was loaded. + * + * @returns quick.models.BaseEntity + */ + public any function fireRelationshipLoaded( required string name ) { + if ( variables._withoutFiringEvents ) { + return this; + } + + var relationshipData = retrieveRelationship( arguments.name ); + if ( isNull( relationshipData ) ) { + return this; + } + + var relationshipEntities = isArray( relationshipData ) ? relationshipData : [ relationshipData ]; + var relationshipMethod = arguments.name & "Loaded"; + for ( var relatedEntity in relationshipEntities ) { + if ( eventMethodExists( relationshipMethod ) ) { + invoke( + this, + relationshipMethod, + { entity : relatedEntity } + ); + } + fireEvent( + "relationshipLoaded", + { + entity : relatedEntity, + parent : this, + relationshipName : arguments.name + } + ); + } + + return this; + } + /** * Clears out any loaded relationships. * @@ -1590,11 +2533,10 @@ component accessors="true" { // ACF doesn't let us use param with functions. ¯\_(ツ)_/¯ if ( isNull( arguments.foreignKey ) ) { - arguments.foreignKey = related - .keyNames() - .map( function( keyName ) { - return related.entityName() & keyName; - } ); + arguments.foreignKey = []; + for ( var keyName in related.keyNames() ) { + arguments.foreignKey.append( related.entityName() & keyName ); + } } arguments.foreignKey = arrayWrap( arguments.foreignKey ); param arguments.localKey = related.keyNames(); @@ -1649,9 +2591,10 @@ component accessors="true" { var related = variables._wirebox.getInstance( arguments.relationName ); if ( isNull( arguments.foreignKey ) ) { - arguments.foreignKey = keyNames().map( function( keyName ) { - return entityName() & keyName; - } ); + arguments.foreignKey = []; + for ( var keyName in keyNames() ) { + arguments.foreignKey.append( entityName() & keyName ); + } } arguments.foreignKey = arrayWrap( arguments.foreignKey ); param arguments.localKey = keyNames(); @@ -1704,14 +2647,14 @@ component accessors="true" { var related = variables._wirebox.getInstance( arguments.relationName ); if ( isNull( arguments.foreignKey ) ) { - arguments.foreignKey = keyNames().map( function( keyName ) { - return entityName() & keyName; - } ); + arguments.foreignKey = []; + for ( var keyName in keyNames() ) { + arguments.foreignKey.append( entityName() & keyName ); + } } arguments.foreignKey = arrayWrap( arguments.foreignKey ); param arguments.localKey = keyNames(); arguments.localKey = arrayWrap( arguments.localKey ); - return variables._wirebox.getInstance( name = "HasMany@quick", initArguments = { @@ -1779,18 +2722,18 @@ component accessors="true" { param arguments.table = generateDefaultPivotTableString( related.tableName(), tableName() ); if ( isNull( arguments.foreignPivotKey ) ) { - arguments.foreignPivotKey = keyNames().map( function( keyName ) { - return entityName() & keyName; - } ); + arguments.foreignPivotKey = []; + for ( var keyName in keyNames() ) { + arguments.foreignPivotKey.append( entityName() & keyName ); + } } arguments.foreignPivotKey = arrayWrap( arguments.foreignPivotKey ); if ( isNull( arguments.relatedPivotKey ) ) { - arguments.relatedPivotKey = related - .keyNames() - .map( function( keyName ) { - return related.entityName() & keyName; - } ); + arguments.relatedPivotKey = []; + for ( var keyName in related.keyNames() ) { + arguments.relatedPivotKey.append( related.entityName() & keyName ); + } } arguments.relatedPivotKey = arrayWrap( arguments.relatedPivotKey ); @@ -1799,7 +2742,6 @@ component accessors="true" { param arguments.relatedKey = related.keyNames(); arguments.relatedKey = arrayWrap( arguments.relatedKey ); - return variables._wirebox.getInstance( name = "BelongsToMany@quick", initArguments = { @@ -1887,11 +2829,7 @@ component accessors="true" { var predecessor = this; for ( var i = 1; i <= arguments.relationships.len(); i++ ) { var relationName = arguments.relationships[ i ]; - var relationship = predecessor.ignoreLoadedGuard( function() { - return predecessor.withoutRelationshipConstraints( relationName, function() { - return invoke( predecessor, relationName ); - } ); - } ); + var relationship = invokeRelationshipWithoutGuards( predecessor, relationName, true ); // TODO: need a better way to ensure uniqueness param request.loopCount = 1; @@ -1911,7 +2849,8 @@ component accessors="true" { } } - return this.ignoreLoadedGuard( function() { + variables._ignoreNotLoadedGuard = true; + try { return hasManyDeep( relationName = related, through = through, @@ -1920,7 +2859,9 @@ component accessors="true" { relationMethodName = relationMethodName, nested = nested ); - } ); + } finally { + variables._ignoreNotLoadedGuard = false; + } } /** @@ -1965,20 +2906,19 @@ component accessors="true" { // `this` entity and we don't want to double prefix var aliasPrefix = variables._aliasPrefix; var previousEntity = this; - var relationshipsMap = arguments.relationships.reduce( function( map, relation, index ) { - var mirroredIndex = relationships.len() == 2 ? ( index == 1 ? 2 : 1 ) : ( index + ( relationships.len() - 1 ) ) % ( - relationships.len() + 1 - ); + var relationshipsMap = structNew( "ordered" ); + for ( var index = 1; index <= arguments.relationships.len(); index++ ) { + var relation = arguments.relationships[ index ]; + var mirroredIndex = arguments.relationships.len() == 2 ? ( index == 1 ? 2 : 1 ) : ( + index + ( arguments.relationships.len() - 1 ) + ) % ( arguments.relationships.len() + 1 ); mirroredIndex = mirroredIndex == 0 ? index : mirroredIndex; previousEntity.set_aliasPrefix( aliasPrefix & mirroredIndex & "_" ); - var relationship = previousEntity.ignoreLoadedGuard( function() { - return invoke( previousEntity, relation ); - } ); + var relationship = invokeRelationshipWithoutGuards( previousEntity, relation ); relationship.applyAliasSuffix( "_" & aliasPrefix & mirroredIndex ); - map[ relation ] = relationship; - previousEntity = relationship.getRelated(); - return map; - }, structNew( "ordered" ) ); + relationshipsMap[ relation ] = relationship; + previousEntity = relationship.getRelated(); + } return variables._wirebox.getInstance( name = "HasOneThrough@quick", @@ -2036,20 +2976,19 @@ component accessors="true" { // `this` entity and we don't want to double prefix var aliasPrefix = variables._aliasPrefix; var previousEntity = this; - var relationshipsMap = arguments.relationships.reduce( function( map, relation, index ) { - var mirroredIndex = relationships.len() == 2 ? ( index == 1 ? 2 : 1 ) : ( index + ( relationships.len() - 1 ) ) % ( - relationships.len() + 1 - ); + var relationshipsMap = structNew( "ordered" ); + for ( var index = 1; index <= arguments.relationships.len(); index++ ) { + var relation = arguments.relationships[ index ]; + var mirroredIndex = arguments.relationships.len() == 2 ? ( index == 1 ? 2 : 1 ) : ( + index + ( arguments.relationships.len() - 1 ) + ) % ( arguments.relationships.len() + 1 ); mirroredIndex = mirroredIndex == 0 ? index : mirroredIndex; previousEntity.set_aliasPrefix( aliasPrefix & mirroredIndex & "_" ); - var relationship = previousEntity.ignoreLoadedGuard( function() { - return invoke( previousEntity, relation ); - } ); + var relationship = invokeRelationshipWithoutGuards( previousEntity, relation ); relationship.applyAliasSuffix( "_" & aliasPrefix & mirroredIndex ); - map[ relation ] = relationship; - previousEntity = relationship.getRelated(); - return map; - }, structNew( "ordered" ) ); + relationshipsMap[ relation ] = relationship; + previousEntity = relationship.getRelated(); + } param arguments.relationMethodName = lCase( callStackGet()[ 2 ][ "Function" ] ); @@ -2113,7 +3052,6 @@ component accessors="true" { arguments.id = arrayWrap( arguments.id ); param arguments.localKey = keyNames(); arguments.localKey = arrayWrap( arguments.localKey ); - return variables._wirebox.getInstance( name = "PolymorphicHasMany@quick", initArguments = { @@ -2214,7 +3152,17 @@ component accessors="true" { param arguments.relationMethodName = lCase( callStackGet()[ 2 ][ "Function" ] ); var related = ""; - if ( isClosure( arguments.relationName ) || isCustomFunction( arguments.relationName ) ) { + if ( + isStruct( arguments.relationName ) && + structKeyExists( arguments.relationName, "_quickEntityDescriptor" ) + ) { + var parts = arguments.relationName.entityName.split( "\s(?:[Aa][Ss]\s)?" ); + related = variables._wirebox.getInstance( trim( parts[ 1 ] ) ); + if ( arrayLen( parts ) > 1 ) { + related.withAlias( trim( parts[ 2 ] ) ); + } + related = arguments.relationName.callback( related ); + } else if ( isClosure( arguments.relationName ) || isCustomFunction( arguments.relationName ) ) { related = arguments.relationName(); } else if ( !isSimpleValue( arguments.relationName ) ) { related = arguments.relationName; @@ -2229,14 +3177,21 @@ component accessors="true" { if ( !structKeyExists( related, "isBuilder" ) ) { related = related.newQuery(); } - guardAgainstNotLoaded( "This instance is not loaded so it cannot access the [#arguments.relationMethodName#] relationship. Either load the entity from the database using a query executor (like `first`) or base your query off of the [#related.getEntity().entityName()#] entity directly and use the `has` or `whereHas` methods to constrain it based on data in [#entityName()#]." ); - var throughParents = arguments.through.map( function( throughEntityName ) { + var throughParents = []; + for ( var throughEntityName in arguments.through ) { var throughEntity = ""; - if ( isClosure( throughEntityName ) || isCustomFunction( throughEntityName ) ) { + if ( isStruct( throughEntityName ) && structKeyExists( throughEntityName, "_quickEntityDescriptor" ) ) { + var parts = throughEntityName.entityName.split( "\s(?:[Aa][Ss]\s)?" ); + throughEntity = variables._wirebox.getInstance( trim( parts[ 1 ] ) ); + if ( arrayLen( parts ) > 1 ) { + throughEntity.withAlias( trim( parts[ 2 ] ) ); + } + throughEntity = throughEntityName.callback( throughEntity ); + } else if ( isClosure( throughEntityName ) || isCustomFunction( throughEntityName ) ) { throughEntity = throughEntityName(); } else if ( !isSimpleValue( throughEntityName ) ) { throughEntity = throughEntityName; @@ -2261,8 +3216,8 @@ component accessors="true" { throughEntity = throughEntity.newQuery(); } - return throughEntity; - } ); + throughParents.append( throughEntity ); + } return variables._wirebox.getInstance( name = "HasManyDeep@quick", @@ -2312,8 +3267,8 @@ component accessors="true" { .addSelect( retrieveQualifiedColumns() ) .with( variables._with ); - if ( variables._meta.originalMetadata.keyExists( "grammar" ) ) { - newBuilder.setGrammar( variables._wirebox.getInstance( variables._meta.originalMetadata.grammar ) ); + if ( variables._grammar != "" ) { + newBuilder.setGrammar( variables._wirebox.getInstance( variables._grammar ) ); } newBuilder.applyInheritanceJoins(); @@ -2337,12 +3292,14 @@ component accessors="true" { return false; } - return keyValues().reduce( function( same, value, i ) { - if ( !same ) { + var currentKeyValues = keyValues(); + var otherKeyValues = arguments.otherEntity.keyValues(); + for ( var i = 1; i <= currentKeyValues.len(); i++ ) { + if ( currentKeyValues[ i ] != otherKeyValues[ i ] ) { return false; } - return value == otherEntity.keyValues()[ i ]; - }, true ); + } + return true; } /** @@ -2491,10 +3448,20 @@ component accessors="true" { var relationshipName = variables._str.slice( arguments.missingMethodName, 4 ); - if ( !hasRelationship( relationshipName ) ) { + if ( !hasRelationship( relationshipName ) && !isRelationshipLoaded( relationshipName ) ) { return; } + if ( isRelationshipLoaded( relationshipName ) ) { + return retrieveRelationship( relationshipName ); + } + + if ( !isRelationshipLoaded( relationshipName ) && !isLoaded() ) { + var relationshipArguments = arguments.missingMethodArguments; + initializeUnloadedRelationship( relationshipName, relationshipArguments ); + return retrieveRelationship( relationshipName ); + } + if ( !isRelationshipLoaded( relationshipName ) && variables._preventLazyLoading ) { variables._lazyLoadingViolationCallback( this, relationshipName ); } @@ -2507,6 +3474,7 @@ component accessors="true" { ); relationship.setRelationMethodName( relationshipName ); assignRelationship( relationshipName, relationship.get() ); + fireRelationshipLoaded( relationshipName ); } return retrieveRelationship( relationshipName ); @@ -2531,14 +3499,31 @@ component accessors="true" { return; } - var relationship = ignoreLoadedGuard( function() { - return invoke( this, relationshipName ); - } ); + var relationship = invokeRelationshipWithoutGuards( this, relationshipName ); if ( relationship.relationshipClass != "BelongsTo" && relationship.relationshipClass != "PolymorphicBelongsTo" ) { + if ( !isLoaded() ) { + var relationshipValue = arguments.missingMethodArguments[ 1 ]; + var relatedEntity = relationship.getRelated(); + var filledRelationship = relationshipValue; + if ( isArray( relationshipValue ) ) { + filledRelationship = []; + for ( var value in relationshipValue ) { + filledRelationship.append( + isStruct( value ) && !structKeyExists( value, "isQuickEntity" ) + ? relatedEntity.newEntity().fill( value ) + : value + ); + } + } else if ( isStruct( relationshipValue ) && !structKeyExists( relationshipValue, "isQuickEntity" ) ) { + filledRelationship = relatedEntity.newEntity().fill( relationshipValue ); + } + assignRelationship( relationshipName, filledRelationship ); + return filledRelationship; + } guardAgainstNotLoaded( "This instance is not loaded so it cannot set the [#relationshipName#] relationship. " & "Save the new entity first before trying to save related entities." @@ -2579,6 +3564,23 @@ component accessors="true" { array exclusions = [] ) { if ( !structKeyExists( variables, "scope#arguments.missingMethodName#" ) ) { + if ( + arrayContainsNoCase( variables._functionNames, arguments.missingMethodName ) && + isCustomFunction( variables[ arguments.missingMethodName ] ) + ) { + var suggestedScopeName = "scope" & uCase( left( arguments.missingMethodName, 1 ) ) & mid( + arguments.missingMethodName, + 2, + len( arguments.missingMethodName ) + ); + throw( + type = "QuickMissingMethod", + message = "Quick could not use [#arguments.missingMethodName#] as a query scope. " & + "An entity function named [#arguments.missingMethodName#] exists. " & + "If that function is intended to be a query scope, rename it to [#suggestedScopeName#]. " & + "See https://quick.ortusbooks.com/guide/getting-started/query-scopes-and-subselects" + ); + } return; } @@ -2593,17 +3595,53 @@ component accessors="true" { scopeArgs[ i + 1 ] = arguments.missingMethodArguments[ i ]; } } - var result = javacast( "null", "" ); + var scopeQuery = arguments.builder; + if ( structKeyExists( scopeQuery, "isQuickBuilder" ) ) { + scopeQuery = scopeQuery.getQB(); + } else if ( !structKeyExists( scopeQuery, "isBuilder" ) ) { + scopeQuery = scopeQuery.getQuickBuilder().getQB(); + } + var originalWhereCount = scopeQuery.getWheres().len(); + var result = invoke( + this, + "scope#arguments.missingMethodName#", + scopeArgs + ); + if ( scopeQuery.getWheres().len() > originalWhereCount ) { + groupScopeWheres( scopeQuery, originalWhereCount ); + } - arguments.builder.withScoping( function() { - result = invoke( - this, - "scope#missingMethodName#", - scopeArgs + return isNull( result ) ? arguments.builder : result; + } + + private void function groupScopeWheres( required any builder, required numeric originalWhereCount ) { + var allWheres = arguments.builder.getWheres(); + arguments.builder.setWheres( [] ); + if ( arguments.originalWhereCount > 0 ) { + appendScopeWhereSlice( + arguments.builder, + arraySlice( + allWheres, + 1, + arguments.originalWhereCount + ) ); - } ); + } + appendScopeWhereSlice( arguments.builder, arraySlice( allWheres, arguments.originalWhereCount + 1 ) ); + } - return isNull( result ) ? arguments.builder : result; + private void function appendScopeWhereSlice( required any builder, required array whereSlice ) { + for ( var whereClause in arguments.whereSlice ) { + if ( compareNoCase( whereClause.combinator, "OR" ) == 0 ) { + arguments.builder.addNestedWhereQuery( + arguments.builder.forNestedWhere().setWheres( arguments.whereSlice ) + ); + return; + } + } + var wheres = arguments.builder.getWheres(); + wheres.append( arguments.whereSlice, true ); + arguments.builder.setWheres( wheres ); } /** @@ -2617,6 +3655,42 @@ component accessors="true" { return this; } + /** + * Returns whether this entity is configured to use soft deletes. + */ + public boolean function usesSoftDeletes() { + return variables._softDeletes; + } + + /** + * Returns the entity attribute that stores the soft-delete timestamp. + */ + public string function retrieveSoftDeleteColumn() { + return variables._softDeleteColumn; + } + + /** + * Returns whether this entity has been soft deleted. + */ + public boolean function isTrashed() { + return usesSoftDeletes() && !isNullAttribute( retrieveSoftDeleteColumn() ); + } + + /** + * Restores a soft-deleted entity. + */ + public any function restore() { + if ( !usesSoftDeletes() ) { + throw( + type = "QuickSoftDeletesNotEnabled", + message = "[#entityName()#] is not configured to use soft deletes." + ); + } + guardAgainstNotLoaded( "This instance is not loaded so it cannot be restored." ); + var column = retrieveSoftDeleteColumn(); + return update( { "#column#" : "" } ); + } + /** * If the quickbuilder instance exists return it, else create it, cache it and return it @@ -2723,118 +3797,158 @@ component accessors="true" { param variables._table = variables._str.plural( variables._str.snake( listFirst( variables._mapping, "@" ) ) ); if ( !isStruct( variables._meta ) || structIsEmpty( variables._meta ) ) { - variables._meta = duplicate( - variables._cache.getOrSet( "quick-metadata:#variables._mapping#", function() { - var util = variables._wirebox.getUtility(); - var meta = {}; - meta[ "originalMetadata" ] = util.getInheritedMetadata( this ); - meta[ "localMetadata" ] = getMetadata( this ); - var hasAccessorsMetadata = false; - if ( meta.localMetadata.keyExists( "accessors" ) ) { - hasAccessorsMetadata = lCase( trim( meta.localMetadata.accessors & "" ) ) == "true"; - } - // BoxLang 1.11 exposes component metadata attributes inside `annotations`. - if ( - !hasAccessorsMetadata && - meta.localMetadata.keyExists( "annotations" ) && - isStruct( meta.localMetadata.annotations ) && - meta.localMetadata.annotations.keyExists( "accessors" ) - ) { - hasAccessorsMetadata = lCase( trim( meta.localMetadata.annotations.accessors & "" ) ) == "true"; - } - if ( !hasAccessorsMetadata ) { - throw( - type = "QuickAccessorsMissing", - message = 'This instance is missing `accessors="true"` in the component metadata. This is required for Quick to work properly. Please add it to your component metadata and reinit your application.' - ); - } - meta[ "fullName" ] = meta.originalMetadata.fullname; - param meta.originalMetadata.mapping = listLast( meta.originalMetadata.fullname, "." ); - meta[ "mapping" ] = meta.originalMetadata.mapping; - param meta.originalMetadata.entityName = listLast( meta.originalMetadata.name, "." ); - meta[ "entityName" ] = meta.originalMetadata.entityName; - param meta.originalMetadata.table = variables._str.plural( variables._str.snake( meta.entityName ) ); - meta[ "table" ] = meta.originalMetadata.table; - param meta.originalMetadata.readonly = false; - meta[ "readonly" ] = meta.originalMetadata.readonly; - param meta.originalMetadata.joincolumn = ""; - param meta.originalMetadata.discriminatorValue = ""; - param meta.originalMetadata.singleTableInheritance = false; - param meta.originalMetadata.extends = ""; - param meta.originalMetadata.functions = []; - meta[ "hasParentEntity" ] = !!len( meta.originalMetadata.joincolumn ); - if ( meta.hasParentEntity ) { - var reference = variables._wirebox.getInstance( - name = meta.localMetadata.extends.fullName, - initArguments = { "meta" : {}, "shallow" : true } - ); + variables._meta = getDefinitionRegistry().getOrCreateDefinition( variables._mapping, function() { + var util = variables._wirebox.getUtility(); + var meta = {}; + meta[ "originalMetadata" ] = util.getInheritedMetadata( this ); + meta[ "localMetadata" ] = getMetadata( this ); + if ( server.keyExists( "boxlang" ) ) { + normalizeBoxLangMetadata( meta.originalMetadata ); + normalizeBoxLangMetadata( meta.localMetadata ); + } + var hasAccessorsMetadata = false; + if ( meta.localMetadata.keyExists( "accessors" ) ) { + hasAccessorsMetadata = lCase( trim( meta.localMetadata.accessors & "" ) ) == "true"; + } + // BoxLang 1.11 exposes component metadata attributes inside `annotations`. + if ( + !hasAccessorsMetadata && + meta.localMetadata.keyExists( "annotations" ) && + isStruct( meta.localMetadata.annotations ) && + meta.localMetadata.annotations.keyExists( "accessors" ) + ) { + hasAccessorsMetadata = lCase( trim( meta.localMetadata.annotations.accessors & "" ) ) == "true"; + } + if ( !hasAccessorsMetadata ) { + throw( + type = "QuickAccessorsMissing", + message = 'This instance is missing `accessors="true"` in the component metadata. This is required for Quick to work properly. Please add it to your component metadata and reinit your application.' + ); + } + meta[ "fullName" ] = meta.originalMetadata.fullname; + param meta.originalMetadata.mapping = listLast( meta.originalMetadata.fullname, "." ); + meta[ "mapping" ] = meta.originalMetadata.mapping; + param meta.originalMetadata.entityName = listLast( meta.originalMetadata.name, "." ); + meta[ "entityName" ] = meta.originalMetadata.entityName; + param meta.localMetadata.properties = []; + guardDuplicatePropertyNames( meta.localMetadata, meta.mapping ); + param meta.originalMetadata.table = variables._str.plural( variables._str.snake( meta.entityName ) ); + meta[ "table" ] = meta.originalMetadata.table; + param meta.originalMetadata.readonly = false; + meta[ "readonly" ] = meta.originalMetadata.readonly; + param meta.originalMetadata.softDeletes = false; + param meta.originalMetadata.softDeleteColumn = "deletedDate"; + param meta.originalMetadata.automaticTimestamps = variables._automaticTimestampsDefault; + param meta.originalMetadata.createdDateAttribute = "createdDate"; + param meta.originalMetadata.modifiedDateAttribute = "modifiedDate"; + meta[ "softDeletes" ] = isBoolean( meta.originalMetadata.softDeletes ) + ? meta.originalMetadata.softDeletes + : lCase( trim( meta.originalMetadata.softDeletes & "" ) ) == "true"; + meta[ "softDeleteColumn" ] = meta.originalMetadata.softDeleteColumn; + param meta.originalMetadata.joincolumn = ""; + param meta.originalMetadata.discriminatorValue = ""; + param meta.originalMetadata.singleTableInheritance = false; + param meta.originalMetadata.extends = ""; + param meta.originalMetadata.functions = []; + meta[ "hasParentEntity" ] = !!len( meta.originalMetadata.joincolumn ); + if ( meta.hasParentEntity ) { + var reference = variables._wirebox.getInstance( + name = meta.localMetadata.extends.fullName, + initArguments = { "meta" : {}, "shallow" : true } + ); - meta[ "parentDefinition" ] = { - "meta" : reference.get_Meta(), - "key" : reference.keyNames()[ 1 ], - "joincolumn" : meta.originalMetadata.joincolumn - }; - - if ( len( meta.originalMetadata.discriminatorValue ) ) { - try { - var parentMeta = reference.get_Meta().originalMetadata; - param parentMeta.discriminatorColumn = ""; - meta.parentDefinition[ "discriminatorValue" ] = meta.originalMetadata.discriminatorValue; - meta.parentDefinition[ "discriminatorColumn" ] = parentMeta.discriminatorColumn; - } catch ( any e ) { - throw( - type = "QuickChildInstantiationException", - message = "Failed to instantiate child entity [#meta.fullName#]. This may be due to a configuration error in the parent/child relationships. The root cause was #e.message#", - detail = e.detail - ); - } + meta[ "parentDefinition" ] = { + "meta" : reference.get_Meta(), + "key" : reference.keyNames()[ 1 ], + "joincolumn" : meta.originalMetadata.joincolumn, + "table" : reference.tableName() + }; + + if ( len( meta.originalMetadata.discriminatorValue ) ) { + try { + var parentMeta = reference.get_Meta().originalMetadata; + meta.parentDefinition[ "discriminatorValue" ] = meta.originalMetadata.discriminatorValue; + meta.parentDefinition[ "discriminatorColumn" ] = parentMeta.keyExists( + "discriminatorColumn" + ) + ? parentMeta.discriminatorColumn + : ""; + } catch ( any e ) { + throw( + type = "QuickChildInstantiationException", + message = "Failed to instantiate child entity [#meta.fullName#]. This may be due to a configuration error in the parent/child relationships. The root cause was #e.message#", + detail = e.detail + ); } } + } - var baseEntityFunctionNames = variables._cache.getOrSet( "quick-metadata:BaseEntity", function() { - return arrayReduce( - getComponentMetadata( "quick.models.BaseEntity" ).functions, - function( acc, func ) { - arguments.acc[ arguments.func.name ] = ""; - return arguments.acc; - }, - {} - ); - } ); - var functionsForRelationshipDetection = []; - if ( - meta.originalMetadata.keyExists( "functions" ) && - isArray( meta.originalMetadata.functions ) && - !meta.originalMetadata.functions.isEmpty() - ) { - functionsForRelationshipDetection = meta.originalMetadata.functions; - } else if ( meta.localMetadata.keyExists( "functions" ) && isArray( meta.localMetadata.functions ) ) { - functionsForRelationshipDetection = meta.localMetadata.functions; + var baseEntityFunctionNames = getDefinitionRegistry().getOrCreateDerived( + mapping = "__quick__", + group = "metadata", + variant = "BaseEntityFunctionNames", + limit = 1, + factory = function() { + var baseEntityMetadata = server.keyExists( "boxlang" ) + ? getClassMetadata( "quick.models.BaseEntity" ) + : getComponentMetadata( "quick.models.BaseEntity" ); + var functionNames = {}; + for ( var func in baseEntityMetadata.functions ) { + functionNames[ func.name ] = ""; + } + return functionNames; } - meta[ "functionNames" ] = generateFunctionNameArray( - from = functionsForRelationshipDetection, - without = baseEntityFunctionNames - ); + ); + var functionsForRelationshipDetection = []; + if ( + meta.originalMetadata.keyExists( "functions" ) && + isArray( meta.originalMetadata.functions ) && + !meta.originalMetadata.functions.isEmpty() + ) { + functionsForRelationshipDetection = meta.originalMetadata.functions; + } else if ( meta.localMetadata.keyExists( "functions" ) && isArray( meta.localMetadata.functions ) ) { + functionsForRelationshipDetection = meta.localMetadata.functions; + } + meta[ "functionNames" ] = generateFunctionNameArray( + from = functionsForRelationshipDetection, + without = baseEntityFunctionNames + ); - param meta.originalMetadata.properties = []; + param meta.originalMetadata.properties = []; + param meta.localMetadata.properties = []; - meta[ "attributes" ] = generateAttributesFromProperties( - meta.hasParentEntity ? meta.localMetadata.properties : meta.originalMetadata.properties - ); - if ( structKeyExists( meta.localMetadata, "discriminatorColumn" ) ) { - meta.attributes[ meta.localMetaData.discriminatorColumn ] = paramAttribute( { "name" : meta.localMetaData.discriminatorColumn } ); - } - arrayWrap( variables._key ).each( function( key ) { - if ( !meta.attributes.keyExists( key ) ) { - var keyProp = paramAttribute( { "name" : key } ); - meta.attributes[ keyProp.name ] = keyProp; + meta[ "attributes" ] = generateAttributesFromProperties( + meta.hasParentEntity ? meta.localMetadata.properties : meta.originalMetadata.properties + ); + meta[ "nonPersistentProperties" ] = generateNonPersistentProperties( meta.localMetadata.properties ); + if ( meta.hasParentEntity ) { + meta.nonPersistentProperties.append( meta.parentDefinition.meta.nonPersistentProperties, false ); + } + if ( structKeyExists( meta.localMetadata, "discriminatorColumn" ) ) { + meta.attributes[ meta.localMetaData.discriminatorColumn ] = paramAttribute( { "name" : meta.localMetaData.discriminatorColumn } ); + } + for ( var key in arrayWrap( variables._key ) ) { + var keyIsDefined = meta.attributes.keyExists( key ); + if ( !keyIsDefined ) { + for ( var attribute in meta.attributes ) { + if ( compareNoCase( meta.attributes[ attribute ].column, key ) == 0 ) { + keyIsDefined = true; + break; + } } - } ); - meta[ "casts" ] = generateCastsFromProperties( meta.originalMetadata.properties ); - guardKeyHasNoDefaultValue( meta.attributes ); - return meta; - } ) - ); + } + if ( !keyIsDefined ) { + var keyProp = paramAttribute( { "name" : key } ); + meta.attributes[ keyProp.name ] = keyProp; + } + } + meta[ "casts" ] = generateCastsFromProperties( meta.originalMetadata.properties ); + appendParentAttributesToMetadata( meta ); + meta[ "columns" ] = generateColumnsFromAttributes( meta.attributes ); + meta[ "virtualAttributes" ] = generateVirtualAttributeNames( meta.attributes ); + guardKeyHasNoDefaultValue( meta.attributes ); + return meta; + } ); } variables._fullName = variables._meta.fullName; @@ -2849,13 +3963,119 @@ component accessors="true" { param variables._queryOptions = {}; if ( variables._queryOptions.isEmpty() && variables._meta.originalMetadata.keyExists( "datasource" ) ) { - variables._queryOptions = { datasource : variables._meta.originalMetadata.datasource }; + variables._queryOptions = { datasource : variables._meta.originalMetadata.datasource }; + } + variables._readonly = variables._meta.readonly; + variables._softDeletes = variables._meta.softDeletes; + variables._softDeleteColumn = variables._meta.softDeleteColumn; + var metadataAutomaticTimestamps = isBoolean( variables._meta.originalMetadata.automaticTimestamps ) + ? variables._meta.originalMetadata.automaticTimestamps + : lCase( trim( variables._meta.originalMetadata.automaticTimestamps & "" ) ) == "true"; + param variables.automaticTimestamps = metadataAutomaticTimestamps; + param variables.createdDateAttribute = variables._meta.originalMetadata.createdDateAttribute; + param variables.modifiedDateAttribute = variables._meta.originalMetadata.modifiedDateAttribute; + variables._attributes = variables._meta.attributes; + variables._columns = variables._meta.columns; + variables._functionNames = variables._meta.functionNames; + variables._nonPersistentProperties = variables._meta.nonPersistentProperties; + variables._grammar = variables._meta.originalMetadata.keyExists( "grammar" ) + ? variables._meta.originalMetadata.grammar + : ""; + variables._discriminatorColumn = variables._meta.localMetadata.keyExists( "discriminatorColumn" ) + ? variables._meta.localMetadata.discriminatorColumn + : ""; + variables._discriminatorValue = variables._meta.localMetadata.keyExists( "discriminatorValue" ) + ? variables._meta.localMetadata.discriminatorValue + : ""; + variables._hasDiscriminatorValue = variables._meta.localMetadata.keyExists( "discriminatorValue" ); + variables._singleTableInheritance = variables._meta.originalMetadata.singleTableInheritance; + variables._virtualAttributes = []; + for ( var declaredVirtualAttribute in variables._meta.virtualAttributes ) { + variables._virtualAttributes.append( declaredVirtualAttribute ); + } + for ( var runtimeAttribute in retrieveRuntimeAttributeDefinitions() ) { + if ( + runtimeAttribute.virtual && + !arrayContainsNoCase( variables._virtualAttributes, runtimeAttribute.name ) + ) { + variables._virtualAttributes.append( runtimeAttribute.name ); + } + if ( runtimeAttribute.virtual && runtimeAttribute.keyExists( "defaultValue" ) ) { + forceAssignAttribute( runtimeAttribute.name, runtimeAttribute.defaultValue ); + } + } + if ( isDiscriminatedChild() ) { + assignAttribute( + variables._parentDefinition.discriminatorColumn, + variables._parentDefinition.discriminatorValue + ); + } + if ( server.keyExists( "boxlang" ) ) { + for ( + var attributeName in retrieveAttributeNames( + withVirtualAttributes = true, + withExcludedAttributes = true + ) + ) { + if ( variables.keyExists( attributeName ) && isNull( variables[ attributeName ] ) ) { + structDelete( variables, attributeName ); + } + } + } + if ( variables._softDeletes && !hasAttribute( variables._softDeleteColumn ) ) { + throw( + type = "QuickSoftDeleteColumnNotFound", + message = "The soft delete attribute [#variables._softDeleteColumn#] was not found on [#entityName()#]." + ); } - variables._readonly = variables._meta.readonly; - explodeAttributesMetadata( variables._meta.attributes ); variables._casts = variables._meta.casts; } + /** + * Normalizes BoxLang metadata annotations to the keys Quick consumes. + */ + private void function normalizeBoxLangMetadata( required struct metadata ) { + if ( arguments.metadata.keyExists( "annotations" ) && isStruct( arguments.metadata.annotations ) ) { + for ( + var key in [ + "mapping", + "entityName", + "table", + "readonly", + "joincolumn", + "discriminatorValue", + "singleTableInheritance", + "datasource", + "grammar", + "discriminatorColumn", + "automaticTimestamps", + "createdDateAttribute", + "modifiedDateAttribute" + ] + ) { + if ( arguments.metadata.annotations.keyExists( key ) && !isNull( arguments.metadata.annotations[ key ] ) ) { + arguments.metadata[ key ] = arguments.metadata.annotations[ key ]; + } + } + } + + if ( arguments.metadata.keyExists( "properties" ) && isArray( arguments.metadata.properties ) ) { + for ( var propertyMetadata in arguments.metadata.properties ) { + if ( propertyMetadata.keyExists( "annotations" ) && isStruct( propertyMetadata.annotations ) ) { + for ( var key in propertyMetadata.annotations ) { + if ( + propertyMetadata.annotations.keyExists( key ) && !isNull( + propertyMetadata.annotations[ key ] + ) + ) { + propertyMetadata[ key ] = propertyMetadata.annotations[ key ]; + } + } + } + } + } + } + /** * Creates an array of all the function names in the metadata. * @@ -2865,12 +4085,13 @@ component accessors="true" { * @return [String] */ private array function generateFunctionNameArray( required array from, struct without = {} ) { - return arguments.from.reduce( function( acc, func ) { - if ( !without.keyExists( func.name ) ) { - acc.append( func.name ); + var functionNames = []; + for ( var func in arguments.from ) { + if ( !arguments.without.keyExists( func.name ) ) { + functionNames.append( func.name ); } - return acc; - }, [] ); + } + return functionNames; } /** @@ -2882,45 +4103,225 @@ component accessors="true" { * @return A struct of attributes for the entity. */ private struct function generateAttributesFromProperties( required array properties ) { - return arguments.properties.reduce( function( acc, prop ) { - var newProp = paramAttribute( arguments.prop ); + var attributes = {}; + for ( var prop in arguments.properties ) { + var newProp = paramAttribute( prop ); if ( !newProp.persistent ) { - return arguments.acc; + continue; + } + attributes[ newProp.name ] = newProp; + } + return attributes; + } + + /** + * Creates an internal property struct for each explicitly fillable, + * non-persistent, non-injected property declared on the entity. + * + * @properties The array of properties declared on the entity. + * + * @return A struct of non-persistent properties for the entity. + */ + private struct function generateNonPersistentProperties( required array properties ) { + var nonPersistentProperties = {}; + for ( var prop in arguments.properties ) { + var newProp = paramAttribute( prop ); + var annotations = newProp.keyExists( "annotations" ) && isStruct( newProp.annotations ) + ? newProp.annotations + : {}; + if ( + newProp.persistent || + !newProp.fillable || + newProp.keyExists( "inject" ) || + annotations.keyExists( "inject" ) + ) { + continue; + } + nonPersistentProperties[ newProp.name ] = newProp; + } + return nonPersistentProperties; + } + + /** + * Returns whether the entity declares a fillable non-persistent property. + * + * @name The property name to check. + */ + private boolean function hasNonPersistentProperty( required string name ) { + return variables._nonPersistentProperties.keyExists( arguments.name ); + } + + private void function guardDuplicatePropertyNames( required struct metadata, required string mapping ) { + var propertyNames = {}; + var entityMapping = arguments.mapping; + for ( var prop in arguments.metadata.properties ) { + if ( propertyNames.keyExists( prop.name ) ) { + throwDuplicateProperty( entityMapping, prop.name ); + } + propertyNames[ prop.name ] = true; + } + + // Some engines collapse duplicate declarations in component metadata. In + // that case, inspect the local component source when it is available. + if ( !arguments.metadata.keyExists( "path" ) || !fileExists( arguments.metadata.path ) ) { + return; + } + + propertyNames = {}; + var source = fileRead( arguments.metadata.path ); + source = reReplace( + source, + "(?s)/[*].*?[*]/|", + " ", + "all" + ); + source = reReplace( source, "(?m)//.*$", " ", "all" ); + var propertyToken = chr( 60 ) & "cfproperty"; + var declarations = reMatchNoCase( "(?is)(^|[^a-z0-9_])(property|#propertyToken#)\s[^;>]*", source ); + for ( var declaration in declarations ) { + var nameAssignment = reFindNoCase( "name\s*=\s*", declaration, 1, true ); + if ( nameAssignment.pos[ 1 ] == 0 ) { + continue; + } + var valueStart = nameAssignment.pos[ 1 ] + nameAssignment.len[ 1 ]; + var quote = mid( declaration, valueStart, 1 ); + if ( quote != chr( 34 ) && quote != chr( 39 ) ) { + continue; + } + var valueEnd = find( quote, declaration, valueStart + 1 ); + if ( valueEnd == 0 ) { + continue; + } + var propertyName = mid( + declaration, + valueStart + 1, + valueEnd - valueStart - 1 + ); + if ( propertyNames.keyExists( propertyName ) ) { + throwDuplicateProperty( entityMapping, propertyName ); } - arguments.acc[ newProp.name ] = newProp; - return arguments.acc; - }, {} ); + propertyNames[ propertyName ] = true; + } + } + + private void function throwDuplicateProperty( required string mapping, required string propertyName ) { + throw( + type = "QuickDuplicateProperty", + message = "[#arguments.mapping#] declares more than one property named [#arguments.propertyName#]. Property names must be unique." + ); } private struct function generateCastsFromProperties( required array properties ) { - return arguments.properties.reduce( function( acc, prop ) { - if ( !arguments.prop.keyExists( "casts" ) || arguments.prop.casts == "" ) { - return arguments.acc; + var casts = {}; + for ( var prop in arguments.properties ) { + if ( !prop.keyExists( "casts" ) || prop.casts == "" ) { + continue; } - arguments.acc[ arguments.prop.name ] = arguments.prop.casts; - return arguments.acc; - }, {} ); + casts[ prop.name ] = prop.casts; + } + return casts; + } + + /** + * Adds inherited attribute definitions to the cached metadata once per + * mapping. Runtime entity instances can then share the completed indexes. + */ + private void function appendParentAttributesToMetadata( required struct meta ) { + if ( !arguments.meta.hasParentEntity ) { + return; + } + + var parentDefinition = arguments.meta.parentDefinition; + var joinAttribute = paramAttribute( { "name" : parentDefinition.joincolumn } ); + if ( !arguments.meta.attributes.keyExists( joinAttribute.name ) ) { + arguments.meta.attributes[ joinAttribute.name ] = joinAttribute; + } + + for ( var alias in parentDefinition.meta.attributes ) { + arguments.meta.attributes[ alias ] = markAttributeAsParent( parentDefinition.meta[ "attributes" ][ alias ] ); + } + + if ( arguments.meta.localMetadata.keyExists( "discriminatorValue" ) ) { + var discriminatorAttribute = paramAttribute( { + "name" : parentDefinition.discriminatorColumn, + "column" : parentDefinition.discriminatorColumn, + "isParentColumn" : true + } ); + arguments.meta.attributes[ discriminatorAttribute.name ] = discriminatorAttribute; + } + } + + private struct function copyAttributeDefinition( required struct attribute ) { + var attributeCopy = {}; + for ( var key in arguments.attribute ) { + if ( !isNull( arguments.attribute[ key ] ) ) { + attributeCopy[ key ] = arguments.attribute[ key ]; + } + } + return attributeCopy; + } + + private struct function markAttributeAsParent( required struct attribute ) { + var parentAttribute = copyAttributeDefinition( arguments.attribute ); + parentAttribute.isParentColumn = true; + return parentAttribute; + } + + private struct function generateColumnsFromAttributes( required struct attributes ) { + var columns = {}; + for ( var alias in arguments.attributes ) { + var attribute = arguments.attributes[ alias ]; + columns[ attribute.column ] = attribute; + } + return columns; + } + + private array function generateVirtualAttributeNames( required struct attributes ) { + var virtualAttributes = []; + for ( var alias in arguments.attributes ) { + if ( arguments.attributes[ alias ].virtual ) { + virtualAttributes.append( alias ); + } + } + return virtualAttributes; } /** * Creates a virtual attribute for the given name. * - * @name The attribute name to create. + * @name The attribute name to create. + * @defaultValue The default value for the virtual attribute. + * @excludeFromMemento Whether to exclude the virtual attribute from mementos. * * @return quick.models.BaseEntity */ - public any function appendVirtualAttribute( required string name, boolean excludeFromMemento = false ) { - if ( !variables._attributes.keyExists( retrieveAliasForColumn( arguments.name ) ) ) { - var attr = paramAttribute( { + public any function appendVirtualAttribute( + required string name, + any defaultValue, + boolean excludeFromMemento = false + ) { + if ( isNull( retrieveAttributeDefinition( arguments.name ) ) ) { + var attributeDefinition = { "name" : arguments.name, "virtual" : true, "exclude" : arguments.excludeFromMemento - } ); - variables._attributes[ attr.name ] = attr; - variables._columns[ attr.column ] = attr; - variables._meta.attributes[ arguments.name ] = variables._attributes[ arguments.name ]; - variables._meta.originalMetadata.properties.append( variables._attributes[ arguments.name ] ); - variables._virtualAttributes.append( arguments.name ); + }; + if ( arguments.keyExists( "defaultValue" ) ) { + attributeDefinition.defaultValue = arguments.defaultValue; + } + var attr = paramAttribute( attributeDefinition ); + registerRuntimeAttribute( attr ); + if ( + !arguments.excludeFromMemento && + structKeyExists( this, "memento" ) && + structKeyExists( this.memento, "defaultIncludes" ) && + !arrayContainsNoCase( this.memento.defaultIncludes, arguments.name ) + ) { + this.memento.defaultIncludes.append( arguments.name ); + } + if ( arguments.keyExists( "defaultValue" ) ) { + forceAssignAttribute( arguments.name, arguments.defaultValue ); + } } return this; } @@ -2930,12 +4331,13 @@ component accessors="true" { } public boolean function isVirtualAttribute( name ) { - return variables._attributes.keyExists( retrieveAliasForColumn( arguments.name ) ) && - variables._attributes[ retrieveAliasForColumn( arguments.name ) ].virtual; + var attribute = retrieveAttributeDefinition( arguments.name ); + return !isNull( attribute ) && attribute.virtual; } public boolean function isParentAttribute( required string column ) { - return variables._attributes[ retrieveAliasForColumn( arguments.column ) ].isParentColumn; + var attribute = retrieveAttributeDefinition( arguments.column ); + return !isNull( attribute ) && attribute.isParentColumn; } /** @@ -2955,8 +4357,17 @@ component accessors="true" { ) { arguments.attr.persistent = arguments.attr.annotations.persistent; } + if ( + !arguments.attr.keyExists( "fillable" ) && + arguments.attr.keyExists( "annotations" ) && + isStruct( arguments.attr.annotations ) && + arguments.attr.annotations.keyExists( "fillable" ) + ) { + arguments.attr.fillable = arguments.attr.annotations.fillable; + } param attr.column = arguments.attr.name; param attr.persistent = true; + param attr.fillable = false; param attr.nullValue = ""; param attr.convertToNull = true; param attr.casts = ""; @@ -2964,36 +4375,20 @@ component accessors="true" { param attr.sqltype = ""; param attr.insert = true; param attr.update = true; + param attr.refreshOnSave = false; param attr.virtual = false; param attr.exclude = false; param attr.isParentColumn = false; if ( !isBoolean( attr.persistent ) ) { attr.persistent = lCase( trim( attr.persistent & "" ) ) == "true"; } - variables._nullValues[ attr.name ] = attr.nullValue; - return arguments.attr; - } - - /** - * Sets up some other helper structs for Quick to quickly check metadata. - * - * @attributes The attributes to explode - */ - private any function explodeAttributesMetadata( required struct attributes ) { - for ( var alias in arguments.attributes ) { - var attr = paramAttribute( arguments.attributes[ alias ] ); - variables._attributes[ attr.name ] = attr; - variables._columns[ attr.column ] = attr; - if ( attr.convertToNull ) { - variables._nullValues[ alias ] = attr.nullValue; - } + if ( !isBoolean( attr.fillable ) ) { + attr.fillable = lCase( trim( attr.fillable & "" ) ) == "true"; } - - if ( hasParentEntity() ) { - explodeParentAttributes(); + if ( !isBoolean( attr.refreshOnSave ) ) { + attr.refreshOnSave = lCase( trim( attr.refreshOnSave & "" ) ) == "true"; } - - return this; + return arguments.attr; } /*================================= @@ -3005,12 +4400,19 @@ component accessors="true" { } public boolean function isDiscriminatedChild() { - return hasParentEntity() && variables._meta.localMetadata.keyExists( "discriminatorValue" ); + return hasParentEntity() && variables._hasDiscriminatorValue; } public boolean function isDiscriminatedParent() { - return variables._meta.localMetadata.keyExists( "discriminatorColumn" ) - && variables._discriminators.len() > 0; + return variables._discriminatorColumn != "" && variables._discriminators.len() > 0; + } + + public string function discriminatorColumn() { + return variables._discriminatorColumn; + } + + public string function discriminatorValue() { + return variables._discriminatorValue; } public function getParentDefinition() { @@ -3018,93 +4420,77 @@ component accessors="true" { } public function getDiscriminations() { - return variables._cache.getOrSet( "quick-metadata:#variables._mapping#-discriminations", function() { - return variables._discriminators.reduce( function( acc, dsl ) { - var childClass = variables._wirebox.getInstance( - dsl = dsl, - initArguments = { "meta" : {}, "shallow" : true } - ); - var childMeta = childClass.get_Meta().localMetaData; - // Ensure if polymorphic association that a join column and discriminator value are passed. - // Can be ignored for singleTableInheritance since there's no join - if ( - !isSingleTableInheritance() && ( - !structKeyExists( childMeta, "joincolumn" ) || - !structKeyExists( childMeta, "discriminatorValue" ) - ) - ) { - throw( - type = "QuickParentInstantiationException", - message = "Failed to instantiate the parent entity [#variables._meta.fullName#]. The discriminated child class [#childMeta.fullName#] did not contain either a `joinColumn` or `discriminatorValue` attribute" + var registry = getDefinitionRegistry(); + var discriminations = registry.getDerived( + variables._mapping, + "discriminations", + "declared" + ); + if ( !isNull( discriminations ) ) { + return discriminations; + } + return registry.getOrCreateDerived( + mapping = variables._mapping, + group = "discriminations", + variant = "declared", + limit = 1, + factory = function() { + var discriminations = {}; + for ( var dsl in variables._discriminators ) { + var childClass = variables._wirebox.getInstance( + dsl = dsl, + initArguments = { "meta" : {}, "shallow" : true } ); - } - var childAttributes = childClass - .get_Attributes() - .reduce( function( acc, attr, data ) { - if ( !data.isParentColumn && !data.virtual && !data.exclude ) { - acc.append( data ); + var childMeta = childClass.get_Meta().localMetaData; + // Ensure if polymorphic association that a join column and discriminator value are passed. + // Can be ignored for singleTableInheritance since there's no join + if ( + !isSingleTableInheritance() && ( + !structKeyExists( childMeta, "joincolumn" ) || + !structKeyExists( childMeta, "discriminatorValue" ) + ) + ) { + throw( + type = "QuickParentInstantiationException", + message = "Failed to instantiate the parent entity [#variables._fullName#]. The discriminated child class [#childMeta.fullName#] did not contain either a `joinColumn` or `discriminatorValue` attribute" + ); + } + var childAttributes = []; + var childAttributeDefinitions = childClass.get_Attributes(); + for ( var attr in childAttributeDefinitions ) { + var attributeData = childAttributeDefinitions[ attr ]; + if ( !attributeData.isParentColumn && !attributeData.virtual && !attributeData.exclude ) { + childAttributes.append( attributeData ); } - return acc; - }, [] ); - - var localColumns = this.retrieveQualifiedColumns(); - var childColumns = childClass - .retrieveQualifiedColumns() - .filter( function( column ) { - return !arrayContainsNoCase( localColumns, column ); - } ); - - acc[ childMeta.discriminatorValue ] = { - "mapping" : childMeta.fullName, - "table" : ( childMeta.keyExists( "table" ) ? childMeta.table : variables._meta.table ), - "joincolumn" : ( - childMeta.keyExists( "joinColumn" ) ? childClass.qualifyColumn( - column = childMeta.joinColumn, - useParentLookup = false - ) : "" - ), - "attributes" : childAttributes, - "childColumns" : childColumns - }; - return acc; - }, {} ); - } ); - } - - /** - * Appends parent attributes as first class attributes - **/ - private function explodeParentAttributes() { - if ( !hasParentEntity() ) return; - - var parentDefinition = getParentDefinition(); - - var attr = paramAttribute( { "name" : parentDefinition.joincolumn } ); - variables._attributes[ attr.name ] = variables._attributes[ attr.name ] ?: attr; - variables._columns[ attr.column ] = variables._columns[ attr.column ] ?: attr; - - parentDefinition.meta.attributes - .keyArray() - .each( function( alias ) { - // Note: bracket notation here on `attributes` as ACF 2016 will sometimes show a null for the dot notation key - var duplicateAttr = structCopy( parentDefinition.meta[ "attributes" ][ alias ] ); - duplicateAttr.isParentColumn = true; - variables._attributes[ duplicateAttr.name ] = duplicateAttr; - variables._columns[ duplicateAttr.column ] = duplicateAttr; - } ); + } - if ( isDiscriminatedChild() ) { - var discriminatorAttr = paramAttribute( { - "name" : parentDefinition.discriminatorColumn, - "column" : parentDefinition.discriminatorColumn, - "isParentColumn" : true - } ); - variables._attributes[ discriminatorAttr.name ] = discriminatorAttr; - variables._columns[ discriminatorAttr.column ] = discriminatorAttr; - assignAttribute( parentDefinition.discriminatorColumn, parentDefinition.discriminatorValue ); - } + var localColumns = this.retrieveQualifiedColumns(); + var childColumns = []; + for ( var column in childClass.retrieveQualifiedColumns() ) { + if ( !arrayContainsNoCase( localColumns, column ) ) { + childColumns.append( column ); + } + } + + discriminations[ childMeta.discriminatorValue ] = { + "mapping" : childMeta.fullName, + "table" : ( childMeta.keyExists( "table" ) ? childMeta.table : variables._table ), + "joincolumn" : ( + childMeta.keyExists( "joinColumn" ) ? childClass.qualifyColumn( + column = childMeta.joinColumn, + useParentLookup = false + ) : "" + ), + "attributes" : childAttributes, + "childColumns" : childColumns + }; + } + return discriminations; + } + ); } + /*================================= = Read-Only = =================================*/ @@ -3182,9 +4568,11 @@ component accessors="true" { * @return Boolean */ private boolean function isReadOnlyAttribute( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes.keyExists( alias ) && - variables._attributes[ alias ].readOnly; + var attribute = retrieveAttributeDefinition( arguments.name ); + return ( !isNull( attribute ) && attribute.readOnly ) || ( + variables._nonPersistentProperties.keyExists( arguments.name ) && + variables._nonPersistentProperties[ arguments.name ].readOnly + ); } /** @@ -3331,22 +4719,35 @@ component accessors="true" { invoke( this, arguments.eventName, - { eventData : arguments.eventData } + { eventData : arguments.eventData } ); } - if ( !isNull( variables._interceptorService ) ) { - param variables.useAnnounceMethodForInterceptorService = structKeyExists( - variables._interceptorService, - "announce" - ); - if ( variables.useAnnounceMethodForInterceptorService ) { - variables._interceptorService.announce( "quick" & arguments.eventName, arguments.eventData ); - } else { - variables._interceptorService.processState( "quick" & arguments.eventName, arguments.eventData ); + announceInterceptionPoint( "quick" & arguments.eventName, arguments.eventData ); + if ( variables._dispatchesEvents.keyExists( arguments.eventName ) ) { + for ( var interceptionPoint in arrayWrap( variables._dispatchesEvents[ arguments.eventName ] ) ) { + announceInterceptionPoint( interceptionPoint, arguments.eventData ); } } } + /** + * Announces an interception point using the configured interceptor service. + * + * @interceptionPoint The interception point to announce. + * @eventData The data associated with the interception point. + */ + private void function announceInterceptionPoint( required string interceptionPoint, required struct eventData ) { + if ( isNull( variables._interceptorService ) ) { + return; + } + param variables.useAnnounceMethodForInterceptorService = structKeyExists( variables._interceptorService, "announce" ); + if ( variables.useAnnounceMethodForInterceptorService ) { + variables._interceptorService.announce( arguments.interceptionPoint, arguments.eventData ); + } else { + variables._interceptorService.processState( arguments.interceptionPoint, arguments.eventData ); + } + } + /** * Returns true if the event method exists on the entity. * @@ -3366,9 +4767,8 @@ component accessors="true" { * @return Boolean */ public boolean function attributeHasSqlType( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes.keyExists( alias ) && - variables._attributes[ alias ].sqltype != ""; + var attribute = retrieveAttributeDefinition( arguments.name ); + return !isNull( attribute ) && attribute.sqltype != ""; } /** @@ -3379,8 +4779,7 @@ component accessors="true" { * @return String */ public string function retrieveSqlTypeForAttribute( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes[ alias ].sqltype; + return retrieveAttributeDefinition( arguments.name ).sqltype; } /** @@ -3408,11 +4807,11 @@ component accessors="true" { * * @return Boolean */ - public boolean function isNullValue( required string key, any value ) { - if ( !isDefined( "arguments.value" ) ) { - // There is potential for the value of an attribute to be an actuall null value - // We must use isDefined instead of cfparam as returning a null value from invoke - // into the 'default' argument of cfparam will raise an exception + public boolean function isNullValue( required string key, any value = variables._nullValueArgumentSentinel ) { + if ( variables._nullValueArgumentSentinel.equals( arguments.value ) ) { + // There is potential for the value of an attribute to be an actual null value. + // Returning a null value from invoke into the 'default' argument of cfparam + // would raise an exception, so retrieve the current value directly. arguments.value = invoke( this, "get" & arguments.key ); } @@ -3425,8 +4824,8 @@ component accessors="true" { return false; } - return variables._nullValues.keyExists( alias ) && - compare( variables._nullValues[ alias ], arguments.value ) == 0; + var attribute = retrieveAttributeDefinition( alias ); + return !isNull( attribute ) && compare( attribute.nullValue, arguments.value ) == 0; } /** @@ -3452,24 +4851,26 @@ component accessors="true" { } if ( !structKeyExists( variables._casts, arguments.key ) ) { - return arguments.value; - } - - if ( !isVirtualAttribute( arguments.key ) && isNullValue( arguments.key, arguments.value ) ) { - return arguments.value; + return isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value; } var castMapping = variables._casts[ arguments.key ]; if ( !variables._casterCache.keyExists( arguments.key ) ) { variables._casterCache[ arguments.key ] = variables._wirebox.getInstance( dsl = castMapping ); } - var caster = variables._casterCache[ arguments.key ]; - variables._castCache[ arguments.key ] = caster.get( + var caster = variables._casterCache[ arguments.key ]; + var castedValue = caster.get( entity = this, key = arguments.key, value = isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value ); - return variables._castCache[ arguments.key ]; + if ( isNull( castedValue ) ) { + structDelete( variables._castCache, arguments.key ); + return javacast( "null", "" ); + } + + variables._castCache[ arguments.key ] = castedValue; + return castedValue; } /** @@ -3520,6 +4921,14 @@ component accessors="true" { } var caster = variables._casterCache[ key ]; var attrs = caster.set( this, key, castedValue ); + if ( isNull( attrs ) ) { + assignAttribute( + name = key, + value = javacast( "null", "" ), + cast = false + ); + continue; + } if ( !isStruct( attrs ) ) { attrs = { "#key#" : attrs }; } @@ -3559,10 +4968,11 @@ component accessors="true" { * @return Boolean */ private boolean function canUpdateAttribute( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes.keyExists( alias ) && - variables._attributes[ alias ].update && - !variables._attributes[ alias ].isParentColumn; + var attribute = retrieveAttributeDefinition( arguments.name ); + return !isNull( attribute ) && + attribute.update && + !attribute.readOnly && + !attribute.isParentColumn; } /** @@ -3573,16 +4983,16 @@ component accessors="true" { * @return Boolean */ private boolean function canInsertAttribute( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes.keyExists( alias ) && - variables._attributes[ alias ].insert && - !variables._attributes[ alias ].isParentColumn; + var attribute = retrieveAttributeDefinition( arguments.name ); + return !isNull( attribute ) && + attribute.insert && + !attribute.readOnly && + !attribute.isParentColumn; } public boolean function canConvertToNull( required string name ) { - var alias = retrieveAliasForColumn( arguments.name ); - return variables._attributes.keyExists( alias ) && - variables._attributes[ alias ].convertToNull; + var attribute = retrieveAttributeDefinition( arguments.name ); + return !isNull( attribute ) && attribute.convertToNull; } /** @@ -3615,9 +5025,10 @@ component accessors="true" { return arguments.arrays; } - var lengths = arguments.arrays.map( function( arr ) { - return arr.len(); - } ); + var lengths = []; + for ( var arr in arguments.arrays ) { + lengths.append( arr.len() ); + } if ( unique( lengths ).len() > 1 ) { throw( type = "ArrayZipLengthMismatch", @@ -3654,7 +5065,7 @@ component accessors="true" { * since the data for each sub entity originates from a single table */ public boolean function isSingleTableInheritance() { - return variables._meta.originalMetadata.singleTableInheritance; + return variables._singleTableInheritance; } } diff --git a/models/CBORMCompatEntity.cfc b/models/CBORMCompatEntity.cfc index 6fbb760e..d6179ffc 100644 --- a/models/CBORMCompatEntity.cfc +++ b/models/CBORMCompatEntity.cfc @@ -12,14 +12,15 @@ component extends="quick.models.BaseEntity" accessors="true" { * @return A struct of attributes for the entity. */ private struct function generateAttributesFromProperties( required array properties ) { - return arguments.properties.reduce( function( acc, prop ) { - var newProp = paramAttribute( arguments.prop ); + var attributes = {}; + for ( var prop in arguments.properties ) { + var newProp = paramAttribute( prop ); if ( !newProp.persistent || newProp.keyExists( "fieldtype" ) ) { - return arguments.acc; + continue; } - arguments.acc[ newProp.name ] = newProp; - return arguments.acc; - }, {} ); + attributes[ newProp.name ] = newProp; + } + return attributes; } function list( @@ -32,9 +33,9 @@ component extends="quick.models.BaseEntity" accessors="true" { boolean asQuery = true ) { var builder = newQuery(); - structEach( criteria, function( key, value ) { - builder.where( retrieveColumnForAlias( key ), value ); - } ); + for ( var key in criteria ) { + builder.where( retrieveColumnForAlias( key ), criteria[ key ] ); + } if ( !isNull( sortOrder ) ) { builder.orderBy( sortOrder ); } @@ -88,13 +89,14 @@ component extends="quick.models.BaseEntity" accessors="true" { function findAllWhere( criteria = {}, sortOrder ) { var builder = newQuery(); - structEach( criteria, function( key, value ) { - builder.where( retrieveColumnForAlias( key ), value ); - } ); + for ( var key in criteria ) { + builder.where( retrieveColumnForAlias( key ), criteria[ key ] ); + } if ( !isNull( sortOrder ) ) { - var sorts = listToArray( sortOrder, "," ).map( function( sort ) { - return replace( sort, " ", "|", "ALL" ); - } ); + var sorts = []; + for ( var sort in listToArray( sortOrder, "," ) ) { + sorts.append( replace( sort, " ", "|", "ALL" ) ); + } builder.orderBy( sorts ); } return builder.get(); @@ -102,9 +104,9 @@ component extends="quick.models.BaseEntity" accessors="true" { function findWhere( criteria = {} ) { var builder = newQuery(); - structEach( criteria, function( key, value ) { - builder.where( retrieveColumnForAlias( key ), value ); - } ); + for ( var key in criteria ) { + builder.where( retrieveColumnForAlias( key ), criteria[ key ] ); + } return builder.first(); } @@ -119,9 +121,10 @@ component extends="quick.models.BaseEntity" accessors="true" { var builder = newQuery(); if ( isNull( id ) ) { if ( !isNull( sortOrder ) ) { - var sorts = listToArray( sortOrder, "," ).map( function( sort ) { - return replace( sort, " ", "|", "ALL" ); - } ); + var sorts = []; + for ( var sort in listToArray( sortOrder, "," ) ) { + sorts.append( replace( sort, " ", "|", "ALL" ) ); + } builder.orderBy( sorts ); } return builder.get(); @@ -149,9 +152,9 @@ component extends="quick.models.BaseEntity" accessors="true" { } function saveAll( entities = [] ) { - entities.each( function( entity ) { + for ( var entity in arguments.entities ) { entity.save(); - } ); + } return this; } diff --git a/models/CBORMCriteriaBuilderCompat.cfc b/models/CBORMCriteriaBuilderCompat.cfc index 12dc6eba..99c4cb42 100644 --- a/models/CBORMCriteriaBuilderCompat.cfc +++ b/models/CBORMCriteriaBuilderCompat.cfc @@ -122,12 +122,12 @@ component } function order( orders ) { - arguments.orders = isArray( arguments.orders ) ? arguments.orders : listToArray( arguments.orders, "," ); - variables.qb.orderBy( - arguments.orders.map( function( order ) { - return replace( order, " ", "|" ); - } ) - ); + arguments.orders = isArray( arguments.orders ) ? arguments.orders : listToArray( arguments.orders, "," ); + var normalizedOrders = []; + for ( var order in arguments.orders ) { + normalizedOrders.append( replace( order, " ", "|" ) ); + } + variables.qb.orderBy( normalizedOrders ); return this; } diff --git a/models/Casts/BooleanCast.cfc b/models/Casts/BooleanCast.cfc index b125834c..292efe26 100644 --- a/models/Casts/BooleanCast.cfc +++ b/models/Casts/BooleanCast.cfc @@ -14,7 +14,11 @@ component singleton { required string key, any value ) { - return isNull( arguments.value ) ? false : !!arguments.value; + if ( isNull( arguments.value ) ) { + return javacast( "null", "" ); + } + + return arguments.entity.isNullValue( arguments.key, arguments.value ) ? arguments.value : !!arguments.value; } /** @@ -32,6 +36,10 @@ component singleton { required string key, any value ) { + if ( isNull( arguments.value ) || arguments.entity.isNullValue( arguments.key, arguments.value ) ) { + return javacast( "null", "" ); + } + return arguments.value ? 1 : 0; } diff --git a/models/EntityDefinitionRegistry.cfc b/models/EntityDefinitionRegistry.cfc new file mode 100644 index 00000000..90ca85d6 --- /dev/null +++ b/models/EntityDefinitionRegistry.cfc @@ -0,0 +1,192 @@ +/** + * Process-local registry for immutable entity definitions and their bounded + * derived views. Definitions live for the module lifecycle and never compete + * with request-shaped derived entries for eviction space. + */ +component singleton { + + public any function init( numeric defaultDerivedLimit = 16 ) { + variables.defaultDerivedLimit = max( 1, arguments.defaultDerivedLimit ); + variables.definitions = newConcurrentMap(); + variables.derivedBuckets = newConcurrentMap(); + variables.definitionLock = newReentrantLock(); + variables.derivedLock = newReentrantLock(); + variables.definitionCompilationCount = newAtomicLong(); + variables.derivedCompilationCount = newAtomicLong(); + variables.derivedEvictionCount = newAtomicLong(); + return this; + } + + public any function getOrCreateDefinition( required string name, required any factory ) { + var definition = variables.definitions.get( arguments.name ); + if ( !isNull( definition ) ) { + return definition; + } + + variables.definitionLock.lock(); + try { + definition = variables.definitions.get( arguments.name ); + if ( isNull( definition ) ) { + definition = arguments.factory(); + if ( isNull( definition ) ) { + throw( + type = "QuickEntityDefinitionMissing", + message = "The entity definition factory for [#arguments.name#] returned null." + ); + } + variables.definitions.put( arguments.name, definition ); + variables.definitionCompilationCount.incrementAndGet(); + } + } finally { + variables.definitionLock.unlock(); + } + return definition; + } + + public any function getDefinition( required string name ) { + return variables.definitions.get( arguments.name ); + } + + public boolean function hasDefinition( required string name ) { + return variables.definitions.containsKey( arguments.name ); + } + + public boolean function clearDefinition( required string name ) { + var removed = variables.definitions.remove( arguments.name ); + clearDerived( arguments.name ); + return !isNull( removed ); + } + + public any function getOrCreateDerived( + required string mapping, + required string group, + required string variant, + required any factory, + numeric limit = variables.defaultDerivedLimit + ) { + arguments.limit = max( 1, arguments.limit ); + var bucketKey = derivedBucketKey( arguments.mapping, arguments.group ); + var bucket = variables.derivedBuckets.get( bucketKey ); + if ( !isNull( bucket ) && bucket.values.containsKey( arguments.variant ) ) { + return bucket.values.get( arguments.variant ); + } + + variables.derivedLock.lock(); + try { + bucket = getOrCreateDerivedBucket( bucketKey ); + if ( !bucket.values.containsKey( arguments.variant ) ) { + var derived = arguments.factory(); + if ( isNull( derived ) ) { + throw( + type = "QuickDerivedDefinitionMissing", + message = "The derived definition factory for [#arguments.mapping#:#arguments.group#:#arguments.variant#] returned null." + ); + } + bucket.values.put( arguments.variant, derived ); + bucket.order.add( arguments.variant ); + variables.derivedCompilationCount.incrementAndGet(); + enforceDerivedLimit( bucket, arguments.limit ); + } + } finally { + variables.derivedLock.unlock(); + } + return bucket.values.get( arguments.variant ); + } + + public any function getDerived( + required string mapping, + required string group, + required string variant + ) { + var bucket = variables.derivedBuckets.get( derivedBucketKey( arguments.mapping, arguments.group ) ); + if ( isNull( bucket ) ) { + return javacast( "null", "" ); + } + return bucket.values.get( arguments.variant ); + } + + public void function clearDerived( string mapping ) { + if ( isNull( arguments.mapping ) ) { + variables.derivedBuckets.clear(); + return; + } + var prefix = arguments.mapping & chr( 31 ); + var iterator = variables.derivedBuckets.keySet().iterator(); + var keys = []; + while ( iterator.hasNext() ) { + var key = iterator.next(); + if ( left( key, len( prefix ) ) == prefix ) { + keys.append( key ); + } + } + for ( var key in keys ) { + variables.derivedBuckets.remove( key ); + } + } + + public void function clear() { + variables.definitions.clear(); + variables.derivedBuckets.clear(); + variables.definitionCompilationCount.set( 0 ); + variables.derivedCompilationCount.set( 0 ); + variables.derivedEvictionCount.set( 0 ); + } + + public struct function getStats() { + var derivedEntryCount = 0; + var iterator = variables.derivedBuckets.values().iterator(); + while ( iterator.hasNext() ) { + derivedEntryCount += iterator.next().values.size(); + } + return { + "definitionCount" : variables.definitions.size(), + "definitionCompilationCount" : variables.definitionCompilationCount.get(), + "derivedBucketCount" : variables.derivedBuckets.size(), + "derivedEntryCount" : derivedEntryCount, + "derivedCompilationCount" : variables.derivedCompilationCount.get(), + "derivedEvictionCount" : variables.derivedEvictionCount.get() + }; + } + + private any function getOrCreateDerivedBucket( required string bucketKey ) { + var bucket = variables.derivedBuckets.get( arguments.bucketKey ); + if ( !isNull( bucket ) ) { + return bucket; + } + var candidate = { + "values" : newConcurrentMap(), + "order" : createObject( "java", "java.util.concurrent.ConcurrentLinkedQueue" ).init() + }; + var existing = variables.derivedBuckets.putIfAbsent( arguments.bucketKey, candidate ); + return isNull( existing ) ? candidate : existing; + } + + private void function enforceDerivedLimit( required struct bucket, required numeric limit ) { + while ( arguments.bucket.values.size() > arguments.limit ) { + var evictedKey = arguments.bucket.order.poll(); + if ( isNull( evictedKey ) ) { + return; + } + if ( !isNull( arguments.bucket.values.remove( evictedKey ) ) ) { + variables.derivedEvictionCount.incrementAndGet(); + } + } + } + + private string function derivedBucketKey( required string mapping, required string group ) { + return arguments.mapping & chr( 31 ) & arguments.group; + } + + private any function newConcurrentMap() { + return createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + } + + private any function newAtomicLong() { + return createObject( "java", "java.util.concurrent.atomic.AtomicLong" ).init( 0 ); + } + + private any function newReentrantLock() { + return createObject( "java", "java.util.concurrent.locks.ReentrantLock" ).init(); + } + +} diff --git a/models/KeyTypes/AutoIncrementingKeyType.cfc b/models/KeyTypes/AutoIncrementingKeyType.cfc index a20df316..f2419489 100644 --- a/models/KeyTypes/AutoIncrementingKeyType.cfc +++ b/models/KeyTypes/AutoIncrementingKeyType.cfc @@ -31,10 +31,23 @@ component implements="KeyType" { * @return void */ public void function postInsert( required any entity, required struct result ) { + // Joined child entities inherit their key from the parent row before this insert. + if ( arguments.entity.hasParentEntity() ) { + return; + } var keyName = arguments.entity.keyNames()[ 1 ]; - var generatedKey = arguments.result.result.keyExists( keyName ) ? arguments.result.result[ keyName ] : arguments.result.result.keyExists( - "generated_key" - ) ? arguments.result.result[ "generated_key" ] : arguments.result.result[ "generatedKey" ]; + var keyColumn = arguments.entity.keyColumns()[ 1 ]; + var generatedKey = arguments.result.keyExists( "query" ) && + !isNull( arguments.result.query ) && + isQuery( arguments.result.query ) && + arguments.result.query.recordCount > 0 && + listFindNoCase( arguments.result.query.columnList, keyColumn ) + ? arguments.result.query[ keyColumn ][ 1 ] + : arguments.result.result.keyExists( keyName ) + ? arguments.result.result[ keyName ] + : arguments.result.result.keyExists( "generated_key" ) + ? arguments.result.result[ "generated_key" ] + : arguments.result.result[ "generatedKey" ]; arguments.entity.assignAttribute( keyName, int( val( generatedKey ) ) ); } diff --git a/models/KeyTypes/GUIDKeyType.cfc b/models/KeyTypes/GUIDKeyType.cfc index cbc8670c..87627221 100644 --- a/models/KeyTypes/GUIDKeyType.cfc +++ b/models/KeyTypes/GUIDKeyType.cfc @@ -12,13 +12,11 @@ component implements="KeyType" { * @return void */ public void function preInsert( required any entity, required any builder ) { - arguments.entity - .keyNames() - .each( function( keyName ) { - if ( entity.isNullAttribute( keyName ) ) { - entity.assignAttribute( keyName, createGUID() ); - } - } ); + for ( var keyName in arguments.entity.keyNames() ) { + if ( arguments.entity.isNullAttribute( keyName ) ) { + arguments.entity.assignAttribute( keyName, createGUID() ); + } + } } /** diff --git a/models/KeyTypes/ReturningKeyType.cfc b/models/KeyTypes/ReturningKeyType.cfc index 0a269568..3c8de023 100644 --- a/models/KeyTypes/ReturningKeyType.cfc +++ b/models/KeyTypes/ReturningKeyType.cfc @@ -24,11 +24,9 @@ component implements="KeyType" { * @return void */ public void function postInsert( required any entity, required struct result ) { - arguments.entity - .keyColumns() - .each( function( keyColumn ) { - entity.assignAttribute( keyColumn, result.query[ keyColumn ] ); - } ); + for ( var keyColumn in arguments.entity.keyColumns() ) { + arguments.entity.assignAttribute( keyColumn, arguments.result.query[ keyColumn ] ); + } } } diff --git a/models/KeyTypes/RowIDKeyType.cfc b/models/KeyTypes/RowIDKeyType.cfc index 23c4b17c..b46961dd 100644 --- a/models/KeyTypes/RowIDKeyType.cfc +++ b/models/KeyTypes/RowIDKeyType.cfc @@ -28,6 +28,10 @@ component implements="KeyType" { * @return void */ public void function postInsert( required any entity, required struct result ) { + // Joined child entities inherit their key from the parent row before this insert. + if ( arguments.entity.hasParentEntity() ) { + return; + } var keyName = arguments.entity.keyNames()[ 1 ]; var rowID = arguments.result.result.keyExists( keyName ) ? arguments.result.result[ keyName ] : arguments.result.result.keyExists( "generated_key" diff --git a/models/KeyTypes/UUIDKeyType.cfc b/models/KeyTypes/UUIDKeyType.cfc index 33fa8a30..87ecffce 100644 --- a/models/KeyTypes/UUIDKeyType.cfc +++ b/models/KeyTypes/UUIDKeyType.cfc @@ -12,13 +12,16 @@ component implements="KeyType" { * @return void */ public void function preInsert( required any entity, required any builder ) { - arguments.entity - .keyNames() - .each( function( keyName ) { - if ( entity.isNullAttribute( keyName ) ) { - entity.assignAttribute( keyName, createUUID() ); + for ( var keyName in arguments.entity.keyNames() ) { + if ( arguments.entity.isNullAttribute( keyName ) ) { + var uuid = createUUID(); + var uuidParts = listToArray( uuid, "-" ); + if ( uuidParts.len() == 5 ) { + uuid = "#uuidParts[ 1 ]#-#uuidParts[ 2 ]#-#uuidParts[ 3 ]#-#uuidParts[ 4 ]##uuidParts[ 5 ]#"; } - } ); + arguments.entity.assignAttribute( keyName, uuid ); + } + } } /** diff --git a/models/QuickBuilder.cfc b/models/QuickBuilder.cfc index 998cd225..8105492f 100644 --- a/models/QuickBuilder.cfc +++ b/models/QuickBuilder.cfc @@ -75,6 +75,16 @@ component accessors="true" transientCache="false" { property name="_asMemento" default="false"; property name="_asMementoSettings"; + /** + * Callbacks applied to each hydrated entity before return transformations. + */ + property name="_entityTransformers"; + + /** + * Whether automatic timestamps are disabled for mutations created by this builder. + */ + property name="_withoutAutomaticTimestamps" default="false"; + /** * Used to quickly identify QueryBuilder instances * instead of resorting to `isInstanceOf` which is slow. @@ -88,14 +98,16 @@ component accessors="true" transientCache="false" { this.isQuickBuilder = true; function init() { - variables._eagerLoad = []; - variables._globalScopesApplied = false; - variables._globalScopeExcludeAll = false; - variables._asMemento = false; - variables._asQuery = false; - variables._withAliases = false; - param variables._preventLazyLoading = false; - if ( isNull( variables._lazyLoadingViolationCallback ) ) { + variables._eagerLoad = []; + variables._globalScopesApplied = false; + variables._globalScopeExcludeAll = false; + variables._asMemento = false; + variables._asQuery = false; + variables._withAliases = false; + variables._entityTransformers = []; + variables._withoutAutomaticTimestamps = false; + param variables._preventLazyLoading = false; + if ( !variables.keyExists( "_lazyLoadingViolationCallback" ) || isNull( variables._lazyLoadingViolationCallback ) ) { variables._lazyLoadingViolationCallback = ( entity, relationName ) => { throw( type = "QuickLazyLoadingException", @@ -109,6 +121,18 @@ component accessors="true" transientCache="false" { return this; } + /** + * Adds a callback that transforms each hydrated entity before it is returned. + * + * @transformer The callback accepting and returning an entity. + * + * @return quick.models.QuickBuilder + */ + public QuickBuilder function addEntityTransformer( required any transformer ) { + variables._entityTransformers.append( arguments.transformer ); + return this; + } + function onDIComplete() { variables.qb.setQuickBuilder( this ); variables.qb.setColumnFormatter( function( column ) { @@ -118,11 +142,43 @@ component accessors="true" transientCache="false" { public QuickBuilder function setEntity( required any newEntity ) { variables.entity = arguments.newEntity; + variables.entity.set_withoutAutomaticTimestamps( variables._withoutAutomaticTimestamps ); variables.qb.setEntity( arguments.newEntity ); variables.aliasMap[ arguments.newEntity.tableAlias() ] = arguments.newEntity; return this; } + /** + * Disables automatic timestamps for mutations and entities created by this builder. + */ + public QuickBuilder function withoutAutomaticTimestamps() { + variables._withoutAutomaticTimestamps = true; + getEntity().set_withoutAutomaticTimestamps( true ); + return this; + } + + /** + * Resolves a relationship without allocating nested guard callbacks. + */ + private any function resolveRelationship( + required any entity, + required string relationshipName, + boolean withoutConstraints = true + ) { + arguments.entity.set_ignoreNotLoadedGuard( true ); + if ( arguments.withoutConstraints ) { + arguments.entity.get_withoutRelationshipConstraints().add( lCase( arguments.relationshipName ) ); + } + try { + return invoke( arguments.entity, arguments.relationshipName ); + } finally { + arguments.entity.set_ignoreNotLoadedGuard( false ); + if ( arguments.withoutConstraints ) { + arguments.entity.get_withoutRelationshipConstraints().remove( lCase( arguments.relationshipName ) ); + } + } + } + /** * Sets an alias for the current table name. * @@ -138,6 +194,29 @@ component accessors="true" transientCache="false" { return this; } + private void function ensureKeyColumnsSelected() { + var selectedColumns = variables.qb.getColumns(); + for ( var column in selectedColumns ) { + if ( column.type == "simple" && column.value.find( "*" ) ) { + return; + } + } + + for ( var keyColumn in getEntity().keyColumns() ) { + var qualifiedKey = getEntity().qualifyColumn( keyColumn ); + var hasKey = false; + for ( var column in selectedColumns ) { + if ( column.type == "simple" && compareNoCase( column.value, qualifiedKey ) == 0 ) { + hasKey = true; + break; + } + } + if ( !hasKey ) { + variables.qb.addSelect( qualifiedKey ); + } + } + } + /** * Adds a subselect query with the given name to the entity. * Useful for computed properties and computed relationship keys. @@ -173,21 +252,9 @@ component accessors="true" transientCache="false" { var relationshipName = listFirst( column, "." ); if ( isNull( q ) ) { - q = getEntity().ignoreLoadedGuard( function() { - return getEntity().withoutRelationshipConstraints( relationshipName, function() { - return invoke( getEntity(), relationshipName ).addCompareConstraints(); - } ); - } ); + q = resolveRelationship( getEntity(), relationshipName ).addCompareConstraints(); } else { - var relationship = q - .getEntity() - .ignoreLoadedGuard( function() { - return q - .getEntity() - .withoutRelationshipConstraints( relationshipName, function() { - return invoke( q.getEntity(), relationshipName ); - } ); - } ); + var relationship = resolveRelationship( q.getEntity(), relationshipName ); q.select( q.raw( 1 ) ); if ( isStruct( qb ) && structKeyExists( qb, "isQuickBuilder" ) ) { q.getQB(); @@ -275,13 +342,14 @@ component accessors="true" transientCache="false" { var builders = []; for ( var r in arrayWrap( arguments.relation ) ) { var relationName = r; - var callback = function() { - }; + var callback = ""; + var hasCallback = false; if ( isStruct( r ) ) { for ( var key in r ) { relationName = key; callback = r[ key ]; + hasCallback = true; break; } } @@ -296,15 +364,11 @@ component accessors="true" transientCache="false" { subselectName = parts[ 2 ]; } - var countBuilder = getEntity().ignoreLoadedGuard( function() { - return getEntity().withoutRelationshipConstraints( relationName, function() { - return invoke( getEntity(), relationName ) - .addCompareConstraints() - .when( true, callback ) - .clearOrders() - .reselectRaw( "COUNT(*)" ); - } ); - } ); + var countBuilder = resolveRelationship( getEntity(), relationName ).addCompareConstraints(); + if ( hasCallback ) { + countBuilder.when( true, callback ); + } + countBuilder.clearOrders().reselectRaw( "COUNT(*)" ); if ( arguments.asBuilder ) { builders.append( countBuilder ); @@ -330,13 +394,14 @@ component accessors="true" transientCache="false" { var builders = []; for ( var r in arrayWrap( arguments.relationMapping ) ) { var relationName = r; - var callback = function() { - }; + var callback = ""; + var hasCallback = false; if ( isStruct( relationName ) ) { for ( var key in relationName ) { callback = relationName[ key ]; relationName = key; + hasCallback = true; break; } } @@ -360,16 +425,12 @@ component accessors="true" transientCache="false" { subselectName = parts[ 2 ]; } - var sumBuilder = getEntity().ignoreLoadedGuard( function() { - return getEntity().withoutRelationshipConstraints( relationName, function() { - var related = invoke( getEntity(), relationName ); - return related - .addCompareConstraints() - .when( true, callback ) - .clearOrders() - .reselectRaw( "COALESCE(SUM(#related.qualifyColumn( attributeName )#), 0)" ); - } ); - } ); + var related = resolveRelationship( getEntity(), relationName ); + var sumBuilder = related.addCompareConstraints(); + if ( hasCallback ) { + sumBuilder.when( true, callback ); + } + sumBuilder.clearOrders().reselectRaw( "COALESCE(SUM(#related.qualifyColumn( attributeName )#), 0)" ); if ( arguments.asBuilder ) { builders.append( sumBuilder ); @@ -392,8 +453,18 @@ component accessors="true" transientCache="false" { * @return [quick.models.BaseEntity] */ private array function getEntities( any columns, struct options = {} ) { + if ( !variables._asQuery ) { + ensureKeyColumnsSelected(); + } var results = variables.qb.get( argumentCollection = arguments ); - return variables._asQuery ? results : results.map( variables.loadEntity ); + if ( variables._asQuery ) { + return results; + } + var entities = []; + for ( var result in results ) { + entities.append( variables.loadEntity( result ) ); + } + return entities; } /** @@ -437,14 +508,174 @@ component accessors="true" transientCache="false" { getEntity().guardReadOnly(); getEntity().guardAgainstReadOnlyAttributes( arguments.attributes ); } - return variables.qb.update( - arguments.attributes.map( function( key, value ) { - return getEntity().generateQueryParamStruct( - column = key, - value = isNull( value ) ? javacast( "null", "" ) : value - ); - } ) - ); + arguments.attributes = appendUpdatedTimestamp( arguments.attributes ); + return variables.qb.update( prepareBulkMutationAttributes( arguments.attributes ) ); + } + + /** + * Inserts rows that do not exist and updates rows matching the target columns. + * + * Like `updateAll`, this is a bulk mutation. It applies Quick attribute metadata + * and read-only guards, but does not hydrate entities or fire per-entity events. + * + * @values The values to insert or the columns selected by the source query. + * @target The columns used to determine whether a row already exists. + * @update The columns or explicit values to update when a row matches. + * @source An optional query builder or callback used as the source rows. + * @deleteUnmatched Whether to delete target rows missing from the source, or a callback constraining those deletes. + * @options Options passed to `queryExecute`. + * @toSql Whether to return SQL instead of executing the query. + * @matchNulls Whether two NULL target values should be considered a match. Supported by MERGE grammars. + * @force If true, skips read-only entity and read-only attribute checks. + * + * @throws QuickReadOnlyException + * + * @return The qb bulk execution result, or SQL when `toSql` is true. + */ + public any function upsert( + required any values, + required any target, + any update, + any source, + any deleteUnmatched = false, + struct options = {}, + boolean toSql = false, + boolean matchNulls = false, + boolean force = false + ) { + if ( !arguments.force ) { + getEntity().guardReadOnly(); + guardBulkMutationAttributes( arguments.values ); + if ( structKeyExists( arguments, "update" ) ) { + guardBulkMutationAttributes( arguments.update ); + } + } + + arguments.values = appendInsertTimestamps( arguments.values ); + arguments.values = prepareBulkMutationValues( arguments.values ); + if ( structKeyExists( arguments, "update" ) && isStruct( arguments.update ) ) { + arguments.update = appendUpdatedTimestamp( arguments.update ); + arguments.update = prepareBulkMutationAttributes( arguments.update ); + } else if ( structKeyExists( arguments, "update" ) && isArray( arguments.update ) ) { + var modifiedDateAttribute = getEntity().retrieveModifiedDateAttribute(); + if ( + !variables._withoutAutomaticTimestamps + && getEntity().usesAutomaticTimestamps() + && len( modifiedDateAttribute ) + && !arguments.update.findNoCase( modifiedDateAttribute ) + ) { + arguments.update.append( modifiedDateAttribute ); + } + } + + structDelete( arguments, "force" ); + return variables.qb.upsert( argumentCollection = arguments ); + } + + /** + * Applies Quick query parameter metadata to a bulk mutation attribute struct. + */ + private struct function prepareBulkMutationAttributes( required struct attributes ) { + var preparedAttributes = {}; + for ( var key in arguments.attributes ) { + preparedAttributes[ key ] = getEntity().generateQueryParamStruct( + column = key, + value = isNull( arguments.attributes[ key ] ) ? javacast( "null", "" ) : arguments.attributes[ key ] + ); + } + return preparedAttributes; + } + + /** + * Adds the configured modified timestamp to bulk mutation attributes when available. + */ + private struct function appendUpdatedTimestamp( required struct attributes ) { + var timestampAttributes = duplicate( arguments.attributes ); + var modifiedDateAttribute = getEntity().retrieveModifiedDateAttribute(); + if ( + !variables._withoutAutomaticTimestamps + && getEntity().usesAutomaticTimestamps() + && len( modifiedDateAttribute ) + && !timestampAttributes.keyExists( modifiedDateAttribute ) + ) { + timestampAttributes[ modifiedDateAttribute ] = now(); + } + return timestampAttributes; + } + + /** + * Adds configured insert timestamps to literal upsert rows when available. + */ + private any function appendInsertTimestamps( required any values ) { + if ( variables._withoutAutomaticTimestamps || !getEntity().usesAutomaticTimestamps() ) { + return arguments.values; + } + if ( isArray( arguments.values ) ) { + return arguments.values.map( ( value ) => isStruct( value ) ? appendInsertTimestamps( value ) : value ); + } + if ( + !isStruct( arguments.values ) + || structKeyExists( arguments.values, "isBuilder" ) + || structKeyExists( arguments.values, "isQuickBuilder" ) + ) { + return arguments.values; + } + var timestampAttributes = appendUpdatedTimestamp( arguments.values ); + var createdDateAttribute = getEntity().retrieveCreatedDateAttribute(); + if ( len( createdDateAttribute ) && !timestampAttributes.keyExists( createdDateAttribute ) ) { + timestampAttributes[ createdDateAttribute ] = now(); + } + return timestampAttributes; + } + + /** + * Applies Quick query parameter metadata to literal upsert rows. + */ + private any function prepareBulkMutationValues( required any values ) { + if ( isArray( arguments.values ) ) { + var preparedValues = []; + for ( var value in arguments.values ) { + preparedValues.append( isStruct( value ) ? prepareBulkMutationAttributes( value ) : value ); + } + return preparedValues; + } + + if ( + isStruct( arguments.values ) && + !structKeyExists( arguments.values, "isBuilder" ) && + !structKeyExists( arguments.values, "isQuickBuilder" ) + ) { + return prepareBulkMutationAttributes( arguments.values ); + } + + return arguments.values; + } + + /** + * Guards literal rows or column collections used by a bulk mutation. + */ + private void function guardBulkMutationAttributes( required any attributes ) { + if ( isArray( arguments.attributes ) ) { + for ( var item in arguments.attributes ) { + guardBulkMutationAttributes( item ); + } + return; + } + + if ( + isStruct( arguments.attributes ) && + !structKeyExists( arguments.attributes, "isBuilder" ) && + !structKeyExists( arguments.attributes, "isQuickBuilder" ) + ) { + getEntity().guardAgainstReadOnlyAttributes( arguments.attributes ); + return; + } + + if ( isSimpleValue( arguments.attributes ) ) { + for ( var attribute in listToArray( arguments.attributes ) ) { + getEntity().guardAgainstReadOnlyAttributes( { "#attribute#" : true } ); + } + } } /** @@ -464,30 +695,77 @@ component accessors="true" transientCache="false" { function onFalse, boolean withoutScoping = false ) { - var defaultCallback = function( q ) { - return q; - }; - arguments.onFalse = isNull( arguments.onFalse ) ? defaultCallback : arguments.onFalse; + if ( !arguments.condition && isNull( arguments.onFalse ) ) { + return this; + } + var selectedCallback = arguments.condition ? arguments.onTrue : arguments.onFalse; if ( arguments.withoutScoping ) { - if ( arguments.condition ) { - arguments.onTrue( this ); - } else { - arguments.onFalse( this ); - } + selectedCallback( this ); } else { - variables.qb.withScoping( function() { - if ( condition ) { - onTrue( this ); - } else { - onFalse( this ); - } - } ); + var originalWhereCount = variables.qb.getWheres().len(); + selectedCallback( this ); + groupNewWheresForScope( originalWhereCount ); } return this; } + /** + * Groups OR predicates added by a callback using qb's nested-query objects. + */ + private void function groupNewWheresForScope( required numeric originalWhereCount ) { + if ( variables.qb.getWheres().len() <= arguments.originalWhereCount ) { + return; + } + var allWheres = variables.qb.getWheres(); + variables.qb.setWheres( [] ); + if ( arguments.originalWhereCount > 0 ) { + appendScopedWhereSlice( + arraySlice( + allWheres, + 1, + arguments.originalWhereCount + ) + ); + } + appendScopedWhereSlice( arraySlice( allWheres, arguments.originalWhereCount + 1 ) ); + } + + private void function appendScopedWhereSlice( required array whereSlice ) { + for ( var whereClause in arguments.whereSlice ) { + if ( compareNoCase( whereClause.combinator, "OR" ) == 0 ) { + variables.qb.addNestedWhereQuery( variables.qb.forNestedWhere().setWheres( arguments.whereSlice ) ); + return; + } + } + var wheres = variables.qb.getWheres(); + wheres.append( arguments.whereSlice, true ); + variables.qb.setWheres( wheres ); + } + + /** + * Adds grouped primary-key constraints for bulk delete operations. + */ + private void function addIdConstraints( required array ids ) { + if ( arrayIsEmpty( arguments.ids ) ) { + return; + } + + var idConstraints = variables.qb.forNestedWhere(); + var keyNames = getEntity().keyNames(); + for ( var id in arguments.ids ) { + var values = arrayWrap( id ); + getEntity().guardAgainstKeyLengthMismatch( values ); + var keyConstraints = idConstraints.forNestedWhere(); + for ( var i = 1; i <= keyNames.len(); i++ ) { + keyConstraints.where( keyNames[ i ], values[ i ] ); + } + idConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.qb.addNestedWhereQuery( idConstraints ); + } + /** * Deletes matching entities according to the configured query. * @@ -501,24 +779,37 @@ component accessors="true" transientCache="false" { */ public struct function deleteAll( array ids = [] ) { getEntity().guardReadOnly(); - if ( !arrayIsEmpty( arguments.ids ) ) { - variables.qb.where( function( q1 ) { - ids.each( function( id ) { - var values = arrayWrap( id ); - getEntity().guardAgainstKeyLengthMismatch( values ); - q1.orWhere( function( q2 ) { - getEntity() - .keyNames() - .each( function( keyName, i ) { - q2.where( keyName, values[ i ] ); - } ); - } ); - } ); - } ); + addIdConstraints( arguments.ids ); + if ( getEntity().usesSoftDeletes() ) { + activateGlobalScopes(); + return updateAll( { "#getEntity().retrieveSoftDeleteColumn()#" : now() } ); } return variables.qb.delete(); } + /** + * Restores all soft-deleted entities matching the configured query. + */ + public struct function restoreAll() { + if ( !getEntity().usesSoftDeletes() ) { + throw( + type = "QuickSoftDeletesNotEnabled", + message = "[#getEntity().entityName()#] is not configured to use soft deletes." + ); + } + withoutGlobalScope( "softDeletes" ); + return updateAll( { "#getEntity().retrieveSoftDeleteColumn()#" : "" } ); + } + + /** + * Permanently deletes all entities matching the configured query. + */ + public struct function forceDeleteAll( array ids = [] ) { + getEntity().guardReadOnly(); + addIdConstraints( arguments.ids ); + return variables.qb.delete(); + } + /** * Add an relation or an array of relations to be eager loaded. * Eager loaded relations are retrieved at the same time as loading @@ -544,6 +835,51 @@ component accessors="true" transientCache="false" { return this; } + /** + * Removes one or more relationships from the eager-load list. Omitting the + * argument leaves the eager-load list unchanged. + * + * @relationName A relationship name or array of relationship names to remove. + * + * @return QuickBuilder + */ + public any function without( any relationName ) { + if ( isNull( arguments.relationName ) ) { + return this; + } + + var exclusions = arrayWrap( arguments.relationName ); + var eagerLoadList = []; + for ( var eagerLoad in variables._eagerLoad ) { + var path = isStruct( eagerLoad ) ? eagerLoad.keyArray()[ 1 ] : eagerLoad; + var isExcluded = false; + for ( var exclusion in exclusions ) { + if ( + compareNoCase( path, exclusion ) == 0 || + compareNoCase( left( path, len( exclusion ) + 1 ), exclusion & "." ) == 0 + ) { + isExcluded = true; + break; + } + } + if ( !isExcluded ) { + eagerLoadList.append( eagerLoad ); + } + } + variables._eagerLoad = eagerLoadList; + return this; + } + + /** + * Removes every configured eager load. + * + * @return QuickBuilder + */ + public any function clearEagerLoads() { + variables._eagerLoad = []; + return this; + } + /** * Eager loads the configured relations for the retrieved entities. * Returns the retrieved entities eager loaded with the configured @@ -562,9 +898,10 @@ component accessors="true" transientCache="false" { // This is a workaround for grammars with a parameter limit. If the grammar // has a `parameterLimit` public property, it is used to slice up the array // and work it in chunks. - if ( structKeyExists( getEntity().newQuery().getGrammar(), "parameterLimit" ) ) { - var parameterLimit = getEntity().newQuery().getGrammar().parameterLimit; - if ( arguments.entities.len() > parameterLimit ) { + var grammar = getEntity().newQuery().getGrammar(); + if ( structKeyExists( grammar, "parameterLimit" ) ) { + var parameterLimit = grammar.parameterLimit; + if ( parameterLimit > 0 && arguments.entities.len() > parameterLimit ) { for ( var i = 1; i < arguments.entities.len(); i += parameterLimit ) { var length = min( arguments.entities.len() - i + 1, parameterLimit ); var slice = arraySlice( arguments.entities, i, length ); @@ -574,13 +911,14 @@ component accessors="true" transientCache="false" { } } - structEach( denestEagerLoads( variables._eagerLoad ), function( relationName, nestedEagerLoads ) { - entities = eagerLoadRelation( + var eagerLoads = denestEagerLoads( variables._eagerLoad ); + for ( var relationName in eagerLoads ) { + arguments.entities = eagerLoadRelation( relationName, - nestedEagerLoads, - entities + eagerLoads[ relationName ], + arguments.entities ); - } ); + } return arguments.entities; } @@ -595,15 +933,19 @@ component accessors="true" transientCache="false" { var result = {}; for ( var relationshipPath in arguments.eagerLoads ) { - var callback = function() { - }; - var pathString = ""; + var callbackConfig = { "present" : false }; + var pathString = ""; // Handle struct format: { "path.to.relation": callback } if ( isStruct( relationshipPath ) ) { for ( var key in relationshipPath ) { - pathString = key; - callback = relationshipPath[ key ]; + pathString = key; + callbackConfig.present = isCustomFunction( relationshipPath[ key ] ) || isClosure( + relationshipPath[ key ] + ); + if ( callbackConfig.present ) { + callbackConfig.value = relationshipPath[ key ]; + } break; } } else { @@ -615,27 +957,21 @@ component accessors="true" transientCache="false" { // Initialize the entry if it doesn't exist if ( !result.keyExists( firstPart ) ) { - result[ firstPart ] = { - "callback" : function() { - }, - "nested" : {} - }; + result[ firstPart ] = { "nested" : {} }; } if ( parts.len() > 1 ) { // Build the nested path with the callback attached to the deepest level var nestedPath = arraySlice( parts, 2 ).toList( "." ); - var nestedItem = isCustomFunction( callback ) || isClosure( callback ) - ? { "#nestedPath#" : callback } - : nestedPath; + var nestedItem = callbackConfig.present ? { "#nestedPath#" : callbackConfig.value } : nestedPath; var nestedResult = denestEagerLoads( [ nestedItem ] ); // Merge nested results result[ firstPart ][ "nested" ] = mergeNestedEagerLoads( result[ firstPart ][ "nested" ], nestedResult ); } else { // This is the target level - apply the callback here - if ( isCustomFunction( callback ) || isClosure( callback ) ) { - result[ firstPart ][ "callback" ] = callback; + if ( callbackConfig.present ) { + result[ firstPart ][ "callback" ] = callbackConfig.value; } } } @@ -675,48 +1011,41 @@ component accessors="true" transientCache="false" { public array function renestEagerLoads( required struct additionalEagerLoads ) { // Input format: { "relationName": { "callback": fn, "nested": { ... } } } // Output format: array of strings or structs like { "path.to.relation": callback } - return structReduce( - arguments.additionalEagerLoads, - function( acc, relationName, eagerLoadConfig ) { - var callback = function() { - }; - if ( eagerLoadConfig.keyExists( "callback" ) ) { - callback = eagerLoadConfig.callback; - } - var nestedConfig = {}; - if ( eagerLoadConfig.keyExists( "nested" ) ) { - nestedConfig = eagerLoadConfig.nested; - } - var hasCallback = isCustomFunction( callback ) || isClosure( callback ); - - // Get the renested items from nested config - var nestedItems = renestEagerLoads( nestedConfig ); - - if ( nestedItems.len() > 0 ) { - // There are nested items - prepend this relationName to each - for ( var nestedItem in nestedItems ) { - if ( isSimpleValue( nestedItem ) ) { - acc.append( relationName & "." & nestedItem ); - } else { - // It's a struct with callback - prepend relationName to the key - for ( var key in nestedItem ) { - acc.append( { "#relationName#.#key#" : nestedItem[ key ] } ); - break; - } + var eagerLoads = []; + for ( var relationName in arguments.additionalEagerLoads ) { + var eagerLoadConfig = arguments.additionalEagerLoads[ relationName ]; + var hasCallback = eagerLoadConfig.keyExists( "callback" ) && ( + isCustomFunction( eagerLoadConfig.callback ) || isClosure( eagerLoadConfig.callback ) + ); + var nestedConfig = {}; + if ( eagerLoadConfig.keyExists( "nested" ) ) { + nestedConfig = eagerLoadConfig.nested; + } + // Get the renested items from nested config + var nestedItems = renestEagerLoads( nestedConfig ); + + if ( nestedItems.len() > 0 ) { + // There are nested items - prepend this relationName to each + for ( var nestedItem in nestedItems ) { + if ( isSimpleValue( nestedItem ) ) { + eagerLoads.append( relationName & "." & nestedItem ); + } else { + // It's a struct with callback - prepend relationName to the key + for ( var key in nestedItem ) { + eagerLoads.append( { "#relationName#.#key#" : nestedItem[ key ] } ); + break; } } - } else if ( hasCallback ) { - // No nested, but has a callback - return as struct - acc.append( { "#relationName#" : callback } ); - } else { - // No nested, no callback - just the relation name - acc.append( relationName ); } - - return acc; - }, - [] - ); + } else if ( hasCallback ) { + // No nested, but has a callback - return as struct + eagerLoads.append( { "#relationName#" : eagerLoadConfig.callback } ); + } else { + // No nested, no callback - just the relation name + eagerLoads.append( relationName ); + } + } + return eagerLoads; } /** @@ -736,29 +1065,29 @@ component accessors="true" transientCache="false" { required array entities ) { // Extract callback and nested config from the eagerLoadConfig - var callback = function() { - }; - if ( arguments.eagerLoadConfig.keyExists( "callback" ) ) { - callback = arguments.eagerLoadConfig.callback; - } var nestedEagerLoads = {}; if ( arguments.eagerLoadConfig.keyExists( "nested" ) ) { nestedEagerLoads = arguments.eagerLoadConfig.nested; } - var relation = getEntity().ignoreLoadedGuard( function() { - return getEntity().withoutRelationshipConstraints( relationName, function() { - return invoke( getEntity(), relationName ); - } ); - } ); - callback( relation ); + var relation = resolveRelationship( getEntity(), relationName ); + if ( arguments.eagerLoadConfig.keyExists( "callback" ) ) { + arguments.eagerLoadConfig.callback( relation ); + } var hasMatches = relation.addEagerConstraints( arguments.entities, getEntity() ); relation.with( renestEagerLoads( nestedEagerLoads ) ); - return relation.match( + var matchedEntities = relation.match( relation.initRelation( arguments.entities, arguments.relationName ), hasMatches ? relation.getEager( variables._asQuery, variables._withAliases ) : [], arguments.relationName ); + var loadedRelationshipName = arguments.relationName; + for ( var entity in matchedEntities ) { + if ( isStruct( entity ) && structKeyExists( entity, "isQuickEntity" ) ) { + entity.fireRelationshipLoaded( loadedRelationshipName ); + } + } + return matchedEntities; } /** @@ -794,21 +1123,9 @@ component accessors="true" transientCache="false" { while ( listLen( arguments.relationshipName, "." ) > 0 ) { var thisRelationshipName = listFirst( arguments.relationshipName, "." ); if ( isNull( q ) ) { - q = getEntity().ignoreLoadedGuard( function() { - return getEntity().withoutRelationshipConstraints( thisRelationshipName, function() { - return invoke( getEntity(), thisRelationshipName ).addCompareConstraints().clearOrders(); - } ); - } ); + q = resolveRelationship( getEntity(), thisRelationshipName ).addCompareConstraints().clearOrders(); } else { - var relationship = q - .getEntity() - .ignoreLoadedGuard( function() { - return q - .getEntity() - .withoutRelationshipConstraints( thisRelationshipName, function() { - return invoke( q.getEntity(), thisRelationshipName ); - } ); - } ); + var relationship = resolveRelationship( q.getEntity(), thisRelationshipName ); var existsQuery = relationship.addCompareConstraints( q.select( q.raw( 1 ) ) ).clearOrders(); @@ -857,9 +1174,30 @@ component accessors="true" transientCache="false" { } } } + if ( !structKeyExists( arguments, "tableName" ) || isNull( arguments.tableName ) ) { + return getEntity().qualifyColumn( arguments.column ); + } return getEntity().qualifyColumn( argumentCollection = arguments ); } + /** + * Qualifies a column using the current table for the provided query. + * + * @column The column to qualify. + * @query The query whose current table should be used. + * + * @return string + */ + public string function qualifyColumnForQuery( required string column, required any query ) { + var tableName = arguments.query.getTableName(); + if ( arguments.query.getAlias() != "" ) { + tableName = arguments.query.getAlias(); + } + return !isNull( tableName ) && isSimpleValue( tableName ) && tableName != "" + ? qualifyColumn( arguments.column, tableName ) + : qualifyColumn( arguments.column ); + } + /** * Returns the table name of the underlying entity */ @@ -893,39 +1231,41 @@ component accessors="true" transientCache="false" { entity.qualifyColumn( entity.keyNames()[ 1 ] ) ); } else if ( entity.isDiscriminatedParent() && entity.get_loadChildren() ) { - entity - .getDiscriminations() - .each( function( discriminator, data ) { - // only join if this is a polymorphic association - if ( !entity.isSingleTableInheritance() ) { - variables.qb.join( - data.table, - getEntity().qualifyColumn( getEntity().keyNames()[ 1 ] ), - "=", - data.joincolumn, - "left outer" - ); - } - variables.qb.addSelect( - data.childColumns.map( ( column ) => { - if ( column == data.joincolumn ) { - return "#column# AS #column#" - } - return column; - } ) + for ( var discriminator in entity.getDiscriminations() ) { + var data = entity.getDiscriminations()[ discriminator ]; + // only join if this is a polymorphic association + if ( !entity.isSingleTableInheritance() ) { + variables.qb.join( + data.table, + getEntity().qualifyColumn( getEntity().keyNames()[ 1 ] ), + "=", + data.joincolumn, + "left outer" ); - } ); + } + var childColumns = []; + for ( var column in data.childColumns ) { + childColumns.append( column == data.joincolumn ? "#column# AS #column#" : column ); + } + variables.qb.addSelect( childColumns ); + } } } /** * Creates a virtual attribute for the given name. * - * @name The attribute name to create. + * @name The attribute name to create. + * @defaultValue The default value for the virtual attribute. + * @excludeFromMemento Whether to exclude the virtual attribute from mementos. * * @return quick.models.BaseEntity */ - public any function appendVirtualAttribute( required string name, boolean excludeFromMemento = false ) { + public any function appendVirtualAttribute( + required string name, + any defaultValue, + boolean excludeFromMemento = false + ) { getEntity().appendVirtualAttribute( argumentCollection = arguments ); return this; } @@ -989,7 +1329,17 @@ component accessors="true" transientCache="false" { return result; } - return javacast( "null", "" ); + throw( + type = "QuickMissingMethod", + message = arrayToList( + [ + "Quick couldn't figure out what to do with [#arguments.missingMethodName#].", + "We tried checking columns, aliases, scopes, and relationships locally.", + "We also forwarded the call on to qb to see if it could do anything with it, but it couldn't." + ], + " " + ) + ); } /** @@ -1019,7 +1369,7 @@ component accessors="true" transientCache="false" { idQuery.where( allKeyNames[ i ], arguments.id[ i ] ); } variables.qb.addNestedWhereQuery( idQuery, "and" ); - return this.first(); + return this.first( arguments.options ); } /** @@ -1090,6 +1440,9 @@ component accessors="true" transientCache="false" { */ public any function first( struct options = {} ) { activateGlobalScopes(); + if ( !variables._asQuery ) { + ensureKeyColumnsSelected(); + } var result = variables.qb.first( argumentCollection = arguments ); return structIsEmpty( result ) ? javacast( "null", "" ) : handleTransformations( @@ -1264,11 +1617,11 @@ component accessors="true" transientCache="false" { if ( !isNull( arguments.id ) ) { arguments.id = arrayWrap( arguments.id ); getEntity().guardAgainstKeyLengthMismatch( arguments.id ); - variables.qb.where( function( q ) { - for ( var keyColumn in getEntity().keyColumns() ) { - q.where( keyColumn, id[ 1 ] ); - } - } ); + var keyConstraints = variables.qb.forNestedWhere(); + for ( var keyColumn in getEntity().keyColumns() ) { + keyConstraints.where( keyColumn, arguments.id[ 1 ] ); + } + variables.qb.addNestedWhereQuery( keyConstraints ); } return variables.qb.exists( arguments.options ); } @@ -1303,6 +1656,51 @@ component accessors="true" transientCache="false" { return true; } + /** + * Retrieves the configured query in chunks and passes hydrated entities to + * the callback. Returning false from the callback stops further retrieval. + * + * @max The maximum number of entities in each chunk. + * @callback The callback invoked for each entity collection. + * @options Any options to pass to `queryExecute`. Default: {}. + * + * @return quick.models.QuickBuilder + */ + public any function chunk( + required numeric max, + required callback, + struct options = {} + ) { + if ( arguments.max <= 0 ) { + throw( type = "InvalidChunkSize", message = "Chunk size must be greater than zero." ); + } + activateGlobalScopes(); + var rowOffset = 0; + while ( true ) { + var rows = variables.qb + .limit( arguments.max ) + .offset( rowOffset ) + .get( options = arguments.options ); + if ( rows.len() == 0 ) { + break; + } + var entities = rows; + if ( !variables._asQuery ) { + entities = []; + for ( var row in rows ) { + entities.append( variables.loadEntity( row ) ); + } + } + var collection = getEntity().newCollection( handleTransformations( eagerLoadRelations( entities ) ) ); + var shouldContinue = arguments.callback( collection ); + if ( ( !isNull( shouldContinue ) && !shouldContinue ) || rows.len() < arguments.max ) { + break; + } + rowOffset += arguments.max; + } + return this; + } + /** * Returns a Pagination Collection of entities. * @@ -1318,13 +1716,20 @@ component accessors="true" transientCache="false" { struct options = {} ) { activateGlobalScopes(); + if ( !variables._asQuery ) { + ensureKeyColumnsSelected(); + } var p = variables.qb.paginate( arguments.page, arguments.maxRows, arguments.options ); if ( !variables._asQuery ) { - p.results = p.results.map( variables.loadEntity ); + var entities = []; + for ( var result in p.results ) { + entities.append( variables.loadEntity( result ) ); + } + p.results = entities; } p.results = handleTransformations( eagerLoadRelations( p.results ) ); return p; @@ -1345,13 +1750,20 @@ component accessors="true" transientCache="false" { struct options = {} ) { activateGlobalScopes(); + if ( !variables._asQuery ) { + ensureKeyColumnsSelected(); + } var p = variables.qb.simplePaginate( arguments.page, arguments.maxRows, arguments.options ); if ( !variables._asQuery ) { - p.results = p.results.map( variables.loadEntity ); + var entities = []; + for ( var result in p.results ) { + entities.append( variables.loadEntity( result ) ); + } + p.results = entities; } p.results = handleTransformations( eagerLoadRelations( p.results ) ); return p; @@ -1412,6 +1824,18 @@ component accessors="true" transientCache="false" { * @return any */ private any function handleTransformations( entity ) { + if ( !variables._asQuery ) { + if ( isArray( arguments.entity ) ) { + var transformedEntities = []; + for ( var item in arguments.entity ) { + transformedEntities.append( applyEntityTransformers( item ) ); + } + arguments.entity = transformedEntities; + } else if ( !isNull( arguments.entity ) ) { + arguments.entity = applyEntityTransformers( arguments.entity ); + } + } + if ( !variables._asMemento ) { return arguments.entity; } @@ -1420,9 +1844,22 @@ component accessors="true" transientCache="false" { return arguments.entity.getMemento( argumentCollection = variables._asMementoSettings ); } - return arguments.entity.map( function( e ) { - return e.getMemento( argumentCollection = variables._asMementoSettings ); - } ); + var mementos = []; + for ( var resultEntity in arguments.entity ) { + mementos.append( resultEntity.getMemento( argumentCollection = variables._asMementoSettings ) ); + } + return mementos; + } + + /** + * Applies all configured entity transformers to one hydrated entity. + */ + private any function applyEntityTransformers( required any entity ) { + var transformed = arguments.entity; + for ( var transformer in variables._entityTransformers ) { + transformed = transformer( transformed ); + } + return transformed; } /** @@ -1435,6 +1872,12 @@ component accessors="true" transientCache="false" { variables._applyingGlobalScopes = true; if ( !variables._globalScopeExcludeAll ) { + if ( + getEntity().usesSoftDeletes() && + !variables._globalScopeExclusions.contains( "softdeletes" ) + ) { + variables.qb.whereNull( getEntity().retrieveSoftDeleteColumn() ); + } getEntity().applyGlobalScopes( this ); } @@ -1444,6 +1887,22 @@ component accessors="true" transientCache="false" { return this; } + /** + * Includes soft-deleted entities in this query. + */ + public any function withTrashed() { + return withoutGlobalScope( "softDeletes" ); + } + + /** + * Restricts this query to only soft-deleted entities. + */ + public any function onlyTrashed() { + withoutGlobalScope( "softDeletes" ); + variables.qb.whereNotNull( getEntity().retrieveSoftDeleteColumn() ); + return this; + } + /** * Allows a query to override one or more global scopes for one execution. * @@ -1452,7 +1911,7 @@ component accessors="true" transientCache="false" { * @return quick.models.BaseEntity */ public any function withoutGlobalScope( any name ) { - if ( !structKeyExists( arguments, "name" ) ) { + if ( !structKeyExists( arguments, "name" ) || isNull( arguments.name ) ) { variables._globalScopeExcludeAll = true; return this; } @@ -1529,31 +1988,41 @@ component accessors="true" transientCache="false" { * * @return quick.models.BaseEntity */ - public any function loadEntity( required struct data ) { + public any function loadEntity( required struct data, any refreshQuery ) { + var loadedData = arguments.data; + var hasVirtualData = false; + for ( var attribute in getEntity().get_virtualAttributes() ) { + if ( loadedData.keyExists( attribute ) ) { + hasVirtualData = true; + break; + } + } + if ( hasVirtualData && isNull( arguments.refreshQuery ) ) { + arguments.refreshQuery = variables.qb.clone(); + } + if ( getEntity().get_loadChildren() && getEntity().isDiscriminatedParent() && - structKeyExists( arguments.data, listLast( getEntity().get_meta().localMetadata.discriminatorColumn, "." ) ) + structKeyExists( arguments.data, listLast( getEntity().discriminatorColumn(), "." ) ) && structKeyExists( getEntity().getDiscriminations(), - arguments.data[ listLast( getEntity().get_meta().localMetadata.discriminatorColumn, "." ) ] + arguments.data[ listLast( getEntity().discriminatorColumn(), "." ) ] ) ) { var discrimination = getEntity().getDiscriminations()[ - arguments.data[ listLast( getEntity().get_meta().localMetadata.discriminatorColumn, "." ) ] + arguments.data[ listLast( getEntity().discriminatorColumn(), "." ) ] ]; var childClass = variables._wirebox.getInstance( discrimination.mapping ); // add any virtual attributes present in the parent entity to child entity - getEntity() - .get_virtualAttributes() - .each( function( item ) { - childClass.appendVirtualAttribute( item ); - } ); + for ( var item in getEntity().get_virtualAttributes() ) { + childClass.appendVirtualAttribute( item ); + } // find the correct child columns to use, in the case that multiple child tables contain the same column for ( var column in data ) { @@ -1568,20 +2037,28 @@ component accessors="true" transientCache="false" { } } - return childClass + var childEntity = childClass .assignAttributesData( arguments.data ) .assignOriginalAttributes( arguments.data ) .set_preventLazyLoading( variables._preventLazyLoading ) .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ) .markLoaded(); + if ( hasVirtualData ) { + childEntity.set_refreshQuery( arguments.refreshQuery ); + } + return childEntity; } else { - return getEntity() + var entity = getEntity() .newEntity() .assignAttributesData( arguments.data ) .assignOriginalAttributes( arguments.data ) .set_preventLazyLoading( variables._preventLazyLoading ) .set_lazyLoadingViolationCallback( variables._lazyLoadingViolationCallback ) .markLoaded(); + if ( hasVirtualData ) { + entity.set_refreshQuery( arguments.refreshQuery ); + } + return entity; } } @@ -1607,25 +2084,24 @@ component accessors="true" transientCache="false" { variables._withAliases = arguments.withAliases; if ( variables._withAliases ) { var qualifiedColumns = getEntity().retrieveQualifiedColumns(); - qb.setColumns( - qb.getColumns() - .map( function( column ) { - if ( column.type == "raw" || column.type == "builder" ) { - return column; - } - - if ( !qualifiedColumns.contains( column.value ) ) { - return column; - } - - return { - "type" : "simple", - "value" : column.value & " AS " & getEntity().retrieveAliasForColumn( - listLast( column.value, "." ) - ) - }; - } ) - ); + var columns = []; + for ( var column in qb.getColumns() ) { + if ( + column.type != "raw" && + column.type != "builder" && + qualifiedColumns.contains( column.value ) + ) { + columns.append( { + "type" : "simple", + "value" : column.value & " AS " & getEntity().retrieveAliasForColumn( + listLast( column.value, "." ) + ) + } ); + } else { + columns.append( column ); + } + } + qb.setColumns( columns ); } variables._asMemento = false; return this; @@ -1646,6 +2122,10 @@ component accessors="true" transientCache="false" { newBuilder.set_withAliases( this.get_withAliases() ); newBuilder.set_asMemento( this.get_asMemento() ); newBuilder.set_asMementoSettings( this.get_asMementoSettings() ); + newBuilder.set_entityTransformers( this.get_entityTransformers() ); + if ( variables._withoutAutomaticTimestamps ) { + newBuilder.withoutAutomaticTimestamps(); + } return newBuilder; } @@ -1679,9 +2159,10 @@ component accessors="true" transientCache="false" { return arguments.arrays; } - var lengths = arguments.arrays.map( function( arr ) { - return arr.len(); - } ); + var lengths = []; + for ( var arr in arguments.arrays ) { + lengths.append( arr.len() ); + } if ( unique( lengths ).len() > 1 ) { throw( diff --git a/models/QuickQB.cfc b/models/QuickQB.cfc index c47db0de..cf9950fe 100644 --- a/models/QuickQB.cfc +++ b/models/QuickQB.cfc @@ -10,6 +10,20 @@ component property name="entity"; property name="quickBuilder"; + /** + * Resolves a relationship without allocating nested guard callbacks. + */ + private any function resolveRelationship( required any entity, required string relationshipName ) { + arguments.entity.set_ignoreNotLoadedGuard( true ); + arguments.entity.get_withoutRelationshipConstraints().add( lCase( arguments.relationshipName ) ); + try { + return invoke( arguments.entity, arguments.relationshipName ); + } finally { + arguments.entity.set_ignoreNotLoadedGuard( false ); + arguments.entity.get_withoutRelationshipConstraints().remove( lCase( arguments.relationshipName ) ); + } + } + /** * Adds a basic WHERE clause to the query. * If the column is an attribute on the entity, a query param struct is @@ -27,12 +41,29 @@ component * * @return quick.models.QuickQB */ - private QuickQB function whereBasic( - required any column, - required any operator, + public QuickQB function where( + any column, + any operator, any value, string combinator = "and" ) { + if ( isClosure( arguments.column ) || isCustomFunction( arguments.column ) ) { + return whereNested( arguments.column, arguments.combinator ); + } + + if ( isNull( arguments.value ) && getQueryValidator().isInvalidOperator( arguments.operator ) ) { + arguments.value = arguments.operator; + arguments.operator = "="; + } + + if ( + !isNull( arguments.value ) && + isStruct( arguments.value ) && + structKeyExists( arguments.value, "isQuickBuilder" ) + ) { + arguments.value = arguments.value.getQB(); + } + if ( isSimpleValue( arguments.column ) && getEntity().hasAttribute( arguments.column ) ) { arguments.value = generateQueryParamStruct( column = arguments.column, @@ -41,7 +72,7 @@ component shouldCastValues = true // we are in a where clause, so the casted values will not be persisted to the entity at this time. ); } - super.whereBasic( argumentCollection = arguments ); + super.where( argumentCollection = arguments ); return this; } @@ -55,17 +86,26 @@ component * * @return qb.models.Query.QueryBuilder */ - private QuickQB function whereInSub( + public QuickQB function whereIn( column, - query, + values, combinator = "and", negate = false ) { - if ( isStruct( arguments.query ) && structKeyExists( arguments.query, "isQuickBuilder" ) ) { - arguments.query = arguments.query.getQB(); + if ( isStruct( arguments.values ) && structKeyExists( arguments.values, "isQuickBuilder" ) ) { + arguments.values = arguments.values.getQB(); + } else if ( isClosure( arguments.values ) || isCustomFunction( arguments.values ) ) { + var callback = arguments.values; + var quickBuilder = variables.quickBuilder; + arguments.values = function( query ) { + query.setColumnFormatter( function( column ) { + return quickBuilder.qualifyColumnForQuery( column, query ); + } ); + return callback( query ); + }; } - return super.whereInSub( argumentCollection = arguments ); + return super.whereIn( argumentCollection = arguments ); } /** @@ -314,14 +354,27 @@ component return super.update( argumentCollection = arguments ); } + /** + * Inserts rows that do not exist and updates rows matching the target columns. + * + * @values The values to insert or the columns selected by the source query. + * @target The columns used to determine whether a row already exists. + * @update The columns or explicit values to update when a row matches. + * @source An optional query builder or callback used as the source rows. + * @deleteUnmatched Whether to delete target rows missing from the source, or a callback constraining those deletes. + * @options Options passed to `queryExecute`. + * @toSql Whether to return SQL instead of executing the query. + * @matchNulls Whether two NULL target values should be considered a match. Supported by MERGE grammars. + */ public any function upsert( required any values, required any target, any update, any source, - boolean deleteUnmatched = false, - struct options = {}, - boolean toSql = false + any deleteUnmatched = false, + struct options = {}, + boolean toSql = false, + boolean matchNulls = false ) { if ( !isNull( arguments.source ) && isStruct( arguments.source ) && structKeyExists( @@ -377,6 +430,99 @@ component return super.whereExists( argumentCollection = arguments ); } + /** + * Constrains the query to entities belonging to one or more related entities. + * The relationship name defaults to the lower-camel-cased related entity name. + * + * @relationshipName The belongsTo relationship to use. + * A related entity can be passed here as a shortcut that infers the relationship name. + * @related A related Quick entity, an array of entities, or a collection of entities. + * @combinator The boolean combinator for the clause. Default: "and". + * + * @return quick.models.QuickQB + */ + public QuickQB function whereBelongsTo( + required any relationshipName, + any related, + string combinator = "and" + ) { + if ( isNull( arguments.related ) ) { + arguments.related = arguments.relationshipName; + arguments.delete( "relationshipName" ); + } + + var relatedEntities = isArray( arguments.related ) + ? arguments.related + : ( + isStruct( arguments.related ) && structKeyExists( arguments.related, "isQuickEntity" ) + ? [ arguments.related ] + : arguments.related.get() + ); + + if ( relatedEntities.isEmpty() ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "whereBelongsTo requires at least one related entity." + ); + } + + if ( !arguments.keyExists( "relationshipName" ) || isNull( arguments.relationshipName ) ) { + var relatedEntityName = relatedEntities[ 1 ].entityName(); + arguments.relationshipName = lCase( left( relatedEntityName, 1 ) ) & removeChars( relatedEntityName, 1, 1 ); + } + var resolvedRelationshipName = arguments.relationshipName; + + var relation = resolveRelationship( getEntity(), resolvedRelationshipName ); + + if ( relation.relationshipClass != "BelongsTo" ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "Relationship [#resolvedRelationshipName#] must be a belongsTo relationship." + ); + } + + var relatedMapping = relation.getRelated().mappingName(); + var foreignKeys = relation.getForeignKeys(); + var localKeys = relation.getLocalKeys(); + for ( var relatedEntity in relatedEntities ) { + if ( + !isStruct( relatedEntity ) || + !structKeyExists( relatedEntity, "isQuickEntity" ) || + relatedEntity.mappingName() != relatedMapping + ) { + throw( + type = "QuickInvalidWhereBelongsTo", + message = "All whereBelongsTo entities must match [#relatedMapping#]." + ); + } + } + + var entityConstraints = forNestedWhere(); + for ( var relatedEntity in relatedEntities ) { + var entityConstraint = entityConstraints.forNestedWhere(); + for ( var i = 1; i <= foreignKeys.len(); i++ ) { + entityConstraint.where( foreignKeys[ i ], relatedEntity.retrieveAttribute( localKeys[ i ] ) ); + } + entityConstraints.addNestedWhereQuery( entityConstraint, "or" ); + } + addNestedWhereQuery( entityConstraints, arguments.combinator ); + return this; + } + + /** + * Adds a whereBelongsTo constraint using an OR combinator. + * + * @relationshipName The belongsTo relationship to use. + * A related entity can be passed here as a shortcut that infers the relationship name. + * @related A related Quick entity, an array of entities, or a collection of entities. + * + * @return quick.models.QuickQB + */ + public QuickQB function orWhereBelongsTo( required any relationshipName, any related ) { + arguments.combinator = "or"; + return whereBelongsTo( argumentCollection = arguments ); + } + /** * Checks for the existence of a relationship when executing the query. * @@ -394,42 +540,25 @@ component string combinator = "and", boolean negate = false ) { - var relation = getEntity().ignoreLoadedGuard( function() { - var relationName = listFirst( relationshipName, "." ); - return getEntity().withoutRelationshipConstraints( relationName, function() { - return invoke( getEntity(), relationName ); - } ); - } ); + if ( listLen( arguments.relationshipName, "." ) > 1 ) { + return applyNestedHas( argumentCollection = arguments ); + } + + var relation = resolveRelationship( getEntity(), listFirst( arguments.relationshipName, "." ) ); arguments.relationQuery = relation .addCompareConstraints() .clearOrders() .select( relation.raw( 1 ) ); - if ( listLen( arguments.relationshipName, "." ) > 1 ) { - arguments.relationshipName = listRest( arguments.relationshipName, "." ); - - if ( structKeyExists( arguments.relationQuery, "retrieveQuery" ) ) { - arguments.relationQuery = arguments.relationQuery.retrieveQuery(); - } - - var nested = hasNested( argumentCollection = arguments ); - if ( structKeyExists( nested, "getQB" ) ) { - nested = nested.getQB(); - } - whereExists( - query = nested, - combinator = arguments.combinator, - negate = arguments.negate + if ( !isNull( arguments.operator ) && !isNull( arguments.count ) ) { + arguments.relationQuery.having( + arguments.relationQuery.raw( "COUNT(*)" ), + arguments.operator, + arguments.count ); - - return this; } - arguments.relationQuery.when( !isNull( arguments.operator ) && !isNull( arguments.count ), function( q ) { - q.having( q.raw( "COUNT(*)" ), operator, count ); - } ); - if ( structKeyExists( arguments.relationQuery, "retrieveQuery" ) ) { arguments.relationQuery = arguments.relationQuery.retrieveQuery(); } @@ -447,6 +576,43 @@ component return this; } + /** + * Applies the same nested query shape as the public `has` callback adapter, + * but invokes the nested `has` directly on the related query. + */ + private QuickQB function applyNestedHas( + required string relationshipName, + any operator, + numeric count, + string combinator = "and", + boolean negate = false + ) { + var relation = resolveRelationship( getEntity(), listFirst( arguments.relationshipName, "." ) ); + var q = relation.addCompareConstraints( nested = true ).clearOrders(); + var nestedArguments = { "relationshipName" : listRest( arguments.relationshipName, "." ) }; + if ( structKeyExists( arguments, "operator" ) ) { + nestedArguments.operator = arguments.operator; + } + if ( structKeyExists( arguments, "count" ) ) { + nestedArguments.count = arguments.count; + } + q.has( argumentCollection = nestedArguments ); + + if ( structKeyExists( q, "retrieveQuery" ) ) { + q = q.retrieveQuery(); + } + if ( structKeyExists( q, "getQB" ) ) { + q = q.getQB(); + } + + whereExists( + query = q, + combinator = arguments.combinator, + negate = arguments.negate + ); + return this; + } + /** * Checks for the existence of a relationship when executing the query. * @@ -503,76 +669,6 @@ component return doesntHave( argumentCollection = arguments ); } - /** - * Checks for the existence of a nested relationship when executing the query. - * - * @relationQuery The currently configured existence check query. - * @relationshipName The relationship to check. Can be a dot-delimited - * list of nested relationships. - * @operator An optional operator to constrain the check. - * @count An optional count to constrain the check. - * - * @return quick.models.QuickQB - */ - private any function hasNested( - required any relationQuery, - required string relationshipName, - any operator, - numeric count - ) { - var relation = relationQuery - .getEntity() - .ignoreLoadedGuard( function() { - var relationName = listFirst( relationshipName, "." ); - return relationQuery - .getEntity() - .withoutRelationshipConstraints( relationName, function() { - return invoke( relationQuery.getEntity(), relationName ); - } ); - } ); - - if ( listLen( arguments.relationshipName, "." ) == 1 ) { - var q = relation - .addCompareConstraints() - .when( !isNull( arguments.operator ) && !isNull( arguments.count ), function( q ) { - q.having( q.raw( "COUNT(*)" ), operator, count ); - } ); - - if ( structKeyExists( q, "retrieveQuery" ) ) { - q = q.retrieveQuery(); - } - - if ( structKeyExists( q, "getQB" ) ) { - q = q.getQB(); - } - - return invoke( - arguments.relationQuery, - "whereExists", - { "query" : q } - ); - } - - var q = relation.addCompareConstraints(); - - if ( structKeyExists( q, "retrieveQuery" ) ) { - q = q.retrieveQuery(); - } - - if ( structKeyExists( q, "getQB" ) ) { - q = q.getQB(); - } - - arguments.relationQuery = invoke( - arguments.relationQuery, - "whereExists", - { "query" : q } - ); - - var result = hasNested( argumentCollection = arguments ); - return structKeyExists( result, "getQB" ) ? result.getQB() : result; - } - /** * Checks for the existence of a relationship when executing the query. * The existence check is constrained by a closure. @@ -595,12 +691,23 @@ component string combinator = "and", boolean negate = false ) { - var relation = getEntity().ignoreLoadedGuard( function() { - var relationName = listFirst( relationshipName, "." ); - return getEntity().withoutRelationshipConstraints( relationName, function() { - return invoke( getEntity(), relationName ); - } ); - } ); + return applyWhereHas( argumentCollection = arguments ); + } + + /** + * Internal implementation for relationship existence checks. Unlike the + * public API, this helper can omit the callback for plain nested `has` calls. + */ + private QuickQB function applyWhereHas( + required string relationshipName, + any callback, + struct directConstraint, + any operator, + any count, + string combinator = "and", + boolean negate = false + ) { + var relation = resolveRelationship( getEntity(), listFirst( arguments.relationshipName, "." ) ); arguments.relationQuery = relation.addCompareConstraints( nested = true ).clearOrders(); @@ -626,13 +733,23 @@ component return this; } - var q = arguments.relationQuery - .when( !isNull( callback ), function( q ) { - callback( q ); - } ) - .when( !isNull( arguments.operator ) && !isNull( arguments.count ), function( q ) { - q.having( q.raw( "COUNT(*)" ), operator, count ); - } ); + var q = arguments.relationQuery; + if ( structKeyExists( arguments, "directConstraint" ) && !isNull( arguments.directConstraint ) ) { + invoke( + q, + arguments.directConstraint.method, + arguments.directConstraint.arguments + ); + } else if ( structKeyExists( arguments, "callback" ) && !isNull( arguments.callback ) ) { + q.when( true, arguments.callback ); + } + if ( !isNull( arguments.operator ) && !isNull( arguments.count ) ) { + q.having( + q.raw( "COUNT(*)" ), + arguments.operator, + arguments.count + ); + } if ( structKeyExists( q, "retrieveQuery" ) ) { q = q.retrieveQuery(); @@ -651,6 +768,40 @@ component return this; } + /** + * Checks for the existence of a relationship with a column value constraint. + * This is a shortcut for passing a callback containing a single `where` clause to `whereHas`. + * + * @relationshipName The relationship to check. + * @column The related column to constrain. + * @operator The operator or value for the constraint. When `value` is omitted, this is treated as the value and the operator is `=`. + * @value The optional value with which to constrain the related column. + * + * @return quick.models.QuickQB + */ + public QuickQB function whereHasValue( + required string relationshipName, + required any column, + required any operator, + any value + ) { + var whereArguments = { + "column" : arguments.column, + "operator" : arguments.operator + }; + if ( structKeyExists( arguments, "value" ) ) { + whereArguments[ "value" ] = arguments.value; + } + + return applyWhereHas( + relationshipName = arguments.relationshipName, + directConstraint = { + "method" : "where", + "arguments" : whereArguments + } + ); + } + /** * Checks for the absence of a relationship when executing the query. * The absence check is constrained by a closure. @@ -690,30 +841,33 @@ component required any relationQuery, required string relationshipName, any callback, + struct directConstraint, any operator, numeric count ) { - var relation = arguments.relationQuery - .getEntity() - .ignoreLoadedGuard( function() { - var relationName = listFirst( relationshipName, "." ); - return relationQuery - .getEntity() - .withoutRelationshipConstraints( relationName, function() { - return invoke( relationQuery.getEntity(), relationName ); - } ); - } ); + var relation = resolveRelationship( + arguments.relationQuery.getEntity(), + listFirst( arguments.relationshipName, "." ) + ); if ( listLen( arguments.relationshipName, "." ) == 1 ) { - var q = relation - .addCompareConstraints( nested = arguments.relationQuery ) - .clearOrders() - .when( !isNull( callback ), function( q ) { - callback( q ); - } ) - .when( !isNull( arguments.operator ) && !isNull( arguments.count ), function( q ) { - q.having( q.raw( "COUNT(*)" ), operator, count ); - } ); + var q = relation.addCompareConstraints( nested = arguments.relationQuery ).clearOrders(); + if ( structKeyExists( arguments, "directConstraint" ) && !isNull( arguments.directConstraint ) ) { + invoke( + q, + arguments.directConstraint.method, + arguments.directConstraint.arguments + ); + } else if ( structKeyExists( arguments, "callback" ) && !isNull( arguments.callback ) ) { + q.when( true, arguments.callback ); + } + if ( !isNull( arguments.operator ) && !isNull( arguments.count ) ) { + q.having( + q.raw( "COUNT(*)" ), + arguments.operator, + arguments.count + ); + } if ( structKeyExists( q, "retrieveQuery" ) ) { q = q.retrieveQuery(); @@ -734,15 +888,26 @@ component q = q.getQB(); } + var nestedArguments = { + "relationQuery" : q, + "relationshipName" : listRest( arguments.relationshipName, "." ) + }; + for ( + var optionalArgument in [ + "callback", + "directConstraint", + "operator", + "count" + ] + ) { + if ( structKeyExists( arguments, optionalArgument ) && !isNull( arguments[ optionalArgument ] ) ) { + nestedArguments[ optionalArgument ] = arguments[ optionalArgument ]; + } + } + var result = relation.nestCompareConstraints( base = arguments.relationQuery, - nested = whereHasNested( - relationQuery = q, - relationshipName = listRest( arguments.relationshipName, "." ), - callback = structKeyExists( arguments, "callback" ) ? arguments.callback : javacast( "null", "" ), - operator = structKeyExists( arguments, "operator" ) ? arguments.operator : javacast( "null", "" ), - count = structKeyExists( arguments, "count" ) ? arguments.count : javacast( "null", "" ) - ) + nested = whereHasNested( argumentCollection = nestedArguments ) ); return structKeyExists( result, "getQB" ) ? result.getQB() : result; @@ -947,13 +1112,17 @@ component */ public QueryBuilder function newQuery() { var newBuilder = new quick.models.QuickQB( - grammar = getGrammar(), - utils = getUtils(), - returnFormat = getReturnFormat(), - paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, - columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), - parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery().clone(), - defaultOptions = getDefaultOptions() + grammar = getGrammar(), + utils = getUtils(), + returnFormat = getReturnFormat(), + returnFormatterRegistry = getReturnFormatterRegistry(), + paginationCollector = isNull( variables.paginationCollector ) ? javacast( "null", "" ) : variables.paginationCollector, + columnFormatter = isNull( getColumnFormatter() ) ? javacast( "null", "" ) : getColumnFormatter(), + parentQuery = isNull( getParentQuery() ) ? javacast( "null", "" ) : getParentQuery().clone(), + defaultOptions = getDefaultOptions(), + validateDuplicateSelectColumns = getValidateDuplicateSelectColumns(), + validateQueryExecuteReturnType = getValidateQueryExecuteReturnType(), + collectQueryLog = getCollectQueryLog() ); newBuilder.setQuickBuilder( getQuickBuilder() ); newBuilder.setEntity( getEntity() ); @@ -986,7 +1155,31 @@ component return result; } - return super.onMissingMethod( argumentCollection = arguments ); + var qbResult = javacast( "null", "" ); + var qbError = {}; + try { + qbResult = super.onMissingMethod( argumentCollection = arguments ); + } catch ( QBMissingMethod e ) { + qbError = e; + } + + if ( !isNull( qbResult ) ) { + return qbResult; + } + + throw( + type = "QuickMissingMethod", + message = arrayToList( + [ + "Quick couldn't figure out what to do with [#arguments.missingMethodName#].", + qbError.keyExists( "message" ) ? "The error returned was: #qbError.message#" : "qb did not return a result.", + "We tried checking columns, aliases, scopes, and relationships locally.", + "We also forwarded the call on to qb to see if it could do anything with it, but it couldn't." + ], + " " + ), + extendedInfo = serializeJSON( qbError ) + ); } // override's super impl diff --git a/models/Relationships/BaseRelationship.cfc b/models/Relationships/BaseRelationship.cfc index 6850674f..0ee26447 100644 --- a/models/Relationships/BaseRelationship.cfc +++ b/models/Relationships/BaseRelationship.cfc @@ -116,6 +116,23 @@ component accessors="true" implements="IRelationship" { return this; } + /** + * Initializes a relationship to its unloaded default value. + * To-one relationships default to null. Collection relationships override + * this method to initialize an empty array. + * + * @entities The entities on which to initialize the relationship. + * @relation The relationship name to initialize. + * + * @return [quick.models.BaseEntity] + */ + public array function initRelation( required array entities, required string relation ) { + for ( var entity in arguments.entities ) { + entity.assignRelationship( arguments.relation, javacast( "null", "" ) ); + } + return arguments.entities; + } + /** * Retrieves the entities for eager loading. * @@ -123,11 +140,10 @@ component accessors="true" implements="IRelationship" { * @return [quick.models.BaseEntity] */ public array function getEager( boolean asQuery = false, boolean withAliases = false ) { - return variables.relationshipBuilder - .when( arguments.asQuery, function( qb ) { - qb.asQuery( withAliases ); - } ) - .get(); + if ( arguments.asQuery ) { + variables.relationshipBuilder.asQuery( arguments.withAliases ); + } + return variables.relationshipBuilder.get(); } /** @@ -321,7 +337,18 @@ component accessors="true" implements="IRelationship" { * @return quick.models.BaseEntity or [quick.models.BaseEntity] */ public any function get() { - return variables.getResults(); + var results = variables.getResults(); + if ( isNull( results ) ) { + return javacast( "null", "" ); + } + if ( + !isArray( results ) && + structKeyExists( results, "isQuickEntity" ) && + variables.relationshipBuilder.get_asMemento() + ) { + return results.getMemento( argumentCollection = variables.relationshipBuilder.get_asMementoSettings() ); + } + return results; } public any function getResults() { @@ -359,22 +386,30 @@ component accessors="true" implements="IRelationship" { required array keys, required any baseEntity ) { - return unique( - arguments.entities.reduce( function( acc, entity ) { - var keyValues = []; - for ( var key in keys ) { - var value = structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( key ) : entity[ key ]; - if ( entityIsNullValue( baseEntity, key, value ) ) { - return acc; - } - keyValues.append( value ); + var seenKeys = createObject( "java", "java.util.LinkedHashSet" ).init(); + var entityKeys = []; + for ( var entity in arguments.entities ) { + var keyValues = []; + var hasNull = false; + for ( var key in arguments.keys ) { + var value = structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( key ) : entity[ key ]; + if ( entityIsNullValue( arguments.baseEntity, key, value ) ) { + hasNull = true; + break; + } + keyValues.append( value ); + } + + if ( !hasNull ) { + var serializedKey = serializeJSON( keyValues ); + if ( seenKeys.contains( serializedKey ) ) { + continue; } - acc.append( keyValues.toList() ); - return acc; - }, [] ) - ).map( function( key ) { - return key.listToArray(); - } ); + seenKeys.add( serializedKey ); + entityKeys.append( keyValues ); + } + } + return entityKeys; } /** @@ -386,7 +421,7 @@ component accessors="true" implements="IRelationship" { */ public boolean function fieldsAreNull( required any entity, required array fields ) { for ( var field in arguments.fields ) { - if ( !arguments.entity.isNullValue( field ) ) { + if ( !arguments.entity.isNullValue( field, arguments.entity.retrieveAttribute( field ) ) ) { return false; } } @@ -401,19 +436,61 @@ component accessors="true" implements="IRelationship" { * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { - return arguments.base - .select( variables.relationshipBuilder.raw( 1 ) ) - .where( function( q ) { - arrayZipEach( - [ - getExistenceLocalKeys( base ), - getExistenceCompareKeys( base ) - ], - function( qualifiedLocalKey, existenceCompareKey ) { - q.whereColumn( qualifiedLocalKey, existenceCompareKey ); - } - ); - } ); + arguments.base.select( variables.relationshipBuilder.raw( 1 ) ); + var localKeys = getExistenceLocalKeys( arguments.base ); + var compareKeys = getExistenceCompareKeys( arguments.base ); + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.whereColumn( localKeys[ i ], compareKeys[ i ] ); + } + query.addNestedWhereQuery( constraints ); + return arguments.base; + } + + /** + * Creates a detached qb join object that can be configured before attachment. + */ + private any function queryBuilderFor( required any builder ) { + var query = arguments.builder; + if ( structKeyExists( query, "retrieveQuery" ) ) { + return query.retrieveQuery(); + } + if ( structKeyExists( query, "isQuickBuilder" ) ) { + return query.getQB(); + } + if ( !structKeyExists( query, "isBuilder" ) && structKeyExists( query, "getQuickBuilder" ) ) { + return query.getQuickBuilder().getQB(); + } + return query; + } + + private any function newJoinClause( + required any builder, + required any table, + string type = "inner" + ) { + var query = queryBuilderFor( arguments.builder ); + return query.newJoin( table = arguments.table, type = arguments.type ); + } + + /** + * Attaches a prebuilt join without qb cloning it. This matches callback-based + * joins, including their duplicate-detection behavior for shared predicates. + */ + private void function attachJoinClause( required any builder, required any join ) { + var query = queryBuilderFor( arguments.builder ); + + if ( query.getPreventDuplicateJoins() ) { + for ( var existingJoin in query.getJoins() ) { + if ( existingJoin.isEqualTo( arguments.join ) ) { + return; + } + } + } + + query.getJoins().append( arguments.join ); + query.addBindings( arguments.join.getBindings(), "join" ); } public any function nestCompareConstraints( required any base, required any nested ) { @@ -553,20 +630,6 @@ component accessors="true" implements="IRelationship" { return arraySlice( createObject( "java", "java.util.HashSet" ).init( arguments.items ).toArray(), 1 ); } - /** - * Calls the callback with the given value and then returns the given value. - * Nice to avoid temporary variables. - * - * @value The value to pass to the callback and as the return value. - * @callback The callback to execute. - * - * @return any - */ - private any function tap( required any value, required any callback ) { - arguments.callback( arguments.value ); - return arguments.value; - } - /** * Ensures the return value is an array, either by returning an array * or by returning the value wrapped in an array. @@ -597,9 +660,10 @@ component accessors="true" implements="IRelationship" { return arguments.arrays; } - var lengths = arguments.arrays.map( function( arr ) { - return arr.len(); - } ); + var lengths = []; + for ( var arr in arguments.arrays ) { + lengths.append( arr.len() ); + } if ( unique( lengths ).len() > 1 ) { throw( diff --git a/models/Relationships/BelongsTo.cfc b/models/Relationships/BelongsTo.cfc index d9021180..61561953 100644 --- a/models/Relationships/BelongsTo.cfc +++ b/models/Relationships/BelongsTo.cfc @@ -112,20 +112,14 @@ component * @return void */ public void function addConstraints() { - variables.relationshipBuilder.where( function( q ) { - arrayZipEach( - [ - variables.localKeys, - variables.foreignKeys - ], - function( localKey, foreignKey ) { - q.where( - variables.related.qualifyColumn( localKey ), - variables.child.retrieveAttribute( foreignKey ) - ); - } + var constraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var i = 1; i <= variables.localKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.localKeys[ i ] ), + variables.child.retrieveAttribute( variables.foreignKeys[ i ] ) ); - } ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( constraints ); } /** @@ -140,18 +134,18 @@ component if ( allKeys.isEmpty() ) { return false; } - variables.relationshipBuilder.where( function( q1 ) { - allKeys.each( function( keys ) { - q1.orWhere( function( q2 ) { - arrayZipEach( [ variables.localKeys, keys ], function( localKey, key ) { - q2.where( - variables.related.qualifyColumn( localKey ), - variables.related.generateQueryParamStruct( localKey, key ) - ); - } ); - } ); - } ); - } ); + var eagerConstraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= variables.localKeys.len(); i++ ) { + keyConstraints.where( + variables.related.qualifyColumn( variables.localKeys[ i ] ), + variables.related.generateQueryParamStruct( variables.localKeys[ i ], keys[ i ] ) + ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( eagerConstraints ); return true; } @@ -164,48 +158,39 @@ component * @return [any] */ public array function getEagerEntityKeys( required array entities, required any baseEntity ) { - return arguments.entities - .reduce( function( keys, entity ) { - var values = variables.foreignKeys - .map( function( foreignKey ) { - return { - "foreignKey" : foreignKey, - "value" : entityRetrieveAttribute( entity, foreignKey, baseEntity ) - }; - } ) - .filter( function( map ) { - if ( !structKeyExists( map, "value" ) ) { - return false; - } - - if ( isNull( map.value ) ) { - return false; - } - - if ( !entityHasAttribute( entity, map.foreignKey, baseEntity ) ) { - return false; - } - - if ( baseEntity.isNullValue( map.foreignKey, map.value ) ) { - return false; - } - - return true; - } ) - .map( function( map ) { - return map.value; - } ); - - if ( values.len() == variables.foreignKeys.len() ) { - arguments.keys[ values.toList() ] = {}; + var seenKeys = createObject( "java", "java.util.LinkedHashSet" ).init(); + var eagerEntityKeys = []; + for ( var entity in arguments.entities ) { + var values = []; + for ( var foreignKey in variables.foreignKeys ) { + if ( + !entityHasAttribute( + entity, + foreignKey, + arguments.baseEntity + ) + ) { + break; } - - return arguments.keys; - }, {} ) - .keyArray() - .map( function( key ) { - return key.listToArray(); - } ); + var value = entityRetrieveAttribute( + entity, + foreignKey, + arguments.baseEntity + ); + if ( isNull( value ) || arguments.baseEntity.isNullValue( foreignKey, value ) ) { + break; + } + values.append( value ); + } + if ( values.len() == variables.foreignKeys.len() ) { + var serializedKey = serializeJSON( values ); + if ( !seenKeys.contains( serializedKey ) ) { + seenKeys.add( serializedKey ); + eagerEntityKeys.append( values ); + } + } + } + return eagerEntityKeys; } /** @@ -218,17 +203,17 @@ component * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - arguments.entities.each( function( entity ) { + for ( var entity in arguments.entities ) { var defaultEntity = newDefaultEntity(); - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( - relation, + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( + arguments.relation, isNull( defaultEntity ) ? javacast( "null", "" ) : defaultEntity ); } else { - arguments.entity[ relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); + entity[ arguments.relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); } - } ); + } return arguments.entities; } @@ -248,32 +233,31 @@ component required array results, required string relation ) { - var dictionary = arguments.results.reduce( function( dict, result ) { - var key = variables.localKeys - .map( function( localKey ) { - return structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( localKey ) : result[ - localKey - ]; - } ) - .toList(); - arguments.dict[ key ] = arguments.result; - return arguments.dict; - }, {} ); - - arguments.entities.each( function( entity ) { - var foreignKeyValue = variables.foreignKeys - .map( function( foreignKey ) { - return entityRetrieveAttribute( entity, foreignKey, variables.parent ); - } ) - .toList(); + var dictionary = {}; + for ( var result in arguments.results ) { + var keyValues = []; + for ( var localKey in variables.localKeys ) { + keyValues.append( + structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( localKey ) : result[ localKey ] + ); + } + dictionary[ keyValues.toList() ] = result; + } + + for ( var entity in arguments.entities ) { + var foreignKeyValues = []; + for ( var foreignKey in variables.foreignKeys ) { + foreignKeyValues.append( entityRetrieveAttribute( entity, foreignKey, variables.parent ) ); + } + var foreignKeyValue = foreignKeyValues.toList(); if ( structKeyExists( dictionary, foreignKeyValue ) ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, dictionary[ foreignKeyValue ] ); + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, dictionary[ foreignKeyValue ] ); } else { - arguments.entity[ relation ] = dictionary[ foreignKeyValue ]; + entity[ arguments.relation ] = dictionary[ foreignKeyValue ]; } } - } ); + } return arguments.entities; } @@ -306,21 +290,20 @@ component * @return quick.models.BaseEntity */ public any function associate( required any entity ) { - var localKeyValues = !isObject( arguments.entity ) ? arrayWrap( arguments.entity ) : variables.localKeys.map( function( localKey ) { - return entity.retrieveAttribute( localKey ); - } ); + var localKeyValues = []; + if ( isObject( arguments.entity ) ) { + for ( var localKey in variables.localKeys ) { + localKeyValues.append( arguments.entity.retrieveAttribute( localKey ) ); + } + } else { + localKeyValues = arrayWrap( arguments.entity ); + } guardAgainstKeyLengthMismatch( localKeyValues, variables.foreignKeys ); - arrayZipEach( - [ - variables.foreignKeys, - localKeyValues - ], - function( foreignKey, localKeyValue ) { - variables.child.forceAssignAttribute( foreignKey, localKeyValue ); - } - ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + variables.child.forceAssignAttribute( variables.foreignKeys[ i ], localKeyValues[ i ] ); + } if ( isObject( arguments.entity ) ) { variables.child.assignRelationship( variables.relationMethodName, arguments.entity ); @@ -329,6 +312,23 @@ component return variables.child; } + /** + * Creates the related parent entity, associates it to the child, and caches + * it as the loaded relationship value. The child entity is not saved. + * + * @attributes The attributes for the new related entity. + * + * @return quick.models.BaseEntity + */ + public any function create( struct attributes = {} ) { + var createdEntity = variables.related + .newEntity() + .fill( arguments.attributes ) + .save(); + associate( createdEntity ); + return createdEntity; + } + /** * Removes an entity as the parent of the relationship. * For example, if a Post belongs to a User, dissociate will set the @@ -337,11 +337,11 @@ component * @return quick.models.BaseEntity */ public any function dissociate() { - return tap( variables.child.clearRelationship( variables.relationMethodName ), function( entity ) { - variables.foreignKeys.each( function( foreignKey ) { - entity.forceClearAttribute( name = foreignKey, setToNull = true ); - } ); - } ); + var entity = variables.child.clearRelationship( variables.relationMethodName ); + for ( var foreignKey in variables.foreignKeys ) { + entity.forceClearAttribute( name = foreignKey, setToNull = true ); + } + return entity; } /** @@ -351,9 +351,11 @@ component * @return [String] */ public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { - return variables.localKeys.map( function( localKey ) { - return variables.related.qualifyColumn( localKey ); - } ); + var qualifiedLocalKeys = []; + for ( var localKey in variables.localKeys ) { + qualifiedLocalKeys.append( variables.related.qualifyColumn( localKey ) ); + } + return qualifiedLocalKeys; } /** @@ -363,9 +365,11 @@ component * @return [String] */ public array function getExistenceCompareKeys( any builder = variables.relationshipBuilder ) { - return variables.foreignKeys.map( function( foreignKey ) { - return variables.child.qualifyColumn( foreignKey ); - } ); + var compareKeys = []; + for ( var foreignKey in variables.foreignKeys ) { + compareKeys.append( variables.child.qualifyColumn( foreignKey ) ); + } + return compareKeys; } /** @@ -376,18 +380,12 @@ component * @return void */ public QuickBuilder function applyThroughExists( required QuickBuilder base ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - base.whereColumn( - variables.child.qualifyColumn( foreignKey ), - variables.related.qualifyColumn( localKey ) - ); - } - ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + arguments.base.whereColumn( + variables.child.qualifyColumn( variables.foreignKeys[ i ] ), + variables.related.qualifyColumn( variables.localKeys[ i ] ) + ); + } return variables.related .newQuery() .reselectRaw( 1 ) @@ -397,18 +395,12 @@ component public QuickBuilder function initialThroughConstraints() { var base = variables.related.newQuery().reselectRaw( 1 ); - arrayZipEach( - [ - variables.localKeys, - variables.foreignKeys - ], - function( localKey, foreignKey ) { - base.where( - variables.related.qualifyColumn( localKey ), - variables.parent.retrieveAttribute( foreignKey ) - ); - } - ); + for ( var i = 1; i <= variables.localKeys.len(); i++ ) { + base.where( + variables.related.qualifyColumn( variables.localKeys[ i ] ), + variables.parent.retrieveAttribute( variables.foreignKeys[ i ] ) + ); + } return base; } @@ -421,17 +413,14 @@ component * @return void */ public void function applyThroughJoin( required any base ) { - arguments.base.join( variables.child.tableName(), function( j ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - j.on( variables.child.qualifyColumn( foreignKey ), variables.related.qualifyColumn( localKey ) ); - } + var join = newJoinClause( arguments.base, variables.child.tableName() ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + join.on( + variables.child.qualifyColumn( variables.foreignKeys[ i ] ), + variables.related.qualifyColumn( variables.localKeys[ i ] ) ); - } ); + } + attachJoinClause( arguments.base, join ); } /** @@ -442,20 +431,15 @@ component * @return void */ public void function applyThroughConstraints( required any base ) { - arguments.base.where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( localKey ), - variables.child.retrieveAttribute( foreignKey ) - ); - } + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.localKeys[ i ] ), + variables.child.retrieveAttribute( variables.foreignKeys[ i ] ) ); - } ); + } + query.addNestedWhereQuery( constraints ); } public struct function appendToDeepRelationship( diff --git a/models/Relationships/BelongsToMany.cfc b/models/Relationships/BelongsToMany.cfc index 6acb2d72..4793ddbc 100644 --- a/models/Relationships/BelongsToMany.cfc +++ b/models/Relationships/BelongsToMany.cfc @@ -57,6 +57,36 @@ component */ property name="tableSuffix" type="string"; + /** + * Additional pivot columns to hydrate on the pivot model. + */ + property name="pivotColumns" type="array"; + + /** + * The relationship name used to expose the hydrated pivot model. + */ + property name="pivotAccessor" type="string"; + + /** + * An optional custom Pivot entity mapping. + */ + property name="pivotEntity"; + + /** + * Internal aliases used to keep pivot columns separate from related columns. + */ + property name="pivotColumnAliases" type="struct"; + + /** + * Values applied to pivot writes and relationship constraints. + */ + property name="pivotValues" type="struct"; + + /** + * Configured created and modified timestamp columns for pivot writes. + */ + property name="pivotTimestampColumns" type="array"; + /** * Used to check for the type of relationship more quickly than using isInstanceOf. */ @@ -94,21 +124,33 @@ component required array relatedKeys, boolean withConstraints = true ) { - variables.table = arguments.table; - variables.parentKeys = arguments.parentKeys; - variables.foreignKeys = arguments.parentKeys; - variables.relatedKeys = arguments.relatedKeys; - variables.relatedPivotKeys = arguments.relatedPivotKeys; - variables.foreignPivotKeys = arguments.foreignPivotKeys; - variables.tablePrefix = ""; - - return super.init( + variables.table = arguments.table; + variables.parentKeys = arguments.parentKeys; + variables.foreignKeys = arguments.parentKeys; + variables.relatedKeys = arguments.relatedKeys; + variables.relatedPivotKeys = arguments.relatedPivotKeys; + variables.foreignPivotKeys = arguments.foreignPivotKeys; + variables.tablePrefix = ""; + variables.pivotColumns = []; + variables.pivotAccessor = "pivot"; + variables.pivotColumnAliases = {}; + variables.pivotValues = {}; + variables.pivotTimestampColumns = []; + variables.pivotEntity = ""; + + super.init( related = arguments.related, relationName = arguments.relationName, relationMethodName = arguments.relationMethodName, parent = arguments.parent, withConstraints = arguments.withConstraints ); + + variables.relationshipBuilder.addEntityTransformer( function( entity ) { + return hydratePivot( arguments.entity ); + } ); + + return this; } /** @@ -128,6 +170,7 @@ component */ public void function addConstraints() { performJoin(); + addPivotSelects(); addWhereConstraints(); } @@ -149,26 +192,18 @@ component } performJoin(); - variables.foreignPivotKeys.each( function( foreignPivotKey ) { - variables.relationshipBuilder.addSelect( listLast( variables.table, " " ) & "." & foreignPivotKey ); - variables.relationshipBuilder.appendVirtualAttribute( name = foreignPivotKey, excludeFromMemento = true ); - } ); + addPivotSelects(); - variables.relationshipBuilder.where( function( q1 ) { - allKeys.each( function( keys ) { - q1.orWhere( function( q2 ) { - arrayZipEach( - [ - getQualifiedForeignPivotKeyNames(), - keys - ], - function( foreignPivotKeyName, keyValue ) { - q2.where( foreignPivotKeyName, keyValue ); - } - ); - } ); - } ); - } ); + var eagerConstraints = variables.relationshipBuilder.getQB().forNestedWhere(); + var qualifiedPivotKeys = getQualifiedForeignPivotKeyNames(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= qualifiedPivotKeys.len(); i++ ) { + keyConstraints.where( qualifiedPivotKeys[ i ], keys[ i ] ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( eagerConstraints ); return true; } @@ -182,14 +217,14 @@ component * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, [] ); + for ( var entity in arguments.entities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, [] ); } else { - arguments.entity[ relation ] = []; + entity[ arguments.relation ] = []; } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -209,23 +244,23 @@ component required string relation ) { var dictionary = variables.buildDictionary( arguments.results ); - arguments.entities.each( function( entity ) { - var parentDictionaryKey = variables.parentKeys - .map( function( parentKey ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( parentKey ) : entity[ - parentKey - ]; - } ) - .toList(); + for ( var entity in arguments.entities ) { + var parentKeyValues = []; + for ( var parentKey in variables.parentKeys ) { + parentKeyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( parentKey ) : entity[ parentKey ] + ); + } + var parentDictionaryKey = parentKeyValues.toList(); if ( structKeyExists( dictionary, parentDictionaryKey ) ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, dictionary[ parentDictionaryKey ] ); + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, dictionary[ parentDictionaryKey ] ); } else { - arguments.entity[ relation ] = dictionary[ parentDictionaryKey ]; + entity[ arguments.relation ] = dictionary[ parentDictionaryKey ]; } } - } ); + } return arguments.entities; } @@ -238,20 +273,26 @@ component * @return {any: quick.models.BaseEntity} */ public struct function buildDictionary( required array results ) { - return arguments.results.reduce( function( dict, result ) { - var key = variables.foreignPivotKeys - .map( function( foreignPivotKey ) { - return structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( foreignPivotKey ) : result[ - foreignPivotKey - ]; - } ) - .toList(); - if ( !structKeyExists( arguments.dict, key ) ) { - arguments.dict[ key ] = []; + var dictionary = {}; + for ( var result in arguments.results ) { + var pivot = structKeyExists( result, "isQuickEntity" ) + ? result.retrieveRelationship( variables.pivotAccessor ) + : {}; + var keyValues = []; + for ( var foreignPivotKey in variables.foreignPivotKeys ) { + keyValues.append( + structKeyExists( result, "isQuickEntity" ) ? pivot.retrieveAttribute( foreignPivotKey ) : result[ + variables.pivotColumnAliases[ foreignPivotKey ] + ] + ); + } + var key = keyValues.toList(); + if ( !structKeyExists( dictionary, key ) ) { + dictionary[ key ] = []; } - arrayAppend( arguments.dict[ key ], arguments.result ); - return arguments.dict; - }, {} ); + arrayAppend( dictionary[ key ], result ); + } + return dictionary; } /** @@ -260,17 +301,12 @@ component * @return quick.models.Relationships.BelongsToMany */ public BelongsToMany function performJoin( any base = variables.relationshipBuilder ) { - arguments.base.join( variables.table, function( j ) { - arrayZipEach( - [ - variables.relatedKeys, - getQualifiedRelatedPivotKeyNames() - ], - function( relatedKey, pivotKey ) { - j.on( variables.related.qualifyColumn( relatedKey ), pivotKey ); - } - ); - } ); + var join = newJoinClause( arguments.base, variables.table ); + var pivotKeys = getQualifiedRelatedPivotKeyNames(); + for ( var i = 1; i <= variables.relatedKeys.len(); i++ ) { + join.on( variables.related.qualifyColumn( variables.relatedKeys[ i ] ), pivotKeys[ i ] ); + } + attachJoinClause( arguments.base, join ); return this; } @@ -280,17 +316,12 @@ component * @return quick.models.Relationships.BelongsToMany */ public BelongsToMany function addWhereConstraints() { - variables.relationshipBuilder.where( function( q ) { - arrayZipEach( - [ - getQualifiedForeignPivotKeyNames(), - variables.parentKeys - ], - function( pivotKey, parentKey ) { - q.where( pivotKey, variables.parent.retrieveAttribute( parentKey ) ); - } - ); - } ); + var pivotKeys = getQualifiedForeignPivotKeyNames(); + var constraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var i = 1; i <= pivotKeys.len(); i++ ) { + constraints.where( pivotKeys[ i ], variables.parent.retrieveAttribute( variables.parentKeys[ i ] ) ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( constraints ); return this; } @@ -302,9 +333,11 @@ component * @return [String] */ public array function getQualifiedRelatedPivotKeyNames() { - return variables.relatedPivotKeys.map( function( relatedPivotKey ) { - return listLast( variables.table, " " ) & "." & relatedPivotKey; - } ); + var qualifiedPivotKeys = []; + for ( var relatedPivotKey in variables.relatedPivotKeys ) { + qualifiedPivotKeys.append( listLast( variables.table, " " ) & "." & relatedPivotKey ); + } + return qualifiedPivotKeys; } /** @@ -315,23 +348,361 @@ component * @return [String] */ public array function getQualifiedForeignPivotKeyNames() { - return variables.foreignPivotKeys.map( function( foreignPivotKey ) { - return listLast( variables.table, " " ) & "." & foreignPivotKey; - } ); + var qualifiedPivotKeys = []; + for ( var foreignPivotKey in variables.foreignPivotKeys ) { + qualifiedPivotKeys.append( listLast( variables.table, " " ) & "." & foreignPivotKey ); + } + return qualifiedPivotKeys; + } + + /** + * Includes additional intermediate-table columns on each related entity's Pivot model. + * Accepts a column name, comma-delimited list, or array. + * + * @columns The pivot columns to include. + * + * @return quick.models.Relationships.BelongsToMany + */ + public BelongsToMany function withPivot( required any columns ) { + var normalizedColumns = isArray( arguments.columns ) + ? arguments.columns + : listToArray( arguments.columns ); + + for ( var column in normalizedColumns ) { + if ( !variables.pivotColumns.findNoCase( column ) ) { + variables.pivotColumns.append( column ); + } + } + + addPivotSelects(); + return this; + } + + /** + * Uses a custom loaded-relationship name instead of `pivot`. + */ + public BelongsToMany function as( required string accessor ) { + if ( !len( trim( arguments.accessor ) ) ) { + throw( type = "QuickInvalidPivotAccessor", message = "A pivot accessor cannot be empty." ); + } + variables.pivotAccessor = arguments.accessor; + return this; + } + + /** + * Uses a custom Pivot entity mapping for hydrated intermediate rows. + */ + public BelongsToMany function using( required string pivotEntity ) { + if ( !len( trim( arguments.pivotEntity ) ) ) { + throw( type = "QuickInvalidPivotModel", message = "A custom pivot model mapping cannot be empty." ); + } + variables.pivotEntity = arguments.pivotEntity; + return this; + } + + /** + * Includes and maintains timestamp columns on pivot writes. + */ + public BelongsToMany function withTimestamps( string createdAt = "created_at", string modifiedAt = "updated_at" ) { + variables.pivotTimestampColumns = [ + arguments.createdAt, + arguments.modifiedAt + ]; + return withPivot( variables.pivotTimestampColumns ); + } + + /** + * Adds a where constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivot( + required string column, + any operator, + any value, + string combinator = "and" + ) { + if ( !arguments.keyExists( "value" ) || isNull( arguments.value ) ) { + arguments.value = arguments.operator; + arguments.operator = "="; + } + variables.relationshipBuilder.where( + column = qualifyPivotColumn( arguments.column ), + operator = arguments.operator, + value = arguments.value, + combinator = arguments.combinator + ); + return this; + } + + /** + * Adds an or-where constraint using a qualified pivot column. + */ + public BelongsToMany function orWherePivot( + required string column, + any operator, + any value + ) { + arguments.combinator = "or"; + return wherePivot( argumentCollection = arguments ); + } + + /** + * Adds a where-in constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotIn( + required string column, + required any values, + string combinator = "and" + ) { + variables.relationshipBuilder.whereIn( + qualifyPivotColumn( arguments.column ), + arguments.values, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-not-in constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotIn( + required string column, + required any values, + string combinator = "and" + ) { + variables.relationshipBuilder.whereNotIn( + qualifyPivotColumn( arguments.column ), + arguments.values, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-between constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotBetween( + required string column, + required any start, + required any end, + string combinator = "and" + ) { + variables.relationshipBuilder.whereBetween( + qualifyPivotColumn( arguments.column ), + arguments.start, + arguments.end, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-not-between constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotBetween( + required string column, + required any start, + required any end, + string combinator = "and" + ) { + variables.relationshipBuilder.whereNotBetween( + qualifyPivotColumn( arguments.column ), + arguments.start, + arguments.end, + arguments.combinator + ); + return this; + } + + /** + * Adds a where-null constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNull( required string column, string combinator = "and" ) { + variables.relationshipBuilder.whereNull( qualifyPivotColumn( arguments.column ), arguments.combinator ); + return this; + } + + /** + * Adds a where-not-null constraint using a qualified pivot column. + */ + public BelongsToMany function wherePivotNotNull( required string column, string combinator = "and" ) { + variables.relationshipBuilder.whereNotNull( qualifyPivotColumn( arguments.column ), arguments.combinator ); + return this; + } + + /** + * Orders the related results using a qualified pivot column. + */ + public BelongsToMany function orderByPivot( required string column, string direction = "asc" ) { + variables.relationshipBuilder.orderBy( qualifyPivotColumn( arguments.column ), arguments.direction ); + return this; + } + + /** + * Orders the related results descending using a qualified pivot column. + */ + public BelongsToMany function orderByPivotDesc( required string column ) { + return orderByPivot( arguments.column, "desc" ); + } + + /** + * Constrains a pivot value and uses it as a default for pivot writes. + */ + public BelongsToMany function withPivotValue( required string column, required any value ) { + variables.pivotValues[ arguments.column ] = arguments.value; + withPivot( arguments.column ); + return wherePivot( arguments.column, arguments.value ); + } + + /** + * Adds any pivot columns not already present to the related select list. + */ + private void function addPivotSelects() { + addPivotSelectColumns( variables.foreignPivotKeys ); + addPivotSelectColumns( variables.relatedPivotKeys ); + addPivotSelectColumns( variables.pivotColumns ); + } + + private void function addPivotSelectColumns( required array columns ) { + for ( var column in arguments.columns ) { + if ( variables.pivotColumnAliases.keyExists( column ) ) { + continue; + } + + var aliasName = "__quick_pivot_#variables.pivotColumnAliases.count() + 1#"; + variables.pivotColumnAliases[ column ] = aliasName; + variables.relationshipBuilder.addSelect( "#qualifyPivotColumn( column )# AS #aliasName#" ); + variables.relationshipBuilder.appendVirtualAttribute( name = aliasName, excludeFromMemento = true ); + } + } + + /** + * Hydrates and assigns a Pivot model to a related entity. + */ + private any function hydratePivot( required any entity ) { + if ( !structKeyExists( arguments.entity, "isQuickEntity" ) ) { + return arguments.entity; + } + + var attributes = {}; + for ( var column in variables.pivotColumnAliases ) { + var value = arguments.entity.retrieveAttribute( variables.pivotColumnAliases[ column ] ); + attributes[ column ] = isNull( value ) ? javacast( "null", "" ) : value; + } + + var hasCustomPivot = len( variables.pivotEntity ) > 0; + var mapping = hasCustomPivot ? variables.pivotEntity : "Pivot@quick"; + var pivot = variables.wirebox.getInstance( mapping ); + if ( !structKeyExists( pivot, "isPivot" ) ) { + throw( + type = "QuickInvalidPivotModel", + message = "The custom pivot model [#mapping#] must extend [quick.models.Relationships.Pivot]." + ); + } + + if ( hasCustomPivot ) { + for ( var attributeName in attributes ) { + if ( !pivot.hasAttribute( attributeName ) ) { + throw( + type = "QuickPivotAttributeNotFound", + message = "The pivot attribute [#attributeName#] is not declared on [#mapping#]." + ); + } + } + } + + var keyNames = []; + keyNames.append( variables.foreignPivotKeys, true ); + keyNames.append( variables.relatedPivotKeys, true ); + + pivot.configurePivot( + table = listFirst( variables.table, " " ), + keyNames = keyNames, + attributes = attributes, + parent = variables.parent, + related = arguments.entity + ); + arguments.entity.assignRelationship( variables.pivotAccessor, pivot ); + return arguments.entity; + } + + /** + * Qualifies a pivot column unless it is already qualified. + */ + private string function qualifyPivotColumn( required string column ) { + return find( ".", arguments.column ) ? arguments.column : "#listLast( variables.table, " " )#.#arguments.column#"; + } + + /** + * Creates a new related entity and attaches it to the parent through the pivot table. + * + * @attributes Attributes for the related entity. + * @pivotAttributes Additional attributes for the pivot row. + * @ignoreNonExistentAttributes Whether to ignore attributes not defined on the related entity. + * @options Options passed to the related entity save query. + * + * @return quick.models.BaseEntity + */ + public any function create( + struct attributes = {}, + struct pivotAttributes = {}, + boolean ignoreNonExistentAttributes = false, + struct options = {} + ) { + var entity = variables.related.create( + arguments.attributes, + arguments.ignoreNonExistentAttributes, + arguments.options + ); + attach( entity, arguments.pivotAttributes ); + return entity; } /** * Associates one or more ids of the related entity to the parent entity. * - * @id The id or array of ids of the related entity. + * @id The id or array of ids of the related entity. + * @pivotAttributes Additional attributes for each inserted pivot row. * * @return quick.models.BaseEntity */ - public any function attach( required any id ) { - variables.newPivotStatement().insert( parseIdsForInsert( arguments.id ) ); + public any function attach( required any id, struct pivotAttributes = {} ) { + var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, true ); + variables.newPivotStatement().insert( parseIdsForInsert( arguments.id, attributes ) ); return variables.parent; } + /** + * Updates an existing intermediate-table row for the parent and related id. + * + * @id The related entity id or composite id values. + * @pivotAttributes The pivot values to update. Pivot keys cannot be overwritten. + * + * @return The number of updated rows. + */ + public any function updateExistingPivot( required any id, required struct pivotAttributes ) { + var attributes = buildPivotWriteAttributes( arguments.pivotAttributes, false ); + for ( var key in variables.foreignPivotKeys ) { + attributes.delete( key ); + } + for ( var key in variables.relatedPivotKeys ) { + attributes.delete( key ); + } + + var query = variables.newPivotStatement(); + for ( var i = 1; i <= variables.foreignPivotKeys.len(); i++ ) { + query.where( + variables.foreignPivotKeys[ i ], + variables.parent.retrieveAttribute( variables.parentKeys[ i ] ) + ); + } + var relatedIds = parseIds( arguments.id )[ 1 ]; + for ( var i = 1; i <= variables.relatedPivotKeys.len(); i++ ) { + query.where( variables.relatedPivotKeys[ i ], relatedIds[ i ] ); + } + + return query.update( attributes ); + } + /** * Deletes one or more ids of the related entity from the pivot table * where the foreign key is the parent's foreign key value.. @@ -341,32 +712,27 @@ component * @return quick.models.BaseEntity */ public any function detach( required any id ) { - var foreignPivotKeyValues = variables.parentKeys.map( function( parentKey ) { - return variables.parent.retrieveAttribute( parentKey ); - } ); - variables - .newPivotStatement() - .where( function( q ) { - arrayZipEach( - [ - variables.foreignPivotKeys, - foreignPivotKeyValues - ], - function( foreignPivotKey, foreignPivotKeyValue ) { - q.where( foreignPivotKey, foreignPivotKeyValue ); - } - ); - } ) - .where( function( q1 ) { - parseIds( arrayWrap( id ) ).each( function( ids ) { - q1.orWhere( function( q2 ) { - arrayZipEach( [ variables.relatedPivotKeys, ids ], function( relatedPivotKey, id ) { - q2.where( relatedPivotKey, id ); - } ); - } ); - } ); - } ) - .delete(); + var foreignPivotKeyValues = []; + for ( var parentKey in variables.parentKeys ) { + foreignPivotKeyValues.append( variables.parent.retrieveAttribute( parentKey ) ); + } + var parsedIds = parseIds( arrayWrap( arguments.id ) ); + var pivotQuery = variables.newPivotStatement(); + var parentConstraints = pivotQuery.getQB().forNestedWhere(); + for ( var i = 1; i <= variables.foreignPivotKeys.len(); i++ ) { + parentConstraints.where( variables.foreignPivotKeys[ i ], foreignPivotKeyValues[ i ] ); + } + pivotQuery.getQB().addNestedWhereQuery( parentConstraints ); + var relatedConstraints = pivotQuery.getQB().forNestedWhere(); + for ( var ids in parsedIds ) { + var idConstraints = relatedConstraints.forNestedWhere(); + for ( var i = 1; i <= variables.relatedPivotKeys.len(); i++ ) { + idConstraints.where( variables.relatedPivotKeys[ i ], ids[ i ] ); + } + relatedConstraints.addNestedWhereQuery( idConstraints, "or" ); + } + pivotQuery.getQB().addNestedWhereQuery( relatedConstraints ); + pivotQuery.delete(); return variables.parent; } @@ -396,25 +762,19 @@ component * * @return quick.models.BaseEntity */ - public any function sync( required any id ) { - var foreignPivotKeyValues = variables.parentKeys.map( function( parentKey ) { - return variables.parent.retrieveAttribute( parentKey ); - } ); - variables - .newPivotStatement() - .where( function( q ) { - arrayZipEach( - [ - variables.foreignPivotKeys, - foreignPivotKeyValues - ], - function( foreignPivotKey, foreignPivotKeyValue ) { - q.where( foreignPivotKey, foreignPivotKeyValue ); - } - ); - } ) - .delete(); - return variables.attach( arguments.id ); + public any function sync( required any id, struct pivotAttributes = {} ) { + var foreignPivotKeyValues = []; + for ( var parentKey in variables.parentKeys ) { + foreignPivotKeyValues.append( variables.parent.retrieveAttribute( parentKey ) ); + } + var pivotQuery = variables.newPivotStatement(); + var parentConstraints = pivotQuery.getQB().forNestedWhere(); + for ( var i = 1; i <= variables.foreignPivotKeys.len(); i++ ) { + parentConstraints.where( variables.foreignPivotKeys[ i ], foreignPivotKeyValues[ i ] ); + } + pivotQuery.getQB().addNestedWhereQuery( parentConstraints ); + pivotQuery.delete(); + return attach( arguments.id, arguments.pivotAttributes ); } /** @@ -436,14 +796,17 @@ component * @return [any] */ public array function parseIds( required any value ) { - return arrayWrap( arguments.value ).map( function( val ) { + var ids = []; + for ( var val in arrayWrap( arguments.value ) ) { // If the value is not a simple value, we will assume // it is an entity and return its key value. - if ( isObject( arguments.val ) ) { - return arguments.val.keyValues(); + if ( isObject( val ) ) { + ids.append( val.keyValues() ); + } else { + ids.append( arrayWrap( val ) ); } - return arrayWrap( arguments.val ); - } ); + } + return ids; } /** @@ -455,38 +818,51 @@ component * @doc_generic any,any * @return [{any: any}] */ - public array function parseIdsForInsert( required any value ) { - var foreignPivotKeyValues = variables.parentKeys.map( function( parentKey ) { - return variables.parent.retrieveAttribute( parentKey ); - } ); - return arrayWrap( arguments.value ).map( function( values ) { + public array function parseIdsForInsert( required any value, struct pivotAttributes = {} ) { + var foreignPivotKeyValues = []; + for ( var parentKey in variables.parentKeys ) { + foreignPivotKeyValues.append( variables.parent.retrieveAttribute( parentKey ) ); + } + var additionalPivotAttributes = arguments.pivotAttributes; + var insertRecords = []; + for ( var values in arrayWrap( arguments.value ) ) { // If the value is not a simple value, we will assume // it is an entity and return its key value. - if ( isObject( arguments.values ) ) { - arguments.values = arguments.values.keyValues(); + if ( isObject( values ) ) { + values = values.keyValues(); } else { - arguments.values = arrayWrap( arguments.values ); + values = arrayWrap( values ); } var insertRecord = {}; - arrayZipEach( - [ - variables.foreignPivotKeys, - foreignPivotKeyValues, - variables.relatedPivotKeys, - arguments.values - ], - function( - foreignPivotKey, - foreignPivotKeyValue, - relatedPivotKey, - val - ) { - insertRecord[ foreignPivotKey ] = foreignPivotKeyValue; - insertRecord[ relatedPivotKey ] = val; - } - ); - return insertRecord; - } ); + for ( var i = 1; i <= variables.foreignPivotKeys.len(); i++ ) { + insertRecord[ variables.foreignPivotKeys[ i ] ] = foreignPivotKeyValues[ i ]; + insertRecord[ variables.relatedPivotKeys[ i ] ] = values[ i ]; + } + insertRecord.append( additionalPivotAttributes, false ); + insertRecords.append( insertRecord ); + } + return insertRecords; + } + + /** + * Combines configured and supplied values and maintains pivot timestamps. + */ + private struct function buildPivotWriteAttributes( struct attributes = {}, boolean inserting = false ) { + var values = {}; + values.append( variables.pivotValues, true ); + values.append( arguments.attributes, true ); + + if ( variables.pivotTimestampColumns.len() == 2 ) { + var timestamp = now(); + if ( arguments.inserting && !values.keyExists( variables.pivotTimestampColumns[ 1 ] ) ) { + values[ variables.pivotTimestampColumns[ 1 ] ] = timestamp; + } + if ( !values.keyExists( variables.pivotTimestampColumns[ 2 ] ) ) { + values[ variables.pivotTimestampColumns[ 2 ] ] = timestamp; + } + } + + return values; } /** @@ -501,63 +877,53 @@ component return addNestedCompareConstraints( arguments.base, arguments.nested ); } - return arguments.base + var query = arguments.base .newQuery() .select( arguments.base.raw( 1 ) ) - .from( variables.table ) - .where( function( q ) { - arrayZipEach( - [ - getQualifiedForeignKeyNames(), - variables.parent.retrieveQualifiedKeyNames() - ], - function( foreignKeyName, keyName ) { - q.whereColumn( foreignKeyName, keyName ); - } - ); - } ); + .from( variables.table ); + var foreignKeyNames = getQualifiedForeignKeyNames(); + var parentKeyNames = variables.parent.retrieveQualifiedKeyNames(); + var qb = queryBuilderFor( query ); + var constraints = qb.forNestedWhere(); + for ( var i = 1; i <= foreignKeyNames.len(); i++ ) { + constraints.whereColumn( foreignKeyNames[ i ], parentKeyNames[ i ] ); + } + qb.addNestedWhereQuery( constraints ); + return query; } public any function addNestedCompareConstraints( required any base, required any nested ) { - return arguments.base - .select( arguments.base.raw( 1 ) ) - .whereExists( function( q ) { - q.selectRaw( 1 ).from( variables.table ); - arrayZipEach( - [ - getQualifiedRelatedPivotKeyNames(), - variables.related.retrieveQualifiedKeyNames() - ], - function( relatedPivotKeyName, keyName ) { - q.whereColumn( relatedPivotKeyName, keyName ); - } - ); - - var nestedQuery = isBoolean( nested ) ? q : nested.clone().select( base.raw( 1 ) ); - arrayZipEach( - [ - getQualifiedForeignKeyNames(), - variables.parent.retrieveQualifiedKeyNames() - ], - function( foreignKeyName, keyName ) { - nestedQuery.whereColumn( foreignKeyName, keyName ); - } - ); - - if ( isBoolean( nested ) ) { - return; - } - - if ( structKeyExists( nestedQuery, "retrieveQuery" ) ) { - nestedQuery = nestedQuery.retrieveQuery(); - } + arguments.base.select( arguments.base.raw( 1 ) ); + var existsQuery = arguments.base + .newQuery() + .selectRaw( 1 ) + .from( variables.table ); + var relatedPivotKeyNames = getQualifiedRelatedPivotKeyNames(); + var relatedKeyNames = variables.related.retrieveQualifiedKeyNames(); + for ( var i = 1; i <= relatedPivotKeyNames.len(); i++ ) { + existsQuery.whereColumn( relatedPivotKeyNames[ i ], relatedKeyNames[ i ] ); + } - if ( structKeyExists( nestedQuery, "getQB" ) ) { - nestedQuery = nestedQuery.getQB(); - } + var nestedQuery = isBoolean( arguments.nested ) ? existsQuery : arguments.nested + .clone() + .select( arguments.base.raw( 1 ) ); + var foreignKeyNames = getQualifiedForeignKeyNames(); + var parentKeyNames = variables.parent.retrieveQualifiedKeyNames(); + for ( var j = 1; j <= foreignKeyNames.len(); j++ ) { + nestedQuery.whereColumn( foreignKeyNames[ j ], parentKeyNames[ j ] ); + } - q.whereExists( nestedQuery ); - } ); + if ( !isBoolean( arguments.nested ) ) { + if ( structKeyExists( nestedQuery, "retrieveQuery" ) ) { + nestedQuery = nestedQuery.retrieveQuery(); + } + if ( structKeyExists( nestedQuery, "getQB" ) ) { + nestedQuery = nestedQuery.getQB(); + } + existsQuery.whereExists( nestedQuery ); + } + arguments.base.whereExists( existsQuery ); + return arguments.base; } function nestCompareConstraints( required any base, required any nested ) { @@ -596,15 +962,10 @@ component .reselectRaw( 1 ) .from( variables.table ); - arrayZipEach( - [ - variables.relatedKeys, - getQualifiedRelatedPivotKeyNames() - ], - function( relatedKey, pivotKey ) { - base.whereColumn( variables.related.qualifyColumn( relatedKey ), pivotKey ); - } - ); + var pivotKeys = getQualifiedRelatedPivotKeyNames(); + for ( var i = 1; i <= variables.relatedKeys.len(); i++ ) { + base.whereColumn( variables.related.qualifyColumn( variables.relatedKeys[ i ] ), pivotKeys[ i ] ); + } return variables.related .newQuery() @@ -621,15 +982,13 @@ component */ public QuickBuilder function applyThroughExists( required QuickBuilder base ) { // apply compare constraints for pivot table - arrayZipEach( - [ - variables.foreignKeys, - getQualifiedForeignPivotKeyNames() - ], - function( foreignKey, pivotKey ) { - base.whereColumn( variables.parent.qualifyColumn( foreignKey ), pivotKey ); - } - ); + var foreignPivotKeys = getQualifiedForeignPivotKeyNames(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + arguments.base.whereColumn( + variables.parent.qualifyColumn( variables.foreignKeys[ i ] ), + foreignPivotKeys[ i ] + ); + } // nest in where exists for pivot table arguments.base = variables.parent @@ -639,15 +998,13 @@ component .whereExists( structKeyExists( arguments.base, "isBuilder" ) ? arguments.base : arguments.base.getQB() ); // apply compare constraints for base table - arrayZipEach( - [ - variables.relatedKeys, - getQualifiedRelatedPivotKeyNames() - ], - function( relatedKey, pivotKey ) { - base.whereColumn( variables.related.qualifyColumn( relatedKey ), pivotKey ); - } - ); + var relatedPivotKeys = getQualifiedRelatedPivotKeyNames(); + for ( var j = 1; j <= variables.relatedKeys.len(); j++ ) { + arguments.base.whereColumn( + variables.related.qualifyColumn( variables.relatedKeys[ j ] ), + relatedPivotKeys[ j ] + ); + } // nest in where exists for base table return variables.related @@ -665,17 +1022,12 @@ component */ public void function applyThroughJoin( required any base ) { performJoin( arguments.base ); - arguments.base.join( variables.parent.tableName(), function( j ) { - arrayZipEach( - [ - variables.parentKeys, - getQualifiedForeignPivotKeyNames() - ], - function( parentKey, pivotKey ) { - j.on( variables.parent.qualifyColumn( parentKey ), pivotKey ); - } - ); - } ); + var join = newJoinClause( arguments.base, variables.parent.tableName() ); + var pivotKeys = getQualifiedForeignPivotKeyNames(); + for ( var i = 1; i <= variables.parentKeys.len(); i++ ) { + join.on( variables.parent.qualifyColumn( variables.parentKeys[ i ] ), pivotKeys[ i ] ); + } + attachJoinClause( arguments.base, join ); } /** @@ -688,20 +1040,16 @@ component public void function applyThroughConstraints( required any base ) { variables.parent.withAlias( variables.parent.tableName() & variables.tableSuffix ); performJoin( arguments.base ); - arguments.base.where( function( q ) { - arrayZipEach( - [ - getQualifiedForeignPivotKeyNames(), - variables.parentKeys - ], - function( localKey, parentKey ) { - q.where( - variables.related.qualifyColumn( localKey ), - variables.parent.retrieveAttribute( parentKey ) - ); - } + var query = queryBuilderFor( arguments.base ); + var localKeys = getQualifiedForeignPivotKeyNames(); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( localKeys[ i ] ), + variables.parent.retrieveAttribute( variables.parentKeys[ i ] ) ); - } ); + } + query.addNestedWhereQuery( constraints ); } public struct function appendToDeepRelationship( diff --git a/models/Relationships/BelongsToThrough.cfc b/models/Relationships/BelongsToThrough.cfc index 3f193fe3..9c7dcc93 100644 --- a/models/Relationships/BelongsToThrough.cfc +++ b/models/Relationships/BelongsToThrough.cfc @@ -108,7 +108,7 @@ component extends="quick.models.Relationships.BaseRelationship" { public boolean function addEagerConstraints( required array entities, required any baseEntity ) { var allKeys = getKeys( entities, - variables.closestToParent.getLocalKeys(), + variables.closestToParent.getForeignKeys(), arguments.baseEntity ); @@ -116,42 +116,35 @@ component extends="quick.models.Relationships.BaseRelationship" { return false; } - performJoin(); - var foreignKeys = variables.closestToParent.getForeignKeys(); - var qualifiedForeignKeyList = foreignKeys - .reduce( function( acc, foreignKey, i ) { - if ( i != 1 ) { - acc.append( "," ); - } - acc.append( variables.closestToParent.qualifyColumn( foreignKey ) ); - return acc; - }, [] ) - .toList(); - - variables.related - .when( - ( qualifiedForeignKeyList.listLen() > 1 ), - function( q1 ) { - q1.selectRaw( "CONCAT(#qualifiedForeignKeyList#) AS __QuickThroughKey__" ); - }, - function( q1 ) { - q1.addSelect( "#qualifiedForeignKeyList# AS __QuickThroughKey__" ); - } - ) - .appendVirtualAttribute( name = "__QuickThroughKey__", excludeFromMemento = true ) - .where( function( q1 ) { - allKeys.each( function( keys ) { - q1.orWhere( function( q2 ) { - arrayZipEach( [ foreignKeys, keys ], function( foreignKey, keyValue ) { - q2.where( - variables.closestToParent.qualifyColumn( foreignKey ), - variables.closestToParent.generateQueryParamStruct( foreignKey, keyValue ) - ); - } ); - } ); - } ); - } ); + performJoin( variables.relationshipBuilder ); + var relatedKeys = variables.closestToParent.getLocalKeys(); + var qualifiedForeignKeys = []; + for ( var i = 1; i <= relatedKeys.len(); i++ ) { + if ( i != 1 ) { + qualifiedForeignKeys.append( "," ); + } + qualifiedForeignKeys.append( variables.closestToParent.qualifyColumn( relatedKeys[ i ] ) ); + } + var qualifiedForeignKeyList = qualifiedForeignKeys.toList(); + if ( qualifiedForeignKeyList.listLen() > 1 ) { + variables.relationshipBuilder.selectRaw( "CONCAT(#qualifiedForeignKeyList#) AS __QuickThroughKey__" ); + } else { + variables.relationshipBuilder.addSelect( "#qualifiedForeignKeyList# AS __QuickThroughKey__" ); + } + variables.relationshipBuilder.appendVirtualAttribute( name = "__QuickThroughKey__", excludeFromMemento = true ); + var eagerConstraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= relatedKeys.len(); i++ ) { + keyConstraints.where( + variables.closestToParent.qualifyColumn( relatedKeys[ i ] ), + variables.closestToParent.generateQueryParamStruct( relatedKeys[ i ], keys[ i ] ) + ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( eagerConstraints ); return true; } @@ -164,14 +157,15 @@ component extends="quick.models.Relationships.BaseRelationship" { * @return {any: quick.models.BaseEntity} */ public struct function buildDictionary( required array results ) { - return arguments.results.reduce( function( dict, result ) { + var dictionary = {}; + for ( var result in arguments.results ) { var key = result.retrieveAttribute( "__QuickThroughKey__" ); - if ( !structKeyExists( arguments.dict, key ) ) { - arguments.dict[ key ] = []; + if ( !structKeyExists( dictionary, key ) ) { + dictionary[ key ] = []; } - arrayAppend( arguments.dict[ key ], arguments.result ); - return arguments.dict; - }, {} ); + arrayAppend( dictionary[ key ], result ); + } + return dictionary; } /** @@ -182,23 +176,20 @@ component extends="quick.models.Relationships.BaseRelationship" { * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ public any function addCompareConstraints( any base = variables.related, any nested ) { - return tap( arguments.base.select(), function( q ) { - performJoin( q ); - q.where( function( q2 ) { - arrayZipEach( - [ - variables.closestToParent.getForeignKeys(), - variables.closestToParent.getLocalKeys() - ], - function( localKey, foreignKey ) { - q2.whereColumn( - variables.parent.qualifyColumn( localKey ), - variables.closestToParent.qualifyColumn( foreignKey ) - ); - } - ); - } ); - } ); + var query = arguments.base.select(); + performJoin( query ); + var localKeys = variables.closestToParent.getForeignKeys(); + var foreignKeys = variables.closestToParent.getLocalKeys(); + var qb = queryBuilderFor( query ); + var constraints = qb.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.whereColumn( + variables.parent.qualifyColumn( localKeys[ i ] ), + variables.closestToParent.qualifyColumn( foreignKeys[ i ] ) + ); + } + qb.addNestedWhereQuery( constraints ); + return query; } /** @@ -243,9 +234,9 @@ component extends="quick.models.Relationships.BaseRelationship" { } if ( isClosure( variables.defaultAttributes ) || isCustomFunction( variables.defaultAttributes ) ) { - return tap( variables.related.newEntity(), function( newEntity ) { - variables.defaultAttributes( newEntity, variables.parent ); - } ); + var newEntity = variables.related.newEntity(); + variables.defaultAttributes( newEntity, variables.parent ); + return newEntity; } return variables.related.newEntity().fill( variables.defaultAttributes ); @@ -261,18 +252,18 @@ component extends="quick.models.Relationships.BaseRelationship" { * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { + for ( var entity in arguments.entities ) { var defaultEntity = newDefaultEntity(); - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( - relation, + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( + arguments.relation, isNull( defaultEntity ) ? javacast( "null", "" ) : defaultEntity ); } else { - arguments.entity[ relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); + entity[ arguments.relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -292,22 +283,24 @@ component extends="quick.models.Relationships.BaseRelationship" { required string relation ) { var dictionary = buildDictionary( arguments.results ); - arguments.entities.each( function( entity ) { - var key = variables.localKeys - .map( function( localKey ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ - localKey - ]; - } ) - .toList(); + for ( var entity in arguments.entities ) { + var keyValues = []; + for ( var foreignKey in variables.closestToParent.getForeignKeys() ) { + keyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( foreignKey ) : entity[ + foreignKey + ] + ); + } + var key = keyValues.toList(); if ( structKeyExists( dictionary, key ) ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, getRelationValue( dictionary, key, "one" ) ); + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( relation, getRelationValue( dictionary, key, "one" ) ); } else { - arguments.entity[ relation ] = getRelationValue( dictionary, key, "one" ); + entity[ relation ] = getRelationValue( dictionary, key, "one" ); } } - } ); + } return arguments.entities; } @@ -339,20 +332,15 @@ component extends="quick.models.Relationships.BaseRelationship" { * @return void */ public void function applyThroughConstraints( required any base ) { - arguments.base.where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( foreignKey ), - variables.parent.retrieveAttribute( localKey ) - ); - } + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.retrieveAttribute( variables.localKeys[ i ] ) ); - } ); + } + query.addNestedWhereQuery( constraints ); } public array function getForeignKeys() { @@ -360,7 +348,7 @@ component extends="quick.models.Relationships.BaseRelationship" { } public array function getLocalKeys() { - return variables.parent.keyNames(); + return variables.closestToParent.getForeignKeys(); } } diff --git a/models/Relationships/Builders/HasManyDeepBuilder.cfc b/models/Relationships/Builders/HasManyDeepBuilder.cfc index e195ab17..f4c8009e 100644 --- a/models/Relationships/Builders/HasManyDeepBuilder.cfc +++ b/models/Relationships/Builders/HasManyDeepBuilder.cfc @@ -25,14 +25,10 @@ component accessors="true" { function callback ) { if ( !isNull( arguments.callback ) ) { - variables.through.append( function() { - var entity = ""; - var parts = entityName.split( "\s(?:[Aa][Ss]\s)?" ); - var entity = variables.wirebox.getInstance( trim( parts[ 1 ] ) ); - if ( arrayLen( parts ) > 1 ) { - entity.withAlias( trim( parts[ 2 ] ) ); - } - return callback( entity ); + variables.through.append( { + "_quickEntityDescriptor" : true, + "entityName" : arguments.entityName, + "callback" : arguments.callback } ); } else { variables.through.append( arguments.entityName ); @@ -80,14 +76,10 @@ component accessors="true" { var related = arguments.relationName; if ( !isNull( arguments.callback ) ) { - related = function() { - var entity = ""; - var parts = relationName.split( "\s(?:[Aa][Ss]\s)?" ); - var entity = variables.wirebox.getInstance( trim( parts[ 1 ] ) ); - if ( arrayLen( parts ) > 1 ) { - entity.withAlias( trim( parts[ 2 ] ) ); - } - return callback( entity ); + related = { + "_quickEntityDescriptor" : true, + "entityName" : arguments.relationName, + "callback" : arguments.callback }; } diff --git a/models/Relationships/HasMany.cfc b/models/Relationships/HasMany.cfc index 63b93bc5..6e98054a 100644 --- a/models/Relationships/HasMany.cfc +++ b/models/Relationships/HasMany.cfc @@ -23,6 +23,11 @@ component extends="quick.models.Relationships.HasOneOrMany" accessors="true" { * @return [quick.models.BaseEntity] */ public array function getResults() { + for ( var localKey in variables.localKeys ) { + if ( variables.parent.isNullAttribute( localKey ) ) { + return []; + } + } return variables.relationshipBuilder.get(); } @@ -36,14 +41,14 @@ component extends="quick.models.Relationships.HasOneOrMany" accessors="true" { * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, [] ); + for ( var entity in arguments.entities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, [] ); } else { - arguments.entity[ relation ] = []; + entity[ arguments.relation ] = []; } - return arguments.entity; - } ); + } + return arguments.entities; } /** diff --git a/models/Relationships/HasManyDeep.cfc b/models/Relationships/HasManyDeep.cfc index c507df74..d5af1909 100644 --- a/models/Relationships/HasManyDeep.cfc +++ b/models/Relationships/HasManyDeep.cfc @@ -145,14 +145,14 @@ component ] ); } - arguments.builder.join( arguments.throughParent.tableName(), function( j ) { - builder.addAliasesFromBuilder( throughParent ); - j.setWheres( throughParent.getWheres() ); - j.addBindings( throughParent.getRawBindings().where, "where" ); - for ( var join in qualifiedJoins ) { - j.on( join[ 1 ], "=", join[ 2 ] ); - } - } ); + var joinClause = newJoinClause( arguments.builder, arguments.throughParent.tableName() ); + arguments.builder.addAliasesFromBuilder( arguments.throughParent ); + joinClause.setWheres( arguments.throughParent.getWheres() ); + joinClause.addBindings( arguments.throughParent.getRawBindings().where, "where" ); + for ( var join in qualifiedJoins ) { + joinClause.on( join[ 1 ], "=", join[ 2 ] ); + } + attachJoinClause( arguments.builder, joinClause ); } public array function throughParentJoins( @@ -182,18 +182,15 @@ component ); // add the joins - arrayZipEach( - [ - arrayWrap( arguments.foreignKey[ i ].foreignKeys ), - arrayWrap( arguments.localKey[ i ] ) - ], - function( foreignKey, localKey ) { - joins.append( [ - throughParent.qualifyColumn( localKey ), - predecessor.qualifyColumn( foreignKey ) - ] ); - } - ); + var polymorphicForeignKeys = arrayWrap( arguments.foreignKey[ i ].foreignKeys ); + var polymorphicLocalKeys = arrayWrap( arguments.localKey[ i ] ); + guardAgainstKeyLengthMismatch( polymorphicForeignKeys, polymorphicLocalKeys ); + for ( var j = 1; j <= polymorphicForeignKeys.len(); j++ ) { + joins.append( [ + arguments.throughParent.qualifyColumn( polymorphicLocalKeys[ j ] ), + arguments.predecessor.qualifyColumn( polymorphicForeignKeys[ j ] ) + ] ); + } } else { joins.append( [ arguments.throughParent.qualifyColumn( arguments.localKey[ i ] ), @@ -206,7 +203,12 @@ component } public any function getResults() { - if ( variables.parent.isNullValue( variables.localKeys[ 1 ] ) ) { + if ( + variables.parent.isNullValue( + variables.localKeys[ 1 ], + variables.parent.retrieveAttribute( variables.localKeys[ 1 ] ) + ) + ) { return variables.related.newCollection(); } else { return variables.relationshipBuilder.get(); @@ -214,19 +216,16 @@ component } public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { - return arguments.base - .select( variables.relationshipBuilder.raw( 1 ) ) - .where( function( q ) { - arrayZipEach( - [ - getExistenceLocalKeys( base ), - getExistenceCompareKeys( base ) - ], - function( qualifiedLocalKey, existenceCompareKey ) { - q.whereColumn( qualifiedLocalKey, existenceCompareKey ); - } - ); - } ); + arguments.base.select( variables.relationshipBuilder.raw( 1 ) ); + var localKeys = getExistenceLocalKeys( arguments.base ); + var compareKeys = getExistenceCompareKeys( arguments.base ); + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.whereColumn( localKeys[ i ], compareKeys[ i ] ); + } + query.addNestedWhereQuery( constraints ); + return arguments.base; } public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { @@ -239,25 +238,25 @@ component var foreignKeys = []; for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { if ( i > variables.throughParents.len() ) { - arrayWrap( variables.foreignKeys[ i ] ).each( function( foreignKey ) { - if ( isStruct( foreignKey ) ) { - foreignKey.foreignKeys.each( function( fk ) { - foreignKeys.append( variables.related.qualifyColumn( fk ) ); - } ); + for ( var relatedForeignKey in arrayWrap( variables.foreignKeys[ i ] ) ) { + if ( isStruct( relatedForeignKey ) ) { + for ( var relatedFk in relatedForeignKey.foreignKeys ) { + foreignKeys.append( variables.related.qualifyColumn( relatedFk ) ); + } } else { - foreignKeys.append( variables.related.qualifyColumn( foreignKey ) ); + foreignKeys.append( variables.related.qualifyColumn( relatedForeignKey ) ); } - } ); + } } else { - arrayWrap( variables.foreignKeys[ i ] ).each( function( foreignKey ) { - if ( isStruct( foreignKey ) ) { - foreignKey.foreignKeys.each( function( fk ) { - foreignKeys.append( variables.throughParents[ i ].qualifyColumn( fk ) ); - } ); + for ( var throughForeignKey in arrayWrap( variables.foreignKeys[ i ] ) ) { + if ( isStruct( throughForeignKey ) ) { + for ( var throughFk in throughForeignKey.foreignKeys ) { + foreignKeys.append( variables.throughParents[ i ].qualifyColumn( throughFk ) ); + } } else { - foreignKeys.append( variables.throughParents[ i ].qualifyColumn( foreignKey ) ); + foreignKeys.append( variables.throughParents[ i ].qualifyColumn( throughForeignKey ) ); } - } ); + } } } return foreignKeys; @@ -273,25 +272,25 @@ component var qualifiedLocalKeys = []; for ( var i = 1; i <= variables.localKeys.len(); i++ ) { if ( i == 1 ) { - arrayWrap( variables.localKeys[ i ] ).each( function( localKey ) { - if ( isStruct( localKey ) ) { - localKey.localKeys.each( function( lk ) { - qualifiedLocalKeys.append( variables.parent.qualifyColumn( lk ) ); - } ); + for ( var parentLocalKey in arrayWrap( variables.localKeys[ i ] ) ) { + if ( isStruct( parentLocalKey ) ) { + for ( var parentLk in parentLocalKey.localKeys ) { + qualifiedLocalKeys.append( variables.parent.qualifyColumn( parentLk ) ); + } } else { - qualifiedLocalKeys.append( variables.parent.qualifyColumn( localKey ) ); + qualifiedLocalKeys.append( variables.parent.qualifyColumn( parentLocalKey ) ); } - } ); + } } else { - arrayWrap( variables.localKeys[ i ] ).each( function( localKey ) { - if ( isStruct( localKey ) ) { - localKey.localKeys.each( function( lk ) { - qualifiedLocalKeys.append( variables.throughParents[ i - 1 ].qualifyColumn( lk ) ); - } ); + for ( var throughLocalKey in arrayWrap( variables.localKeys[ i ] ) ) { + if ( isStruct( throughLocalKey ) ) { + for ( var throughLk in throughLocalKey.localKeys ) { + qualifiedLocalKeys.append( variables.throughParents[ i - 1 ].qualifyColumn( throughLk ) ); + } } else { - qualifiedLocalKeys.append( variables.throughParents[ i - 1 ].qualifyColumn( localKey ) ); + qualifiedLocalKeys.append( variables.throughParents[ i - 1 ].qualifyColumn( throughLocalKey ) ); } - } ); + } } } return qualifiedLocalKeys; @@ -335,14 +334,14 @@ component * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, [] ); + for ( var entity in arguments.entities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, [] ); } else { - arguments.entity[ relation ] = []; + entity[ arguments.relation ] = []; } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -362,14 +361,14 @@ component required string relation ) { var dictionary = buildDictionary( arguments.results ); - arguments.entities.each( function( entity ) { - var key = arrayWrap( variables.localKeys[ 1 ] ) - .map( function( localKey ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ - localKey - ]; - } ) - .toList(); + for ( var entity in arguments.entities ) { + var keyValues = []; + for ( var localKey in arrayWrap( variables.localKeys[ 1 ] ) ) { + keyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ localKey ] + ); + } + var key = keyValues.toList(); if ( structKeyExists( dictionary, key ) ) { if ( structKeyExists( entity, "isQuickEntity" ) ) { entity.assignRelationship( relation, dictionary[ key ] ); @@ -377,7 +376,7 @@ component entity[ relation ] = dictionary[ key ]; } } - } ); + } return arguments.entities; } @@ -390,16 +389,17 @@ component * @return {any: quick.models.BaseEntity} */ public struct function buildDictionary( required array results ) { - return arguments.results.reduce( function( dict, result ) { + var dictionary = {}; + for ( var result in arguments.results ) { var key = structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( "__QuickThroughKey__" ) : result[ "__QuickThroughKey__" ]; - if ( !structKeyExists( arguments.dict, key ) ) { - arguments.dict[ key ] = []; + if ( !structKeyExists( dictionary, key ) ) { + dictionary[ key ] = []; } - arrayAppend( arguments.dict[ key ], arguments.result ); - return arguments.dict; - }, {} ); + arrayAppend( dictionary[ key ], result ); + } + return dictionary; } public struct function appendToDeepRelationship( diff --git a/models/Relationships/HasManyThrough.cfc b/models/Relationships/HasManyThrough.cfc index c1b2990e..648fe405 100644 --- a/models/Relationships/HasManyThrough.cfc +++ b/models/Relationships/HasManyThrough.cfc @@ -37,14 +37,14 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, [] ); + for ( var entity in arguments.entities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, [] ); } else { - arguments.entity[ relation ] = []; + entity[ arguments.relation ] = []; } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -64,15 +64,14 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { required string relation ) { var dictionary = buildDictionary( arguments.results ); - arguments.entities.each( function( entity ) { - var key = variables.closestToParent - .getLocalKeys() - .map( function( localKey ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ - localKey - ]; - } ) - .toList(); + for ( var entity in arguments.entities ) { + var keyValues = []; + for ( var localKey in variables.closestToParent.getLocalKeys() ) { + keyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ localKey ] + ); + } + var key = keyValues.toList(); if ( structKeyExists( dictionary, key ) ) { if ( structKeyExists( entity, "isQuickEntity" ) ) { entity.assignRelationship( relation, dictionary[ key ] ); @@ -80,7 +79,7 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { entity[ relation ] = dictionary[ key ]; } } - } ); + } return arguments.entities; } diff --git a/models/Relationships/HasOne.cfc b/models/Relationships/HasOne.cfc index 951c20cb..c7e3857c 100644 --- a/models/Relationships/HasOne.cfc +++ b/models/Relationships/HasOne.cfc @@ -38,9 +38,9 @@ component extends="quick.models.Relationships.HasOneOrMany" { } if ( isClosure( variables.defaultAttributes ) || isCustomFunction( variables.defaultAttributes ) ) { - return tap( variables.related.newEntity(), function( newEntity ) { - variables.defaultAttributes( newEntity, variables.parent ); - } ); + var newEntity = variables.related.newEntity(); + variables.defaultAttributes( newEntity, variables.parent ); + return newEntity; } return variables.related.newEntity().fill( variables.defaultAttributes ); @@ -56,18 +56,18 @@ component extends="quick.models.Relationships.HasOneOrMany" { * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { + for ( var entity in arguments.entities ) { var defaultEntity = newDefaultEntity(); - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( - relation, + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( + arguments.relation, isNull( defaultEntity ) ? javacast( "null", "" ) : defaultEntity ); } else { - arguments.entity[ relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); + entity[ arguments.relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -97,20 +97,15 @@ component extends="quick.models.Relationships.HasOneOrMany" { * @return void */ public void function applyThroughConstraints( required any base ) { - arguments.base.where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( foreignKey ), - variables.parent.retrieveAttribute( localKey ) - ); - } + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.retrieveAttribute( variables.localKeys[ i ] ) ); - } ); + } + query.addNestedWhereQuery( constraints ); } } diff --git a/models/Relationships/HasOneOrMany.cfc b/models/Relationships/HasOneOrMany.cfc index b077a942..59da9857 100644 --- a/models/Relationships/HasOneOrMany.cfc +++ b/models/Relationships/HasOneOrMany.cfc @@ -64,17 +64,13 @@ component * @return quick.models.Relationships.HasOneOrMany */ public HasOneOrMany function addConstraints() { - variables.relationshipBuilder.where( function( q ) { - arrayZipEach( - [ - getQualifiedForeignKeyNames(), - getParentKeys() - ], - function( keyName, parentKey ) { - q.where( keyName, parentKey ).whereNotNull( keyName ); - } - ); - } ); + var foreignKeyNames = getQualifiedForeignKeyNames(); + var parentKeys = getParentKeys(); + var constraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var i = 1; i <= foreignKeyNames.len(); i++ ) { + constraints.where( foreignKeyNames[ i ], parentKeys[ i ] ).whereNotNull( foreignKeyNames[ i ] ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( constraints ); return this; } @@ -95,18 +91,18 @@ component return false; } - variables.relationshipBuilder.where( function( q ) { - allKeys.each( function( keys ) { - q.orWhere( function( q2 ) { - arrayZipEach( [ variables.foreignKeys, keys ], function( foreignKey, keyValue ) { - q2.where( - variables.related.qualifyColumn( foreignKey ), - variables.relationshipBuilder.generateQueryParamStruct( foreignKey, keyValue ) - ); - } ); - } ); - } ); - } ); + var eagerConstraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + keyConstraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.relationshipBuilder.generateQueryParamStruct( variables.foreignKeys[ i ], keys[ i ] ) + ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( eagerConstraints ); return true; } @@ -171,22 +167,22 @@ component required string type ) { var dictionary = buildDictionary( arguments.results ); - arguments.entities.each( function( entity ) { - var key = variables.localKeys - .map( function( localKey ) { - return structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ - localKey - ]; - } ) - .toList(); + for ( var entity in arguments.entities ) { + var keyValues = []; + for ( var localKey in variables.localKeys ) { + keyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ localKey ] + ); + } + var key = keyValues.toList(); if ( structKeyExists( dictionary, key ) ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, getRelationValue( dictionary, key, type ) ); + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, getRelationValue( dictionary, key, arguments.type ) ); } else { - arguments.entity[ relation ] = getRelationValue( dictionary, key, type ); + entity[ arguments.relation ] = getRelationValue( dictionary, key, arguments.type ); } } - } ); + } return arguments.entities; } @@ -199,22 +195,25 @@ component * @return {any: quick.models.BaseEntity} */ public struct function buildDictionary( required array results ) { - return arguments.results.reduce( function( dict, result ) { - var key = variables.foreignKeys - .map( function( foreignKey ) { - return entityRetrieveAttribute( + var dictionary = {}; + for ( var result in arguments.results ) { + var keyValues = []; + for ( var foreignKey in variables.foreignKeys ) { + keyValues.append( + entityRetrieveAttribute( result, foreignKey, variables.related - ); - } ) - .toList(); - if ( !structKeyExists( arguments.dict, key ) ) { - arguments.dict[ key ] = []; + ) + ); } - arrayAppend( arguments.dict[ key ], arguments.result ); - return arguments.dict; - }, {} ); + var key = keyValues.toList(); + if ( !structKeyExists( dictionary, key ) ) { + dictionary[ key ] = []; + } + arrayAppend( dictionary[ key ], result ); + } + return dictionary; } /** @@ -243,9 +242,11 @@ component * @return any */ public any function getParentKeys() { - return variables.localKeys.map( function( localKey ) { - return variables.parent.retrieveAttribute( localKey ); - } ); + var parentKeys = []; + for ( var localKey in variables.localKeys ) { + parentKeys.append( variables.parent.retrieveAttribute( localKey ) ); + } + return parentKeys; } /** @@ -260,19 +261,19 @@ component * @return [quick.models.BaseEntity] */ public array function applySetter() { - variables.relationshipBuilder.updateAll( - attributes = variables.foreignKeys.reduce( function( acc, foreignKey ) { - acc[ foreignKey ] = { - "value" : "", - "cfsqltype" : "varchar", - "null" : true, - "nulls" : true - }; - return acc; - }, {} ), - force = true - ); - return saveMany( argumentCollection = arguments ); + var nullAttributes = {}; + for ( var foreignKey in variables.foreignKeys ) { + nullAttributes[ foreignKey ] = { + "value" : "", + "cfsqltype" : "varchar", + "null" : true, + "nulls" : true + }; + } + variables.relationshipBuilder.updateAll( attributes = nullAttributes, force = true ); + var savedEntities = saveMany( argumentCollection = arguments ); + variables.parent.assignRelationship( variables.relationMethodName, savedEntities ); + return savedEntities; } /** @@ -284,11 +285,46 @@ component * @return [quick.models.BaseEntity] */ public array function saveMany( required any entities ) { - arguments.entities = isArray( arguments.entities ) ? arguments.entities : [ arguments.entities ]; + arguments.entities = isArray( arguments.entities ) ? arguments.entities : [ arguments.entities ]; + var relationshipWasLoaded = variables.parent.isRelationshipLoaded( variables.relationMethodName ); + var loadedEntities = relationshipWasLoaded ? variables.parent.retrieveRelationship( + variables.relationMethodName + ) : []; + + var savedEntities = []; + for ( var entity in arguments.entities ) { + savedEntities.append( save( entity ) ); + } + if ( relationshipWasLoaded ) { + loadedEntities.append( savedEntities, true ); + variables.parent.assignRelationship( variables.relationMethodName, loadedEntities ); + } + return savedEntities; + } + + /** + * Deletes entities matching the relationship query and synchronizes a loaded parent cache. + * + * @ids An optional array of related entity ids to delete. + * + * @return { "query": QueryBuilder Return Format, "result": struct } + */ + public struct function deleteAll( array ids = [] ) { + var result = variables.relationshipBuilder.deleteAll( arguments.ids ); + + if ( variables.parent.isRelationshipLoaded( variables.relationMethodName ) ) { + if ( arguments.ids.isEmpty() ) { + var loadedValue = variables.parent.retrieveRelationship( variables.relationMethodName ); + variables.parent.assignRelationship( + variables.relationMethodName, + isArray( loadedValue ) ? [] : javacast( "null", "" ) + ); + } else { + variables.parent.clearRelationship( variables.relationMethodName ); + } + } - return arguments.entities.map( function( entity ) { - return save( arguments.entity ); - } ); + return result; } /** @@ -302,12 +338,13 @@ component if ( !isObject( arguments.entity ) ) { arguments.entity = arrayWrap( arguments.entity ); guardAgainstKeyLengthMismatch( arguments.entity, variables.related.keyNames() ); - arguments.entity = tap( variables.related.newEntity(), function( e ) { - e.set_loaded( true ); - arrayZipEach( [ variables.related.keyNames(), entity ], function( keyName, value ) { - e.forceAssignAttribute( keyName, value ); - } ); - } ); + var keyValues = arguments.entity; + arguments.entity = variables.related.newEntity(); + var relatedKeyNames = variables.related.keyNames(); + for ( var i = 1; i <= relatedKeyNames.len(); i++ ) { + arguments.entity.forceAssignAttribute( relatedKeyNames[ i ], keyValues[ i ] ); + } + arguments.entity.assignOriginalAttributes( arguments.entity.retrieveAttributesData() ).set_loaded( true ); } setForeignAttributesForCreate( arguments.entity ); return arguments.entity.save(); @@ -316,12 +353,36 @@ component /** * Creates a new entity, associates it to the parent entity, and returns it. * - * @attributes The attributes for the new related entity. + * @attributes The attributes for the new related entity. + * @inverseRelationship An optional relationship name on the new entity to + * seed with the parent before saving. * * @return quick.models.BaseEntity */ - public any function create( struct attributes = {} ) { - return newEntity().fill( arguments.attributes ).save(); + public any function create( struct attributes = {}, string inverseRelationship ) { + var createdEntity = newEntity().fill( arguments.attributes ); + if ( !isNull( arguments.inverseRelationship ) ) { + if ( !createdEntity.hasRelationship( arguments.inverseRelationship ) ) { + throw( + type = "RelationshipNotFound", + message = "The [#arguments.inverseRelationship#] relationship was not found on the [#createdEntity.entityName()#] entity." + ); + } + createdEntity.assignRelationship( arguments.inverseRelationship, variables.parent ); + } + createdEntity.save(); + + if ( variables.parent.isRelationshipLoaded( variables.relationMethodName ) ) { + var loadedValue = variables.parent.retrieveRelationship( variables.relationMethodName ); + if ( isArray( loadedValue ) ) { + loadedValue.append( createdEntity ); + variables.parent.assignRelationship( variables.relationMethodName, loadedValue ); + } else { + variables.parent.assignRelationship( variables.relationMethodName, createdEntity ); + } + } + + return createdEntity; } /** @@ -332,17 +393,11 @@ component * @return quick.models.BaseEntity */ public any function setForeignAttributesForCreate( required any entity ) { - return tap( arguments.entity, function( e ) { - arrayZipEach( - [ - variables.foreignKeys, - getParentKeys() - ], - function( foreignKey, parentKey ) { - e.forceAssignAttribute( foreignKey, parentKey ); - } - ); - } ); + var parentKeys = getParentKeys(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + arguments.entity.forceAssignAttribute( variables.foreignKeys[ i ], parentKeys[ i ] ); + } + return arguments.entity; } @@ -353,9 +408,11 @@ component * @return [String] */ public array function getQualifiedLocalKeys( any builder = variables.relationshipBuilder ) { - return variables.localKeys.map( function( localKey ) { - return variables.parent.qualifyColumn( localKey ); - } ); + var qualifiedLocalKeys = []; + for ( var localKey in variables.localKeys ) { + qualifiedLocalKeys.append( variables.parent.qualifyColumn( localKey ) ); + } + return qualifiedLocalKeys; } /** @@ -365,9 +422,11 @@ component * @return [String] */ public array function getQualifiedForeignKeyNames( any builder = variables.relationshipBuilder ) { - return variables.foreignKeys.map( function( foreignKey ) { - return builder.qualifyColumn( foreignKey ); - } ); + var qualifiedForeignKeys = []; + for ( var foreignKey in variables.foreignKeys ) { + qualifiedForeignKeys.append( arguments.builder.qualifyColumn( foreignKey ) ); + } + return qualifiedForeignKeys; } /** @@ -379,18 +438,12 @@ component */ public QuickBuilder function applyThroughExists( any base = variables.relationshipBuilder ) { // apply compare constraints - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - base.whereColumn( - variables.related.qualifyColumn( foreignKey ), - variables.parent.qualifyColumn( localKey ) - ); - } - ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + arguments.base.whereColumn( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.qualifyColumn( variables.localKeys[ i ] ) + ); + } // nest in exists return variables.related @@ -407,17 +460,14 @@ component * @return void */ public void function applyThroughJoin( required any base ) { - arguments.base.join( variables.parent.tableName(), function( j ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - j.on( variables.related.qualifyColumn( foreignKey ), variables.parent.qualifyColumn( localKey ) ); - } + var join = newJoinClause( arguments.base, variables.parent.tableName() ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + join.on( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.qualifyColumn( variables.localKeys[ i ] ) ); - } ); + } + attachJoinClause( arguments.base, join ); } /** @@ -426,23 +476,16 @@ component * @return void */ public QuickBuilder function initialThroughConstraints() { - return variables.related - .newQuery() - .reselectRaw( 1 ) - .where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( foreignKey ), - variables.parent.retrieveAttribute( localKey ) - ); - } - ); - } ); + var query = variables.related.newQuery().reselectRaw( 1 ); + var constraints = query.getQB().forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.retrieveAttribute( variables.localKeys[ i ] ) + ); + } + query.getQB().addNestedWhereQuery( constraints ); + return query; } public struct function appendToDeepRelationship( diff --git a/models/Relationships/HasOneOrManyThrough.cfc b/models/Relationships/HasOneOrManyThrough.cfc index ebc7e2e1..1136b423 100644 --- a/models/Relationships/HasOneOrManyThrough.cfc +++ b/models/Relationships/HasOneOrManyThrough.cfc @@ -105,25 +105,18 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" public QuickBuilder function applyThroughExists( required QuickBuilder base ) { var selectedColumns = variables.relationshipBuilder.getColumns(); + var localKeys = variables.closestToParent.getQualifiedLocalKeys(); + var foreignKeys = variables.closestToParent.getForeignKeys(); + var constraints = arguments.base.getQB().forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.whereColumn( localKeys[ i ], variables.closestToParent.qualifyColumn( foreignKeys[ i ] ) ); + } + arguments.base.getQB().addNestedWhereQuery( constraints ); var joiningQuery = variables.closestToParent .getRelated() .newQuery() .reselectRaw( 1 ) - .whereExists( - arguments.base - .where( function( q ) { - arrayZipEach( - [ - variables.closestToParent.getQualifiedLocalKeys(), - variables.closestToParent.getForeignKeys() - ], - function( localKey, foreignKey ) { - q.whereColumn( localKey, variables.closestToParent.qualifyColumn( foreignKey ) ); - } - ); - } ) - .getQB() - ); + .whereExists( arguments.base.getQB() ); return addNestedWhereExists( joiningQuery ).select( selectedColumns ); } @@ -167,39 +160,33 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" var relation = variables.relationshipsMap[ relationshipName ]; relation.applyThroughJoin( variables.relationshipBuilder ); - var foreignKeys = variables.parent.keyNames(); - var qualifiedForeignKeyList = foreignKeys - .reduce( function( acc, foreignKey, i ) { - if ( i != 1 ) { - acc.append( "," ); - } - acc.append( variables.parent.qualifyColumn( foreignKey ) ); - return acc; - }, [] ) - .toList(); - variables.relationshipBuilder - .when( - ( qualifiedForeignKeyList.listLen() > 1 ), - function( q1 ) { - q1.selectRaw( "CONCAT(#qualifiedForeignKeyList#) AS __QuickThroughKey__" ); - }, - function( q1 ) { - q1.addSelect( "#qualifiedForeignKeyList# AS __QuickThroughKey__" ); - } - ) - .appendVirtualAttribute( name = "__QuickThroughKey__", excludeFromMemento = true ) - .where( function( q1 ) { - allKeys.each( function( keys ) { - q1.orWhere( function( q2 ) { - arrayZipEach( [ foreignKeys, keys ], function( foreignKey, keyValue ) { - q2.where( - variables.parent.qualifyColumn( foreignKey ), - variables.parent.generateQueryParamStruct( foreignKey, keyValue ) - ); - } ); - } ); - } ); - } ); + var foreignKeys = variables.parent.keyNames(); + var qualifiedForeignKeys = []; + for ( var i = 1; i <= foreignKeys.len(); i++ ) { + if ( i != 1 ) { + qualifiedForeignKeys.append( "," ); + } + qualifiedForeignKeys.append( variables.parent.qualifyColumn( foreignKeys[ i ] ) ); + } + var qualifiedForeignKeyList = qualifiedForeignKeys.toList(); + if ( qualifiedForeignKeyList.listLen() > 1 ) { + variables.relationshipBuilder.selectRaw( "CONCAT(#qualifiedForeignKeyList#) AS __QuickThroughKey__" ); + } else { + variables.relationshipBuilder.addSelect( "#qualifiedForeignKeyList# AS __QuickThroughKey__" ); + } + variables.relationshipBuilder.appendVirtualAttribute( name = "__QuickThroughKey__", excludeFromMemento = true ); + var eagerConstraints = variables.relationshipBuilder.getQB().forNestedWhere(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= foreignKeys.len(); i++ ) { + keyConstraints.where( + variables.parent.qualifyColumn( foreignKeys[ i ] ), + variables.parent.generateQueryParamStruct( foreignKeys[ i ], keys[ i ] ) + ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + variables.relationshipBuilder.getQB().addNestedWhereQuery( eagerConstraints ); return true; } @@ -213,16 +200,52 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" * @return {any: quick.models.BaseEntity} */ public struct function buildDictionary( required array results ) { - return arguments.results.reduce( function( dict, result ) { + var dictionary = {}; + for ( var result in arguments.results ) { var key = structKeyExists( result, "isQuickEntity" ) ? result.retrieveAttribute( "__QuickThroughKey__" ) : result[ "__QuickThroughKey__" ]; - if ( !structKeyExists( arguments.dict, key ) ) { - arguments.dict[ key ] = []; + if ( !structKeyExists( dictionary, key ) ) { + dictionary[ key ] = []; } - arrayAppend( arguments.dict[ key ], arguments.result ); - return arguments.dict; - }, {} ); + arrayAppend( dictionary[ key ], result ); + } + return dictionary; + } + + /** + * Matches the array of entity results to a single value for the relation. + * + * @entities The entities being eager loaded. + * @results The relationship results. + * @relation The name of the relation being loaded. + * + * @doc_generic quick.models.BaseEntity + * @return [quick.models.BaseEntity] + */ + public array function matchOne( + required array entities, + required array results, + required string relation + ) { + var dictionary = buildDictionary( arguments.results ); + for ( var entity in arguments.entities ) { + var keyValues = []; + for ( var localKey in variables.closestToParent.getLocalKeys() ) { + keyValues.append( + structKeyExists( entity, "isQuickEntity" ) ? entity.retrieveAttribute( localKey ) : entity[ localKey ] + ); + } + var key = keyValues.toList(); + if ( structKeyExists( dictionary, key ) ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( relation, dictionary[ key ][ 1 ] ); + } else { + entity[ relation ] = dictionary[ key ][ 1 ]; + } + } + } + return arguments.entities; } /** @@ -243,23 +266,20 @@ component extends="quick.models.Relationships.BaseRelationship" accessors="true" ); } - return tap( arguments.base.select(), function( q ) { - performJoin( q ); - q.where( function( q2 ) { - arrayZipEach( - [ - variables.parent.keyNames(), - variables.closestToParent.getForeignKeys() - ], - function( localKey, foreignKey ) { - q2.whereColumn( - variables.parent.qualifyColumn( localKey ), - variables.closestToParent.qualifyColumn( foreignKey ) - ); - } - ); - } ); - } ); + var query = arguments.base.select(); + performJoin( query ); + var localKeys = variables.parent.keyNames(); + var foreignKeys = variables.closestToParent.getForeignKeys(); + var qb = queryBuilderFor( query ); + var constraints = qb.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + constraints.whereColumn( + variables.parent.qualifyColumn( localKeys[ i ] ), + variables.closestToParent.qualifyColumn( foreignKeys[ i ] ) + ); + } + qb.addNestedWhereQuery( constraints ); + return query; } /** diff --git a/models/Relationships/HasOneThrough.cfc b/models/Relationships/HasOneThrough.cfc index 4ba9cd4f..b56a7515 100644 --- a/models/Relationships/HasOneThrough.cfc +++ b/models/Relationships/HasOneThrough.cfc @@ -34,9 +34,9 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { } if ( isClosure( variables.defaultAttributes ) || isCustomFunction( variables.defaultAttributes ) ) { - return tap( variables.related.newEntity(), function( newEntity ) { - variables.defaultAttributes( newEntity, variables.parent ); - } ); + var newEntity = variables.related.newEntity(); + variables.defaultAttributes( newEntity, variables.parent ); + return newEntity; } return variables.related.newEntity().fill( variables.defaultAttributes ); @@ -52,18 +52,18 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { + for ( var entity in arguments.entities ) { var defaultEntity = newDefaultEntity(); - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( - relation, + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( + arguments.relation, isNull( defaultEntity ) ? javacast( "null", "" ) : defaultEntity ); } else { - arguments.entity[ relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); + entity[ arguments.relation ] = isNull( defaultEntity ) ? {} : defaultEntity.getMemento(); } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -93,20 +93,15 @@ component extends="quick.models.Relationships.HasOneOrManyThrough" { * @return void */ public void function applyThroughConstraints( required any base ) { - arguments.base.where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( foreignKey ), - variables.parent.retrieveAttribute( localKey ) - ); - } + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.retrieveAttribute( variables.localKeys[ i ] ) ); - } ); + } + query.addNestedWhereQuery( constraints ); } } diff --git a/models/Relationships/Pivot.cfc b/models/Relationships/Pivot.cfc new file mode 100644 index 00000000..75321fd4 --- /dev/null +++ b/models/Relationships/Pivot.cfc @@ -0,0 +1,66 @@ +/** + * Represents one intermediate-table row for a belongs-to-many relationship. + * + * The default Pivot model is read-only because its schema is assembled from the + * relationship at runtime. Extend this component and declare the pivot columns + * as properties to opt in to explicit Quick persistence and custom behavior. + */ +component + extends ="quick.models.BaseEntity" + accessors="true" + readonly ="true" +{ + + property name="_pivotParent" persistent="false"; + property name="_pivotRelated" persistent="false"; + + this.isPivot = true; + + /** + * Configures and hydrates this pivot for a relationship result. + */ + public Pivot function configurePivot( + required string table, + required array keyNames, + required struct attributes, + required any parent, + required any related + ) { + set_table( arguments.table ); + set_key( arguments.keyNames ); + variables._pivotParent = arguments.parent; + variables._pivotRelated = arguments.related; + + for ( var attributeName in arguments.attributes ) { + if ( + !retrieveAttributeNames( withVirtualAttributes = true, withExcludedAttributes = true ).findNoCase( + attributeName + ) && + !retrieveColumnNames( withVirtualAttributes = true ).findNoCase( attributeName ) + ) { + appendVirtualAttribute( attributeName ); + } + } + + this.memento.defaultIncludes = retrieveAttributeNames( withVirtualAttributes = true ); + + return assignAttributesData( arguments.attributes ) + .assignOriginalAttributes( arguments.attributes ) + .markLoaded(); + } + + /** + * Returns the parent entity which loaded this pivot. + */ + public any function getPivotParent() { + return variables._pivotParent; + } + + /** + * Returns the related entity carrying this pivot. + */ + public any function getPivotRelated() { + return variables._pivotRelated; + } + +} diff --git a/models/Relationships/PivotTable.cfc b/models/Relationships/PivotTable.cfc index 33f864b6..67e23e76 100644 --- a/models/Relationships/PivotTable.cfc +++ b/models/Relationships/PivotTable.cfc @@ -39,7 +39,7 @@ component accessors="true" { return columnName; } - return isNull( variables.alias ) ? "#variables.table#.#arguments.columnName#" : "#variables.alias#.#arguments.columnName#"; + return !variables.keyExists( "alias" ) || isNull( variables.alias ) ? "#variables.table#.#arguments.columnName#" : "#variables.alias#.#arguments.columnName#"; } public array function getWheres() { diff --git a/models/Relationships/PolymorphicBelongsTo.cfc b/models/Relationships/PolymorphicBelongsTo.cfc index 57a6a765..0f7f0265 100644 --- a/models/Relationships/PolymorphicBelongsTo.cfc +++ b/models/Relationships/PolymorphicBelongsTo.cfc @@ -94,22 +94,28 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { * @return {string: {any: quick.models.BaseEntity}} */ public struct function buildDictionary( required any baseEntity ) { - variables.dictionary = variables.entities.reduce( function( dict, entity ) { - var type = retrieveMorphType( arguments.entity, baseEntity ); - if ( !structKeyExists( arguments.dict, type ) ) { - arguments.dict[ type ] = {}; + variables.dictionary = {}; + for ( var entity in variables.entities ) { + var type = retrieveMorphType( entity, arguments.baseEntity ); + if ( !structKeyExists( variables.dictionary, type ) ) { + variables.dictionary[ type ] = {}; } - var key = variables.foreignKeys - .map( function( foreignKey ) { - return entityRetrieveAttribute( entity, foreignKey, baseEntity ); - } ) - .toList(); - if ( !structKeyExists( arguments.dict[ type ], key ) ) { - arguments.dict[ type ][ key ] = []; + var keyValues = []; + for ( var foreignKey in variables.foreignKeys ) { + keyValues.append( + entityRetrieveAttribute( + entity, + foreignKey, + arguments.baseEntity + ) + ); + } + var key = keyValues.toList(); + if ( !structKeyExists( variables.dictionary[ type ], key ) ) { + variables.dictionary[ type ][ key ] = []; } - arrayAppend( arguments.dict[ type ][ key ], arguments.entity ); - return arguments.dict; - }, {} ); + arrayAppend( variables.dictionary[ type ][ key ], entity ); + } return variables.dictionary; } @@ -129,19 +135,19 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { * @return [quick.models.BaseEntity] */ public array function getEager( boolean asQuery = false, boolean withAliases = false ) { - structKeyArray( variables.dictionary ).each( function( type ) { - var instance = createModelByType( arguments.type ); + for ( var type in variables.dictionary ) { + var instance = createModelByType( type ); matchToMorphParents( - arguments.type, + type, instance, getResultsByType( - arguments.type, + type, instance, - asQuery, - withAliases + arguments.asQuery, + arguments.withAliases ) ); - } ); + } return variables.entities; } @@ -168,20 +174,20 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { return []; } - return arguments.instance - .when( arguments.asQuery, function( qb ) { - qb.asQuery( withAliases ); - } ) - .where( function( q1 ) { - gatherKeysByType( type ).each( function( keys ) { - q1.orWhere( function( q2 ) { - arrayZipEach( [ localKeys, keys ], function( localKey, keyValue ) { - q2.where( localKey, keyValue ); - } ); - } ); - } ); - } ) - .get(); + var query = arguments.instance; + if ( arguments.asQuery ) { + query = query.asQuery( arguments.withAliases ); + } + var eagerConstraints = query.getQB().forNestedWhere(); + for ( var keys in allKeys ) { + var keyConstraints = eagerConstraints.forNestedWhere(); + for ( var i = 1; i <= localKeys.len(); i++ ) { + keyConstraints.where( localKeys[ i ], keys[ i ] ); + } + eagerConstraints.addNestedWhereQuery( keyConstraints, "or" ); + } + query.getQB().addNestedWhereQuery( eagerConstraints ); + return query.get(); } /** @@ -193,26 +199,21 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { * @return [any] */ public array function gatherKeysByType( required string type ) { - return unique( - structReduce( - variables.dictionary[ arguments.type ], - function( acc, key, values ) { - var entity = arguments.values[ 1 ]; - arrayAppend( - arguments.acc, - variables.foreignKeys - .map( function( foreignKey ) { - return entityRetrieveAttribute( entity, foreignKey, variables.parent ); - } ) - .toList() - ); - return acc; - }, - [] - ) - ).map( function( key ) { - return key.listToArray(); - } ); + var serializedKeys = []; + for ( var key in variables.dictionary[ arguments.type ] ) { + var entity = variables.dictionary[ arguments.type ][ key ][ 1 ]; + var keyValues = []; + for ( var foreignKey in variables.foreignKeys ) { + keyValues.append( entityRetrieveAttribute( entity, foreignKey, variables.parent ) ); + } + serializedKeys.append( keyValues.toList() ); + } + + var keys = []; + for ( var serializedKey in unique( serializedKeys ) ) { + keys.append( serializedKey.listToArray() ); + } + return keys; } /** @@ -240,15 +241,19 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { required array results ) { for ( var result in arguments.results ) { - var localDictionaryKey = variables.localKeys.isEmpty() ? entityRetrieveKeyValues( - type, - result, - morphParent - ).toList() : variables.localKeys - .map( function( localKey ) { - return result.retrieveAttribute( localKey ); - } ) - .toList(); + var localKeyValues = []; + if ( variables.localKeys.isEmpty() ) { + localKeyValues = entityRetrieveKeyValues( + arguments.type, + result, + arguments.morphParent + ); + } else { + for ( var localKey in variables.localKeys ) { + localKeyValues.append( result.retrieveAttribute( localKey ) ); + } + } + var localDictionaryKey = localKeyValues.toList(); if ( variables.dictionary[ arguments.type ].keyExists( localDictionaryKey ) ) { var entities = variables.dictionary[ arguments.type ][ localDictionaryKey ]; @@ -267,18 +272,12 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { public QuickBuilder function initialThroughConstraints() { var base = variables.related.newQuery().reselectRaw( 1 ); - arrayZipEach( - [ - variables.localKeys, - variables.foreignKeys - ], - function( localKey, foreignKey ) { - base.where( - variables.related.qualifyColumn( localKey ), - variables.parent.retrieveAttribute( foreignKey ) - ); - } - ); + for ( var i = 1; i <= variables.localKeys.len(); i++ ) { + base.where( + variables.related.qualifyColumn( variables.localKeys[ i ] ), + variables.parent.retrieveAttribute( variables.foreignKeys[ i ] ) + ); + } return base; } @@ -304,11 +303,17 @@ component extends="quick.models.Relationships.BelongsTo" accessors="true" { return arguments.entity.keyValues(); } - return arguments.morphParent - .keyNames() - .map( function( key ) { - return entityRetrieveAttribute( entity, key, morphParent ); - } ); + var keyValues = []; + for ( var key in arguments.morphParent.keyNames() ) { + keyValues.append( + entityRetrieveAttribute( + arguments.entity, + key, + arguments.morphParent + ) + ); + } + return keyValues; } } diff --git a/models/Relationships/PolymorphicHasMany.cfc b/models/Relationships/PolymorphicHasMany.cfc index 8732a395..85aabf7e 100644 --- a/models/Relationships/PolymorphicHasMany.cfc +++ b/models/Relationships/PolymorphicHasMany.cfc @@ -40,14 +40,14 @@ component extends="quick.models.Relationships.PolymorphicHasOneOrMany" accessors * @return [quick.models.BaseEntity] */ public array function initRelation( required array entities, required string relation ) { - return arguments.entities.map( function( entity ) { - if ( structKeyExists( arguments.entity, "isQuickEntity" ) ) { - arguments.entity.assignRelationship( relation, [] ); + for ( var entity in arguments.entities ) { + if ( structKeyExists( entity, "isQuickEntity" ) ) { + entity.assignRelationship( arguments.relation, [] ); } else { - arguments.entity[ relation ] = []; + entity[ arguments.relation ] = []; } - return arguments.entity; - } ); + } + return arguments.entities; } /** @@ -71,22 +71,16 @@ component extends="quick.models.Relationships.PolymorphicHasOneOrMany" accessors .reselectRaw( 1 ) .where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); - variables.localKeys.each( function( localKey ) { + for ( var localKey in variables.localKeys ) { base.where( variables.parent.qualifyColumn( localKey ), variables.parent.retrieveAttribute( localKey ) ); - } ); + } - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - base.whereColumn( - variables.related.qualifyColumn( foreignKey ), - variables.parent.qualifyColumn( localKey ) - ); - } - ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + base.whereColumn( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.qualifyColumn( variables.localKeys[ i ] ) + ); + } return variables.related .newQuery() @@ -102,22 +96,16 @@ component extends="quick.models.Relationships.PolymorphicHasOneOrMany" accessors * @return void */ public void function applyThroughConstraints( required any base ) { - arguments.base - .where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ) - .where( function( q ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - q.where( - variables.related.qualifyColumn( foreignKey ), - variables.parent.retrieveAttribute( localKey ) - ); - } - ); - } ); + arguments.base.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); + var query = queryBuilderFor( arguments.base ); + var constraints = query.forNestedWhere(); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + constraints.where( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.retrieveAttribute( variables.localKeys[ i ] ) + ); + } + query.addNestedWhereQuery( constraints ); } } diff --git a/models/Relationships/PolymorphicHasOneOrMany.cfc b/models/Relationships/PolymorphicHasOneOrMany.cfc index 7be7a195..68ee3160 100644 --- a/models/Relationships/PolymorphicHasOneOrMany.cfc +++ b/models/Relationships/PolymorphicHasOneOrMany.cfc @@ -102,9 +102,9 @@ component * @return quick.models.BaseEntity | qb.models.Query.QueryBuilder */ public any function addCompareConstraints( any base = variables.relationshipBuilder, any nested ) { - return tap( super.addCompareConstraints( arguments.base ), function( q ) { - q.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); - } ); + var query = super.addCompareConstraints( arguments.base ); + query.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); + return query; } /** @@ -115,18 +115,15 @@ component * @return void */ public void function applyThroughJoin( required any base ) { - arguments.base.join( variables.parent.tableName(), function( j ) { - arrayZipEach( - [ - variables.foreignKeys, - variables.localKeys - ], - function( foreignKey, localKey ) { - j.on( variables.related.qualifyColumn( foreignKey ), variables.parent.qualifyColumn( localKey ) ); - j.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); - } + var join = newJoinClause( arguments.base, variables.parent.tableName() ); + for ( var i = 1; i <= variables.foreignKeys.len(); i++ ) { + join.on( + variables.related.qualifyColumn( variables.foreignKeys[ i ] ), + variables.parent.qualifyColumn( variables.localKeys[ i ] ) ); - } ); + } + join.where( variables.related.qualifyColumn( variables.morphType ), variables.morphMapping ); + attachJoinClause( arguments.base, join ); } public struct function appendToDeepRelationship( diff --git a/resources/testing/Factory.cfc b/resources/testing/Factory.cfc new file mode 100644 index 00000000..ae53e201 --- /dev/null +++ b/resources/testing/Factory.cfc @@ -0,0 +1,99 @@ +/** + * Base class for Laravel-inspired Quick model factories. + * + * Factory support lives under `resources/testing` so applications can omit the + * entire directory from production deployments. Subclasses provide + * `definition()` and may expose named states which return `state( ... )`. + */ +component { + + /** + * Create a factory for a Quick entity provider. + * + * @entityProvider A WireBox provider for the Quick entity mapping. + * @context Optional application-specific values available to definitions and states. + */ + public any function init( required any entityProvider, struct context = {} ) { + variables.entityProvider = arguments.entityProvider; + variables.factoryContext = arguments.context; + variables.afterMakingCallbacks = []; + variables.afterCreatingCallbacks = []; + configure(); + return this; + } + + /** + * Return the default attributes for one entity. + */ + public struct function definition() { + throw( type = "QuickFactory.AbstractMethod", message = "Factory subclasses must implement definition()." ); + } + + /** + * Register factory-wide callbacks in subclasses. + */ + public any function configure() { + return this; + } + + public any function state( required any transformation ) { + return newBuilder().state( arguments.transformation ); + } + + public any function sequence( required array states ) { + return newBuilder().sequence( arguments.states ); + } + + public any function count( required numeric amount ) { + return newBuilder().count( arguments.amount ); + } + + public any function make( struct attributes = {} ) { + return newBuilder().make( arguments.attributes ); + } + + public any function create( struct attributes = {} ) { + return newBuilder().create( arguments.attributes ); + } + + public any function afterMaking( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterMakingCallbacks, arguments.callback ); + return this; + } + + public any function afterCreating( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterCreatingCallbacks, arguments.callback ); + return this; + } + + public struct function getFactoryContext() { + return variables.factoryContext; + } + + public any function newEntity( required struct attributes ) { + return variables.entityProvider.newEntity().fill( arguments.attributes ); + } + + public array function getAfterMakingCallbacks() { + return variables.afterMakingCallbacks; + } + + public array function getAfterCreatingCallbacks() { + return variables.afterCreatingCallbacks; + } + + private any function newBuilder() { + return new quick.resources.testing.FactoryBuilder( this ); + } + + private boolean function isCallable( required any candidate ) { + return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate ); + } + +} diff --git a/resources/testing/FactoryBuilder.cfc b/resources/testing/FactoryBuilder.cfc new file mode 100644 index 00000000..9e9bee02 --- /dev/null +++ b/resources/testing/FactoryBuilder.cfc @@ -0,0 +1,216 @@ +/** + * A one-use fluent builder produced by a Quick factory definition. + */ +component { + + public any function init( required any factory ) { + variables.factory = arguments.factory; + variables.amount = 1; + variables.explicitCount = false; + variables.transformations = []; + variables.afterMakingCallbacks = []; + variables.afterCreatingCallbacks = []; + return this; + } + + public any function count( required numeric amount ) { + if ( arguments.amount < 0 || int( arguments.amount ) != arguments.amount ) { + throw( type = "QuickFactory.InvalidCount", message = "Factory count must be a non-negative integer." ); + } + variables.amount = int( arguments.amount ); + variables.explicitCount = true; + return this; + } + + public any function state( required any transformation ) { + if ( !isStruct( arguments.transformation ) && !isCallable( arguments.transformation ) ) { + throw( type = "QuickFactory.InvalidState", message = "Factory state must be a struct or closure." ); + } + arrayAppend( variables.transformations, arguments.transformation ); + return this; + } + + public any function sequence( required array states ) { + arrayAppend( variables.transformations, new quick.resources.testing.Sequence( arguments.states ) ); + return this; + } + + public any function afterMaking( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterMakingCallbacks, arguments.callback ); + return this; + } + + public any function afterCreating( required any callback ) { + if ( !isCallable( arguments.callback ) ) { + throw( type = "QuickFactory.InvalidCallback", message = "Factory callbacks must be closures or functions." ); + } + arrayAppend( variables.afterCreatingCallbacks, arguments.callback ); + return this; + } + + /** + * Forward named state methods to the factory definition so chains may use + * either `factory.count( 3 ).inactive()` or `factory.inactive().count( 3 )`. + */ + public any function onMissingMethod( required string missingMethodName, required struct missingMethodArguments ) { + if ( !structKeyExists( variables.factory, arguments.missingMethodName ) ) { + throw( + type = "QuickFactory.UnknownMethod", + message = "Unknown factory method [#arguments.missingMethodName#]." + ); + } + var stateBuilder = invoke( + variables.factory, + arguments.missingMethodName, + arguments.missingMethodArguments + ); + if ( !isInstanceOf( stateBuilder, "quick.resources.testing.FactoryBuilder" ) ) { + return stateBuilder; + } + arrayAppend( + variables.transformations, + stateBuilder.getTransformations(), + true + ); + arrayAppend( + variables.afterMakingCallbacks, + stateBuilder.getAfterMakingCallbacks(), + true + ); + arrayAppend( + variables.afterCreatingCallbacks, + stateBuilder.getAfterCreatingCallbacks(), + true + ); + return this; + } + + public array function getTransformations() { + return variables.transformations; + } + + public array function getAfterMakingCallbacks() { + return variables.afterMakingCallbacks; + } + + public array function getAfterCreatingCallbacks() { + return variables.afterCreatingCallbacks; + } + + /** + * Build Quick entities without persisting them. + */ + public any function make( struct attributes = {} ) { + var entities = []; + for ( var index = 1; index <= variables.amount; index++ ) { + var evaluatedAttributes = evaluateAttributes( + attributes = arguments.attributes, + index = index, + count = variables.amount + ); + var entity = variables.factory.newEntity( evaluatedAttributes ); + runCallbacks( + variables.factory.getAfterMakingCallbacks(), + entity, + evaluatedAttributes + ); + runCallbacks( + variables.afterMakingCallbacks, + entity, + evaluatedAttributes + ); + arrayAppend( entities, entity ); + } + return variables.explicitCount ? entities : entities[ 1 ]; + } + + /** + * Build and persist Quick entities through `BaseEntity.save()`. + */ + public any function create( struct attributes = {} ) { + var entities = make( arguments.attributes ); + var collection = variables.explicitCount ? entities : [ entities ]; + for ( var entity in collection ) { + entity.save(); + var persistedAttributes = entity.retrieveAttributesData(); + runCallbacks( + variables.factory.getAfterCreatingCallbacks(), + entity, + persistedAttributes + ); + runCallbacks( + variables.afterCreatingCallbacks, + entity, + persistedAttributes + ); + } + return variables.explicitCount ? collection : collection[ 1 ]; + } + + private struct function evaluateAttributes( + required struct attributes, + required numeric index, + required numeric count + ) { + var definition = variables.factory.definition(); + if ( !isStruct( definition ) ) { + throw( type = "QuickFactory.InvalidDefinition", message = "Factory definitions must return a struct." ); + } + + var values = copyStruct( definition ); + var context = { + index : arguments.index - 1, + count : arguments.count + }; + + for ( var transformation in variables.transformations ) { + var changes = {}; + if ( isInstanceOf( transformation, "quick.resources.testing.Sequence" ) ) { + changes = transformation.next( copyStruct( values ), context ); + } else if ( isStruct( transformation ) ) { + changes = transformation; + } else if ( isCallable( transformation ) ) { + changes = transformation( copyStruct( values ), context ); + } + if ( !isStruct( changes ) ) { + throw( + type = "QuickFactory.InvalidStateResult", + message = "Factory state transformations must return a struct." + ); + } + structAppend( values, changes, true ); + } + + structAppend( values, arguments.attributes, true ); + for ( var key in values ) { + if ( !isNull( values[ key ] ) && isCallable( values[ key ] ) ) { + values[ key ] = values[ key ]( copyStruct( values ), context ); + } + } + return values; + } + + private void function runCallbacks( + required array callbacks, + required any entity, + required struct attributes + ) { + for ( var callback in arguments.callbacks ) { + callback( arguments.entity, arguments.attributes ); + } + } + + private struct function copyStruct( required struct source ) { + var copied = {}; + structAppend( copied, arguments.source, true ); + return copied; + } + + private boolean function isCallable( required any candidate ) { + return isClosure( arguments.candidate ) || isCustomFunction( arguments.candidate ); + } + +} diff --git a/resources/testing/FactoryManager.cfc b/resources/testing/FactoryManager.cfc new file mode 100644 index 00000000..07f64a03 --- /dev/null +++ b/resources/testing/FactoryManager.cfc @@ -0,0 +1,42 @@ +/** + * Lazily discovers application factory definitions by convention. + * + * A request for `User` resolves `.UserFactory` through WireBox and + * supplies the matching Quick entity provider plus the per-test context. + */ +component { + + public any function init( + required any wirebox, + required string factoryPath, + struct context = {} + ) { + variables.wirebox = arguments.wirebox; + variables.factoryPath = arguments.factoryPath; + variables.context = arguments.context; + variables.factories = {}; + return this; + } + + public any function factory( required string name ) { + if ( !reFind( "^[A-Za-z][A-Za-z0-9]*$", arguments.name ) ) { + throw( type = "QuickFactory.InvalidFactoryName", message = "Invalid factory name [#arguments.name#]." ); + } + + if ( !structKeyExists( variables.factories, arguments.name ) ) { + variables.factories[ arguments.name ] = variables.wirebox.getInstance( + name = "#variables.factoryPath#.#arguments.name#Factory", + initArguments = { + entityProvider : variables.wirebox.getInstance( + dsl = "provider:#arguments.name#", + targetObject = this + ), + context : variables.context + } + ); + } + + return variables.factories[ arguments.name ]; + } + +} diff --git a/resources/testing/Sequence.cfc b/resources/testing/Sequence.cfc new file mode 100644 index 00000000..6443d19a --- /dev/null +++ b/resources/testing/Sequence.cfc @@ -0,0 +1,29 @@ +/** + * Cycles factory state values across a counted make/create operation. + */ +component { + + public any function init( required array states ) { + if ( arrayLen( arguments.states ) == 0 ) { + throw( type = "QuickFactory.EmptySequence", message = "Factory sequences require at least one state." ); + } + variables.states = arguments.states; + return this; + } + + public struct function next( required struct attributes, required struct context ) { + var position = ( arguments.context.index mod arrayLen( variables.states ) ) + 1; + var value = variables.states[ position ]; + if ( !isNull( value ) && ( isClosure( value ) || isCustomFunction( value ) ) ) { + value = value( arguments.attributes, arguments.context ); + } + if ( isNull( value ) || !isStruct( value ) ) { + throw( + type = "QuickFactory.InvalidSequenceState", + message = "Each factory sequence value must be a struct or closure returning a struct." + ); + } + return value; + } + +} diff --git a/server-boxlang@be.json b/server-boxlang@be.json index d8519752..0346b9cd 100644 --- a/server-boxlang@be.json +++ b/server-boxlang@be.json @@ -20,6 +20,6 @@ "openBrowser":"false", "env":{}, "scripts":{ - "onServerInitialInstall":"install bx-esapi --noSave" + "onServerInitialInstall":"install bx-esapi,bx-mysql --noSave" } } diff --git a/tests/Application.cfc b/tests/Application.cfc index 1f74ede0..e434a3d9 100644 --- a/tests/Application.cfc +++ b/tests/Application.cfc @@ -1,5 +1,6 @@ component { + this.enableNullSupport = shouldEnableFullNullSupport(); this.name = "ColdBoxTestingSuite" & hash(getCurrentTemplatePath()); this.sessionManagement = true; this.setClientCookies = true; @@ -27,6 +28,12 @@ component { this.datasource = "quick"; + private boolean function shouldEnableFullNullSupport() { + var system = createObject( "java", "java.lang.System" ); + var value = system.getEnv( "FULL_NULL" ); + return isNull( value ) ? false : !!value; + } + function onApplicationStart() { param url.reloadDatabase = true; } diff --git a/tests/performance/BenchmarkHarness.cfc b/tests/performance/BenchmarkHarness.cfc new file mode 100644 index 00000000..481fbf50 --- /dev/null +++ b/tests/performance/BenchmarkHarness.cfc @@ -0,0 +1,272 @@ +component { + + public any function init( + numeric warmupIterations = 5, + numeric samples = 9, + numeric iterations = 25 + ) { + variables.defaultWarmupIterations = arguments.warmupIterations; + variables.defaultSamples = arguments.samples; + variables.defaultIterations = arguments.iterations; + variables.system = createObject( "java", "java.lang.System" ); + variables.runtime = createObject( "java", "java.lang.Runtime" ).getRuntime(); + variables.thread = createObject( "java", "java.lang.Thread" ).currentThread(); + variables.threadId = variables.thread.getId(); + variables.threadBean = createObject( "java", "java.lang.management.ManagementFactory" ).getThreadMXBean(); + variables.cpuTimeSupported = configureCpuTime(); + variables.allocationSupported = configureAllocationTracking(); + variables.blackhole = 0; + return this; + } + + /** + * Measures repeated invocations of a callback after an untimed warmup. + * The callback should return a value so the work remains observable to the JVM. + */ + public struct function measure( + required string name, + required any callback, + string category = "general", + numeric operationsPerIteration = 1, + numeric warmupIterations = variables.defaultWarmupIterations, + numeric samples = variables.defaultSamples, + numeric iterations = variables.defaultIterations, + string description = "" + ) { + guardPositive( "operationsPerIteration", arguments.operationsPerIteration ); + guardPositive( "samples", arguments.samples ); + guardPositive( "iterations", arguments.iterations ); + if ( arguments.warmupIterations < 0 ) { + throw( type = "InvalidBenchmarkConfiguration", message = "warmupIterations must be zero or greater." ); + } + + for ( var warmupIndex = 1; warmupIndex <= arguments.warmupIterations; warmupIndex++ ) { + consume( arguments.callback( warmupIndex ) ); + } + + var wallTimeSamples = []; + var cpuTimeSamples = []; + var allocationSamples = []; + var operationsPerSample = arguments.iterations * arguments.operationsPerIteration; + + for ( var sampleIndex = 1; sampleIndex <= arguments.samples; sampleIndex++ ) { + var allocationStart = variables.allocationSupported ? currentThreadAllocatedBytes() : 0; + var cpuStart = variables.cpuTimeSupported ? variables.threadBean.getCurrentThreadCpuTime() : 0; + var wallStart = variables.system.nanoTime(); + + for ( var iterationIndex = 1; iterationIndex <= arguments.iterations; iterationIndex++ ) { + consume( arguments.callback( iterationIndex ) ); + } + + var wallEnd = variables.system.nanoTime(); + var cpuEnd = variables.cpuTimeSupported ? variables.threadBean.getCurrentThreadCpuTime() : 0; + var allocationEnd = variables.allocationSupported ? currentThreadAllocatedBytes() : 0; + + wallTimeSamples.append( ( wallEnd - wallStart ) / operationsPerSample ); + if ( variables.cpuTimeSupported ) { + cpuTimeSamples.append( ( cpuEnd - cpuStart ) / operationsPerSample ); + } + if ( variables.allocationSupported ) { + allocationSamples.append( ( allocationEnd - allocationStart ) / operationsPerSample ); + } + } + + var wallTime = summarize( wallTimeSamples ); + wallTime.unit = "nanoseconds_per_operation"; + wallTime.opsPerSecond = wallTime.median == 0 ? 0 : 1000000000 / wallTime.median; + + return { + "name" : arguments.name, + "category" : arguments.category, + "description" : arguments.description, + "warmupIterations" : arguments.warmupIterations, + "samples" : arguments.samples, + "iterationsPerSample" : arguments.iterations, + "operationsPerIteration" : arguments.operationsPerIteration, + "operationsPerSample" : operationsPerSample, + "wallTime" : wallTime, + "cpuTime" : { + "supported" : variables.cpuTimeSupported, + "unit" : "nanoseconds_per_operation", + "summary" : variables.cpuTimeSupported ? summarize( cpuTimeSamples ) : {} + }, + "allocation" : { + "supported" : variables.allocationSupported, + "unit" : "bytes_per_operation", + "summary" : variables.allocationSupported ? summarize( allocationSamples ) : {} + } + }; + } + + /** + * Estimates retained heap per live item after full GC. The control trial keeps + * the same number of references to one shared object so most array overhead is + * removed from the result. Treat this as directional, not an object-size API. + */ + public struct function measureRetainedHeap( + required string name, + required any factory, + numeric count = 1000, + numeric samples = 3, + string category = "memory", + string description = "" + ) { + guardPositive( "count", arguments.count ); + guardPositive( "samples", arguments.samples ); + + var warmupItems = []; + for ( var warmupIndex = 1; warmupIndex <= min( arguments.count, 25 ); warmupIndex++ ) { + warmupItems.append( arguments.factory( warmupIndex ) ); + } + warmupItems = []; + forceGc(); + + var retainedSamples = []; + var controlSamples = []; + var sharedSentinel = createObject( "java", "java.lang.Object" ).init(); + + for ( var sampleIndex = 1; sampleIndex <= arguments.samples; sampleIndex++ ) { + forceGc(); + var controlStart = usedHeapBytes(); + var controlItems = []; + for ( var controlIndex = 1; controlIndex <= arguments.count; controlIndex++ ) { + controlItems.append( sharedSentinel ); + } + variables.blackhole = controlItems; + forceGc(); + var controlBytes = usedHeapBytes() - controlStart; + controlSamples.append( controlBytes ); + controlItems = []; + variables.blackhole = 0; + forceGc(); + + var retainedStart = usedHeapBytes(); + var retainedItems = []; + for ( var itemIndex = 1; itemIndex <= arguments.count; itemIndex++ ) { + retainedItems.append( arguments.factory( itemIndex ) ); + } + variables.blackhole = retainedItems; + forceGc(); + var retainedBytes = usedHeapBytes() - retainedStart; + retainedSamples.append( ( retainedBytes - controlBytes ) / arguments.count ); + retainedItems = []; + variables.blackhole = 0; + forceGc(); + } + + return { + "name" : arguments.name, + "category" : arguments.category, + "description" : arguments.description, + "count" : arguments.count, + "samples" : arguments.samples, + "unit" : "approximate_retained_bytes_per_item", + "summary" : summarize( retainedSamples ), + "controlBytes" : summarize( controlSamples ), + "caveat" : "Post-GC heap deltas are noisy and include engine bookkeeping. Use allocation results and profiler evidence for decisions." + }; + } + + public struct function capabilities() { + return { + "threadCpuTime" : variables.cpuTimeSupported, + "threadAllocatedBytes" : variables.allocationSupported, + "retainedHeapEstimate" : true + }; + } + + private boolean function configureCpuTime() { + try { + if ( !variables.threadBean.isCurrentThreadCpuTimeSupported() ) { + return false; + } + if ( !variables.threadBean.isThreadCpuTimeEnabled() ) { + variables.threadBean.setThreadCpuTimeEnabled( true ); + } + return variables.threadBean.isThreadCpuTimeEnabled(); + } catch ( any ignored ) { + return false; + } + } + + private boolean function configureAllocationTracking() { + try { + if ( !variables.threadBean.isThreadAllocatedMemorySupported() ) { + return false; + } + if ( !variables.threadBean.isThreadAllocatedMemoryEnabled() ) { + variables.threadBean.setThreadAllocatedMemoryEnabled( true ); + } + return variables.threadBean.isThreadAllocatedMemoryEnabled(); + } catch ( any ignored ) { + return false; + } + } + + private numeric function currentThreadAllocatedBytes() { + return variables.threadBean.getThreadAllocatedBytes( variables.threadId ); + } + + private void function consume( any value ) { + if ( !isNull( arguments.value ) ) { + variables.blackhole = arguments.value; + } + } + + private void function guardPositive( required string name, required numeric value ) { + if ( arguments.value <= 0 ) { + throw( type = "InvalidBenchmarkConfiguration", message = "#arguments.name# must be greater than zero." ); + } + } + + private struct function summarize( required array values ) { + if ( arguments.values.isEmpty() ) { + return {}; + } + + var sorted = []; + for ( var value in arguments.values ) { + sorted.append( value ); + } + arraySort( sorted, "numeric", "asc" ); + + var total = 0; + for ( var value in sorted ) { + total += value; + } + var mean = total / sorted.len(); + var variance = 0; + for ( var value in sorted ) { + variance += ( value - mean ) * ( value - mean ); + } + variance /= sorted.len(); + + return { + "sampleCount" : sorted.len(), + "min" : sorted[ 1 ], + "median" : percentile( sorted, 0.50 ), + "mean" : mean, + "p95" : percentile( sorted, 0.95 ), + "max" : sorted[ sorted.len() ], + "standardDeviation" : sqr( variance ), + "raw" : sorted + }; + } + + private numeric function percentile( required array sortedValues, required numeric percentile ) { + var index = ceiling( arguments.sortedValues.len() * arguments.percentile ); + return arguments.sortedValues[ max( 1, min( arguments.sortedValues.len(), index ) ) ]; + } + + private numeric function usedHeapBytes() { + return variables.runtime.totalMemory() - variables.runtime.freeMemory(); + } + + private void function forceGc() { + variables.system.gc(); + sleep( 100 ); + variables.system.gc(); + sleep( 100 ); + } + +} diff --git a/tests/performance/Compare.cfc b/tests/performance/Compare.cfc new file mode 100644 index 00000000..d9b622cd --- /dev/null +++ b/tests/performance/Compare.cfc @@ -0,0 +1,88 @@ +component { + + function run( + required string baseline, + required string candidate, + numeric maxWallRegressionPercent = 10, + numeric maxAllocationRegressionPercent = 10, + numeric maxRetainedRegressionPercent = 10, + boolean failOnRegression = true + ) { + var baselinePayload = deserializeJSON( fileRead( arguments.baseline ) ); + var candidatePayload = deserializeJSON( fileRead( arguments.candidate ) ); + var baselineByName = indexBenchmarks( baselinePayload.benchmarks ); + var regressions = []; + + print.line( "Benchmark | wall change | allocation change" ); + for ( var benchmark in candidatePayload.benchmarks ) { + if ( !baselineByName.keyExists( benchmark.name ) ) { + continue; + } + var previous = baselineByName[ benchmark.name ]; + var wallChange = percentChange( previous.wallTime.median, benchmark.wallTime.median ); + var allocationChange = "n/a"; + if ( previous.allocation.supported && benchmark.allocation.supported ) { + allocationChange = percentChange( + previous.allocation.summary.median, + benchmark.allocation.summary.median + ); + } + var allocationOutput = isNumeric( allocationChange ) ? formatPercent( allocationChange ) : allocationChange; + print.line( benchmark.name & " | " & formatPercent( wallChange ) & " | " & allocationOutput ); + if ( wallChange > arguments.maxWallRegressionPercent ) { + regressions.append( benchmark.name & " wall (" & formatPercent( wallChange ) & ")" ); + } + if ( + isNumeric( allocationChange ) && + allocationChange > arguments.maxAllocationRegressionPercent + ) { + regressions.append( benchmark.name & " allocation (" & formatPercent( allocationChange ) & ")" ); + } + } + + var baselineMemoryByName = indexBenchmarks( baselinePayload.memory ); + if ( !candidatePayload.memory.isEmpty() ) { + print.line( "" ); + print.line( "Retained memory | change" ); + } + for ( var memoryBenchmark in candidatePayload.memory ) { + if ( !baselineMemoryByName.keyExists( memoryBenchmark.name ) ) { + continue; + } + var retainedChange = percentChange( + baselineMemoryByName[ memoryBenchmark.name ].summary.median, + memoryBenchmark.summary.median + ); + print.line( memoryBenchmark.name & " | " & formatPercent( retainedChange ) ); + if ( retainedChange > arguments.maxRetainedRegressionPercent ) { + regressions.append( + memoryBenchmark.name & " retained memory (" & formatPercent( retainedChange ) & ")" + ); + } + } + + if ( arguments.failOnRegression && !regressions.isEmpty() ) { + throw( + type = "PerformanceRegression", + message = "Wall-time regression threshold exceeded: " & regressions.toList( ", " ) + ); + } + } + + private struct function indexBenchmarks( required array benchmarks ) { + var indexed = {}; + for ( var benchmark in arguments.benchmarks ) { + indexed[ benchmark.name ] = benchmark; + } + return indexed; + } + + private numeric function percentChange( required numeric baseline, required numeric candidate ) { + return arguments.baseline == 0 ? 0 : ( ( arguments.candidate - arguments.baseline ) / arguments.baseline ) * 100; + } + + private string function formatPercent( required numeric value ) { + return ( arguments.value > 0 ? "+" : "" ) & numberFormat( arguments.value, "0.00" ) & "%"; + } + +} diff --git a/tests/performance/QuickBenchmarkSuite.cfc b/tests/performance/QuickBenchmarkSuite.cfc new file mode 100644 index 00000000..c135ee85 --- /dev/null +++ b/tests/performance/QuickBenchmarkSuite.cfc @@ -0,0 +1,768 @@ +component { + + public any function init( required any wirebox, struct config = {} ) { + variables.wirebox = arguments.wirebox; + variables.config = { + "warmupIterations" : 5, + "samples" : 9, + "iterations" : 25, + "databaseRows" : 1000, + "retainedItems" : 250, + "includeDatabase" : true, + "includeRetained" : true, + "only" : [] + }; + variables.config.append( arguments.config, true ); + if ( isSimpleValue( variables.config.only ) ) { + variables.config.only = listToArray( variables.config.only ); + } + variables.harness = new tests.performance.BenchmarkHarness( + warmupIterations = variables.config.warmupIterations, + samples = variables.config.samples, + iterations = variables.config.iterations + ); + variables.userPrototype = variables.wirebox.getInstance( "User" ); + variables.aPrototype = variables.wirebox.getInstance( "A" ); + variables.userRow = buildUserRow(); + return this; + } + + public struct function run() { + var startedAt = getTickCount(); + var benchmarks = []; + var memory = []; + var errors = []; + + if ( isSelected( "entity.instantiate" ) ) { + benchmarks.append( benchmarkEntityInstantiation() ); + } + if ( isSelected( "entity.instantiate_narrow" ) ) { + benchmarks.append( benchmarkNarrowEntityInstantiation() ); + } + if ( isSelected( "entity.instantiate_narrow_shallow_internal" ) ) { + benchmarks.append( benchmarkInternalShallowEntityInstantiation() ); + } + if ( isSelected( "entity.hydrate" ) ) { + benchmarks.append( benchmarkEntityHydration() ); + } + if ( isSelected( "entity.bind_row_existing" ) ) { + benchmarks.append( benchmarkExistingEntityRowBinding() ); + } + if ( isSelected( "entity.post_load_event" ) ) { + benchmarks.append( benchmarkPostLoadEvent() ); + } + if ( isSelected( "entity.hydrate_batch_10" ) ) { + benchmarks.append( benchmarkBatchHydration( 10 ) ); + } + if ( isSelected( "entity.hydrate_batch_100" ) ) { + benchmarks.append( benchmarkBatchHydration( 100 ) ); + } + if ( isSelected( "entity.hydrate_batch_1000" ) ) { + benchmarks.append( benchmarkBatchHydration( 1000 ) ); + } + if ( isSelected( "attribute.read" ) ) { + benchmarks.append( benchmarkAttributeRead() ); + } + if ( isSelected( "attribute.assign" ) ) { + benchmarks.append( benchmarkAttributeAssignment() ); + } + if ( isSelected( "attribute.runtime_overlay_deep_lookup" ) ) { + benchmarks.append( benchmarkDeepRuntimeOverlayLookup() ); + } + if ( isSelected( "metadata.cache_lookup" ) ) { + benchmarks.append( benchmarkMetadataCacheLookup() ); + } + if ( isSelected( "metadata.registry_lookup" ) ) { + benchmarks.append( benchmarkMetadataRegistryLookup() ); + } + if ( isSelected( "metadata.definition_access" ) ) { + benchmarks.append( benchmarkMetadataDefinitionAccess() ); + } + if ( isSelected( "metadata.qualified_columns_cached" ) ) { + benchmarks.append( benchmarkCachedQualifiedColumns() ); + } + if ( isSelected( "entity.attributes_snapshot" ) ) { + benchmarks.append( benchmarkAttributesSnapshot() ); + } + if ( isSelected( "entity.is_dirty_clean" ) ) { + benchmarks.append( benchmarkDirtyCheck() ); + } + if ( isSelected( "entity.memento" ) ) { + benchmarks.append( benchmarkMementoSerialization() ); + } + if ( isSelected( "builder.instantiate" ) ) { + benchmarks.append( benchmarkBuilderCreation() ); + } + if ( isSelected( "builder.clone" ) ) { + benchmarks.append( benchmarkBuilderClone() ); + } + if ( isSelected( "builder.compose_sql" ) ) { + benchmarks.append( benchmarkBuilderComposition() ); + } + if ( isSelected( "relationship.construct_has_many" ) ) { + benchmarks.append( benchmarkRelationshipConstruction() ); + } + if ( isSelected( "metadata.cold_compile" ) ) { + benchmarks.append( benchmarkColdMetadataCompilation() ); + } + if ( isSelected( "metadata.selective_cold_compile" ) ) { + benchmarks.append( benchmarkSelectiveColdMetadataCompilation() ); + } + if ( isSelected( "metadata.cache_mutation_control" ) ) { + benchmarks.append( benchmarkMetadataCacheMutationControl() ); + } + + if ( + variables.config.includeDatabase && + ( isSelected( "database.raw_rows" ) || isSelected( "database.hydrated_rows" ) ) + ) { + try { + benchmarks.append( runDatabaseBenchmarks(), true ); + } catch ( any e ) { + errors.append( { + "category" : "database", + "type" : e.type, + "message" : e.message, + "detail" : e.detail + } ); + } + } + + if ( variables.config.includeRetained ) { + if ( isSelected( "memory.entity_unloaded" ) ) { + memory.append( measureRetainedEntities() ); + } + if ( isSelected( "memory.entity_hydrated" ) ) { + memory.append( measureRetainedHydratedEntities() ); + } + if ( isSelected( "memory.entity_narrow" ) ) { + memory.append( measureRetainedNarrowEntities() ); + } + if ( isSelected( "memory.entity_narrow_shallow_internal" ) ) { + memory.append( measureRetainedInternalShallowNarrowEntities() ); + } + if ( isSelected( "memory.builder" ) ) { + memory.append( measureRetainedBuilders() ); + } + } + + return { + "schemaVersion" : 1, + "generatedAt" : dateTimeFormat( dateConvert( "local2Utc", now() ), "yyyy-mm-dd'T'HH:nn:ss'Z'" ), + "durationMs" : getTickCount() - startedAt, + "environment" : environmentMetadata(), + "configuration" : variables.config, + "capabilities" : variables.harness.capabilities(), + "benchmarks" : benchmarks, + "memory" : memory, + "comparisons" : buildComparisons( benchmarks ), + "metadataDiagnostics" : buildMetadataDiagnostics(), + "errors" : errors + }; + } + + private struct function buildMetadataDiagnostics() { + var meta = variables.userPrototype.get_meta(); + var diagnostics = { + "topLevelKeys" : structCount( meta ), + "attributes" : structCount( meta.attributes ), + "columns" : structCount( meta.columns ), + "casts" : structCount( meta.casts ), + "functionNames" : arrayLen( meta.functionNames ), + "virtualAttributes" : arrayLen( meta.virtualAttributes ), + "originalMetadataKeys" : structCount( meta.originalMetadata ), + "originalMetadataProperties" : arrayLen( meta.originalMetadata.properties ), + "originalMetadataFunctions" : arrayLen( meta.originalMetadata.functions ), + "localMetadataKeys" : structCount( meta.localMetadata ), + "localMetadataProperties" : arrayLen( meta.localMetadata.properties ), + "localMetadataFunctions" : meta.localMetadata.keyExists( "functions" ) + ? arrayLen( meta.localMetadata.functions ) + : 0, + "serializedCharacters" : -1 + }; + try { + diagnostics.serializedCharacters = len( serializeJSON( meta ) ); + } catch ( any ignored ) { + } + return diagnostics; + } + + private boolean function isSelected( required string name ) { + return arrayLen( variables.config.only ) == 0 || arrayFindNoCase( variables.config.only, arguments.name ) > 0; + } + + private struct function benchmarkEntityInstantiation() { + return variables.harness.measure( + name = "entity.instantiate", + category = "entity", + description = "Create a warmed User entity through newEntity(), including WireBox DI and onDIComplete.", + callback = function( iterationIndex ) { + return variables.userPrototype.newEntity(); + } + ); + } + + private struct function benchmarkNarrowEntityInstantiation() { + return variables.harness.measure( + name = "entity.instantiate_narrow", + category = "entity", + description = "Create a warmed, two-attribute A entity through newEntity(), including WireBox DI and onDIComplete.", + callback = function( iterationIndex ) { + return variables.aPrototype.newEntity(); + } + ); + } + + /** + * Diagnostic boundary only. Quick's shallow flag skips normal post-DI memento + * and lifecycle setup; the returned object is not a substitute for newEntity(). + */ + private struct function benchmarkInternalShallowEntityInstantiation() { + return variables.harness.measure( + name = "entity.instantiate_narrow_shallow_internal", + category = "diagnostic", + description = "Create the same A entity with Quick's internal shallow flag to isolate normal post-DI setup cost.", + callback = function( iterationIndex ) { + return variables.wirebox.getInstance( + name = variables.aPrototype.mappingName(), + initArguments = { + "meta" : variables.aPrototype.get_meta(), + "runtimeAttributeOverlay" : variables.aPrototype.get_runtimeAttributeOverlay(), + "shallow" : true + } + ); + } + ); + } + + private struct function benchmarkEntityHydration() { + return variables.harness.measure( + name = "entity.hydrate", + category = "entity", + description = "Create and hydrate one warmed, wide User entity from an already-materialized row.", + callback = function( iterationIndex ) { + return variables.userPrototype.newEntity().hydrate( variables.userRow ); + } + ); + } + + private struct function benchmarkExistingEntityRowBinding() { + var entity = variables.userPrototype.newEntity(); + return variables.harness.measure( + name = "entity.bind_row_existing", + category = "hydration", + description = "Bind one wide row and record original state on an already-constructed entity without lifecycle events.", + iterations = variables.config.iterations * 10, + callback = function( iterationIndex ) { + return entity.assignAttributesData( variables.userRow ).assignOriginalAttributes( variables.userRow ); + } + ); + } + + private struct function benchmarkPostLoadEvent() { + var entity = variables.userPrototype.newEntity().assignAttributesData( variables.userRow ); + return variables.harness.measure( + name = "entity.post_load_event", + category = "hydration", + description = "Mark an existing entity loaded and fire its postLoad lifecycle event and interception point.", + iterations = variables.config.iterations * 25, + callback = function( iterationIndex ) { + return entity.markLoaded(); + } + ); + } + + private struct function benchmarkBatchHydration( required numeric count ) { + var rows = []; + for ( var rowIndex = 1; rowIndex <= arguments.count; rowIndex++ ) { + rows.append( variables.userRow ); + } + return variables.harness.measure( + name = "entity.hydrate_batch_#arguments.count#", + category = "entity", + description = "Hydrate #arguments.count# wide User entities through hydrateAll().", + operationsPerIteration = arguments.count, + iterations = max( 1, ceiling( variables.config.iterations / max( 1, arguments.count / 20 ) ) ), + callback = function( iterationIndex ) { + return variables.userPrototype.hydrateAll( rows ); + } + ); + } + + private struct function benchmarkAttributeRead() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "attribute.read", + category = "attribute", + description = "Read one mapped attribute through retrieveAttribute().", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return entity.retrieveAttribute( "username" ); + } + ); + } + + private struct function benchmarkAttributeAssignment() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "attribute.assign", + category = "attribute", + description = "Assign one mapped, non-key attribute through assignAttribute().", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return entity.assignAttribute( "username", "quick-performance-#iterationIndex#" ); + } + ); + } + + private struct function benchmarkDeepRuntimeOverlayLookup() { + var entity = variables.userPrototype.newEntity(); + for ( var overlayIndex = 1; overlayIndex <= 10; overlayIndex++ ) { + entity.appendVirtualAttribute( "performanceOverlay#overlayIndex#" ); + } + return variables.harness.measure( + name = "attribute.runtime_overlay_deep_lookup", + category = "attribute", + description = "Resolve the oldest attribute in a ten-node runtime overlay chain.", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return entity.hasAttribute( "performanceOverlay1" ); + } + ); + } + + private struct function benchmarkMetadataCacheLookup() { + var metadataCache = variables.userPrototype.get_cache(); + var cacheKey = "quick-performance:cache-lookup-control"; + metadataCache.set( cacheKey, variables.userPrototype.get_meta() ); + return variables.harness.measure( + name = "metadata.cache_lookup", + category = "metadata", + description = "Read one warmed definition-shaped value from the configured Quick CacheBox provider as a control.", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return metadataCache.get( cacheKey ); + } + ); + } + + private struct function benchmarkMetadataRegistryLookup() { + var registry = variables.userPrototype.getDefinitionRegistry(); + return variables.harness.measure( + name = "metadata.registry_lookup", + category = "metadata", + description = "Read one warmed entity definition from Quick's process-local registry.", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return registry.getDefinition( variables.userPrototype.mappingName() ); + } + ); + } + + private struct function benchmarkMetadataDefinitionAccess() { + return variables.harness.measure( + name = "metadata.definition_access", + category = "metadata", + description = "Read the shared entity metadata definition already bound to a warmed prototype.", + iterations = variables.config.iterations * 100, + callback = function( iterationIndex ) { + return variables.userPrototype.get_meta(); + } + ); + } + + private struct function benchmarkCachedQualifiedColumns() { + variables.userPrototype.retrieveQualifiedColumns(); + return variables.harness.measure( + name = "metadata.qualified_columns_cached", + category = "metadata", + description = "Read cached qualified columns and materialize the defensive result array.", + iterations = variables.config.iterations * 25, + callback = function( iterationIndex ) { + return variables.userPrototype.retrieveQualifiedColumns(); + } + ); + } + + private struct function benchmarkColdMetadataCompilation() { + var metadataCache = variables.userPrototype.get_cache(); + var registry = variables.userPrototype.getDefinitionRegistry(); + return variables.harness.measure( + name = "metadata.cold_compile", + category = "metadata", + description = "Measure the full cold metadata path: clear Quick's cache and construct User through WireBox.", + warmupIterations = 1, + samples = min( 7, variables.config.samples ), + iterations = 1, + callback = function( iterationIndex ) { + metadataCache.clearAll(); + registry.clear(); + return variables.wirebox.getInstance( "User" ); + } + ); + } + + private struct function benchmarkSelectiveColdMetadataCompilation() { + var registry = variables.userPrototype.getDefinitionRegistry(); + return variables.harness.measure( + name = "metadata.selective_cold_compile", + category = "metadata", + description = "Evict only User metadata, then reconstruct User through WireBox while shared metadata remains warm.", + warmupIterations = 1, + samples = min( 7, variables.config.samples ), + iterations = 1, + callback = function( iterationIndex ) { + registry.clearDefinition( variables.userPrototype.mappingName() ); + return variables.wirebox.getInstance( "User" ); + } + ); + } + + private struct function benchmarkMetadataCacheMutationControl() { + var metadataCache = variables.userPrototype.get_cache(); + var cacheKey = "quick-performance:metadata-mutation-control"; + metadataCache.set( cacheKey, true ); + return variables.harness.measure( + name = "metadata.cache_mutation_control", + category = "metadata", + description = "Clear and restore one trivial CacheBox entry to quantify mutation overhead in selective-cold measurements.", + warmupIterations = 1, + samples = min( 7, variables.config.samples ), + iterations = 1, + callback = function( iterationIndex ) { + metadataCache.clear( cacheKey ); + metadataCache.set( cacheKey, true ); + return true; + } + ); + } + + private struct function benchmarkAttributesSnapshot() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "entity.attributes_snapshot", + category = "entity_state", + description = "Synchronize accessors and copy the current wide User attribute state.", + iterations = variables.config.iterations * 10, + callback = function( iterationIndex ) { + return entity.retrieveAttributesData(); + } + ); + } + + private struct function benchmarkDirtyCheck() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "entity.is_dirty_clean", + category = "entity_state", + description = "Check an unchanged wide User entity with the original hash already warmed.", + iterations = variables.config.iterations * 10, + callback = function( iterationIndex ) { + return entity.isDirty(); + } + ); + } + + private struct function benchmarkMementoSerialization() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "entity.memento", + category = "serialization", + description = "Create a default memento for one wide User entity.", + iterations = variables.config.iterations * 5, + callback = function( iterationIndex ) { + return entity.getMemento(); + } + ); + } + + private struct function benchmarkBuilderCreation() { + return variables.harness.measure( + name = "builder.instantiate", + category = "builder", + description = "Create and initialize a QuickBuilder for a warmed User entity.", + callback = function( iterationIndex ) { + return variables.userPrototype.newQuery(); + } + ); + } + + private struct function benchmarkBuilderClone() { + var builder = variables.userPrototype + .newQuery() + .where( "username", "benchmark" ) + .orderBy( "createdDate", "desc" ); + return variables.harness.measure( + name = "builder.clone", + category = "builder", + description = "Clone a configured QuickBuilder, matching the refresh-query copy made during entity fetches.", + callback = function( iterationIndex ) { + return builder.clone(); + } + ); + } + + private struct function benchmarkBuilderComposition() { + return variables.harness.measure( + name = "builder.compose_sql", + category = "builder", + description = "Create a builder, add common predicates and ordering, then compile SQL without executing it.", + callback = function( iterationIndex ) { + return variables.userPrototype + .newQuery() + .where( "username", "benchmark" ) + .whereNotNull( "createdDate" ) + .orderBy( "createdDate", "desc" ) + .limit( 25 ) + .toSQL(); + } + ); + } + + private struct function benchmarkRelationshipConstruction() { + var entity = variables.userPrototype.newEntity().hydrate( variables.userRow ); + return variables.harness.measure( + name = "relationship.construct_has_many", + category = "relationship", + description = "Resolve User.posts(), including related entity, builder, relationship object, and constraints.", + callback = function( iterationIndex ) { + return entity.posts(); + } + ); + } + + private array function runDatabaseBenchmarks() { + var databaseBenchmarks= []; + transaction action ="begin" { + try { + seedDatabaseRows( variables.config.databaseRows ); + if ( isSelected( "database.raw_rows" ) ) { + databaseBenchmarks.append( + variables.harness.measure( + name = "database.raw_rows", + category = "database", + description = "Fetch the seeded A rows as raw structs; fixture setup is outside the timed region.", + operationsPerIteration = variables.config.databaseRows, + warmupIterations = min( 2, variables.config.warmupIterations ), + iterations = max( 1, ceiling( variables.config.iterations / 10 ) ), + callback = function( iterationIndex ) { + return variables.aPrototype + .newQuery() + .asQuery( false ) + .get(); + } + ) + ); + } + if ( isSelected( "database.hydrated_rows" ) ) { + databaseBenchmarks.append( + variables.harness.measure( + name = "database.hydrated_rows", + category = "database", + description = "Fetch and hydrate the same seeded A rows as Quick entities.", + operationsPerIteration = variables.config.databaseRows, + warmupIterations = min( 2, variables.config.warmupIterations ), + iterations = max( 1, ceiling( variables.config.iterations / 10 ) ), + callback = function( iterationIndex ) { + return variables.aPrototype.newQuery().get(); + } + ) + ); + } + } finally { + transaction action="rollback"; + } + } + return databaseBenchmarks; + } + + private void function seedDatabaseRows( required numeric count ) { + queryExecute( "DELETE FROM `a`" ); + var placeholders = []; + var params = []; + for ( var rowIndex = 1; rowIndex <= arguments.count; rowIndex++ ) { + placeholders.append( "(?)" ); + params.append( { + "value" : "Performance row #rowIndex#", + "cfsqltype" : "varchar" + } ); + } + queryExecute( "INSERT INTO `a` (`name`) VALUES #placeholders.toList( "," )#", params ); + } + + private struct function measureRetainedEntities() { + return variables.harness.measureRetainedHeap( + name = "memory.entity_unloaded", + category = "memory", + description = "Approximate retained heap for live, unloaded User entities.", + count = variables.config.retainedItems, + factory = function( itemIndex ) { + return variables.userPrototype.newEntity(); + } + ); + } + + private struct function measureRetainedHydratedEntities() { + return variables.harness.measureRetainedHeap( + name = "memory.entity_hydrated", + category = "memory", + description = "Approximate retained heap for live, hydrated User entities, including a distinct source row.", + count = variables.config.retainedItems, + factory = function( itemIndex ) { + return variables.userPrototype.newEntity().hydrate( structCopy( variables.userRow ) ); + } + ); + } + + private struct function measureRetainedNarrowEntities() { + return variables.harness.measureRetainedHeap( + name = "memory.entity_narrow", + category = "memory", + description = "Approximate retained heap for live, normally initialized A entities.", + count = variables.config.retainedItems, + factory = function( itemIndex ) { + return variables.aPrototype.newEntity(); + } + ); + } + + private struct function measureRetainedInternalShallowNarrowEntities() { + return variables.harness.measureRetainedHeap( + name = "memory.entity_narrow_shallow_internal", + category = "diagnostic", + description = "Approximate retained heap for A entities without normal post-DI memento and lifecycle setup.", + count = variables.config.retainedItems, + factory = function( itemIndex ) { + return variables.wirebox.getInstance( + name = variables.aPrototype.mappingName(), + initArguments = { + "meta" : variables.aPrototype.get_meta(), + "runtimeAttributeOverlay" : variables.aPrototype.get_runtimeAttributeOverlay(), + "shallow" : true + } + ); + } + ); + } + + private struct function measureRetainedBuilders() { + return variables.harness.measureRetainedHeap( + name = "memory.builder", + category = "memory", + description = "Approximate retained heap for live QuickBuilder instances.", + count = variables.config.retainedItems, + factory = function( itemIndex ) { + return variables.userPrototype.newQuery(); + } + ); + } + + private struct function buildUserRow() { + return { + "id" : 1, + "username" : "quick-performance", + "first_name" : "Quick", + "last_name" : "Benchmark", + "password" : "not-a-real-password", + "country_id" : 1, + "team_id" : 1, + "created_date" : createDateTime( 2026, 1, 1, 0, 0, 0 ), + "modified_date" : createDateTime( 2026, 1, 1, 0, 0, 0 ), + "email" : "benchmark@example.invalid", + "type" : "benchmark", + "externalID" : "performance-1", + "favoritePost_id" : 1, + "streetOne" : "100 Benchmark Way", + "streetTwo" : "", + "city" : "Testville", + "state" : "UT", + "zip" : "84000" + }; + } + + private struct function environmentMetadata() { + var engine = { + "name" : "unknown", + "version" : "unknown" + }; + if ( server.keyExists( "lucee" ) ) { + engine = { + "name" : "Lucee", + "version" : server.lucee.version + }; + } else if ( server.keyExists( "boxlang" ) ) { + engine = { + "name" : "BoxLang", + "version" : server.boxlang.version + }; + } else if ( server.keyExists( "coldfusion" ) ) { + engine = { + "name" : "Adobe ColdFusion", + "version" : server.coldfusion.productVersion + }; + } + + return { + "engine" : engine, + "javaVersion" : createObject( "java", "java.lang.System" ).getProperty( "java.version" ), + "javaVendor" : createObject( "java", "java.lang.System" ).getProperty( "java.vendor" ), + "operatingSystem" : createObject( "java", "java.lang.System" ).getProperty( "os.name" ), + "architecture" : createObject( "java", "java.lang.System" ).getProperty( "os.arch" ), + "availableProcessors" : createObject( "java", "java.lang.Runtime" ).getRuntime().availableProcessors() + }; + } + + private struct function buildComparisons( required array benchmarks ) { + var byName = {}; + for ( var benchmark in arguments.benchmarks ) { + byName[ benchmark.name ] = benchmark; + } + + var comparisons = {}; + if ( byName.keyExists( "entity.instantiate" ) && byName.keyExists( "entity.hydrate" ) ) { + comparisons[ "hydrationAddedWallTimePercent" ] = percentDifference( + byName[ "entity.instantiate" ].wallTime.median, + byName[ "entity.hydrate" ].wallTime.median + ); + } + if ( + byName.keyExists( "entity.instantiate_narrow" ) && + byName.keyExists( "entity.instantiate_narrow_shallow_internal" ) + ) { + comparisons[ "normalPostDISetupAddedWallTimePercent" ] = percentDifference( + byName[ "entity.instantiate_narrow_shallow_internal" ].wallTime.median, + byName[ "entity.instantiate_narrow" ].wallTime.median + ); + if ( + byName[ "entity.instantiate_narrow" ].allocation.supported && + byName[ "entity.instantiate_narrow_shallow_internal" ].allocation.supported + ) { + comparisons[ "normalPostDISetupAddedAllocationPercent" ] = percentDifference( + byName[ "entity.instantiate_narrow_shallow_internal" ].allocation.summary.median, + byName[ "entity.instantiate_narrow" ].allocation.summary.median + ); + } + } + if ( byName.keyExists( "database.raw_rows" ) && byName.keyExists( "database.hydrated_rows" ) ) { + comparisons[ "databaseHydrationAddedWallTimePercent" ] = percentDifference( + byName[ "database.raw_rows" ].wallTime.median, + byName[ "database.hydrated_rows" ].wallTime.median + ); + if ( + byName[ "database.raw_rows" ].allocation.supported && + byName[ "database.hydrated_rows" ].allocation.supported + ) { + comparisons[ "databaseHydrationAddedAllocationPercent" ] = percentDifference( + byName[ "database.raw_rows" ].allocation.summary.median, + byName[ "database.hydrated_rows" ].allocation.summary.median + ); + } + } + return comparisons; + } + + private numeric function percentDifference( required numeric baseline, required numeric candidate ) { + return arguments.baseline == 0 ? 0 : ( ( arguments.candidate - arguments.baseline ) / arguments.baseline ) * 100; + } + +} diff --git a/tests/performance/README.md b/tests/performance/README.md new file mode 100644 index 00000000..375b1620 --- /dev/null +++ b/tests/performance/README.md @@ -0,0 +1,94 @@ +# Quick performance benchmarks + +This opt-in suite measures Quick outside the normal TestBox run. It reports +median, p95, and standard deviation for wall-clock time, JVM thread CPU time, +and bytes allocated on the benchmark thread. It also includes directional +post-GC retained-heap probes. + +The suite currently covers warmed entity creation, the internal shallow +construction boundary, single and batch hydration, attribute reads and writes, +attribute snapshots, clean dirty checks, memento serialization, builder +creation and cloning, SQL composition, relationship construction, +raw-versus-hydrated database fetches, warmed metadata access, cached qualified +columns, selective and full-cold metadata compilation, and cache-mutation +controls. Hydration decomposition separately measures row binding and post-load +events, while batch scenarios cover 10, 100, and 1,000 entities. Each result +also records a portable metadata-shape diagnostic for the representative entity. + +## Run a benchmark + +Start one of the normal Quick test servers and initialize the test database if +needed: + +```bash +box server start serverConfigFile=server-lucee@6.json --noSaveSettings +curl -fsS 'http://127.0.0.1:60299/tests/runner.cfm?reloadDatabase=true&bundles=tests.specs.integration.ModuleCanBeActivedSpec&reporter=json' >/dev/null +``` + +Then run the suite: + +```bash +box run-script performance +``` + +The default command writes `tests/results/performance-latest.json`, which is +ignored by Git. Parameters can be overridden through the task directly: + +```bash +box task run taskFile=tests/performance/Run.cfc \ + :samples=15 \ + :iterations=50 \ + :output=tests/results/baseline.json +``` + +For CPU-only work or a database-less engine, use `includeDatabase=false`. For +quick feedback, use `includeRetained=false`; retained-heap probes intentionally +force GC and are slower. + +Select one or more scenarios during optimization loops with a comma-delimited +`only` argument: + +```bash +box task run taskFile=tests/performance/Run.cfc \ + :only=attribute.read,attribute.assign,entity.is_dirty_clean \ + :includeDatabase=false \ + :includeRetained=false +``` + +## Compare a candidate to a baseline + +Use the same engine, JVM, heap settings, database, power mode, and machine for +both files. Run each revision at least twice and compare the second result to +reduce class-loading and JIT noise. + +```bash +box task run taskFile=tests/performance/Compare.cfc \ + :baseline=tests/results/baseline.json \ + :candidate=tests/results/candidate.json \ + :maxWallRegressionPercent=10 \ + :maxAllocationRegressionPercent=10 \ + :maxRetainedRegressionPercent=10 +``` + +The comparison task exits non-zero when a matched benchmark's median wall time, +thread allocation, or retained-heap estimate regresses beyond its threshold. A +10% local threshold is the default because CFML engine, JVM, database, and heap +noise make smaller one-run differences unreliable. Performance PRs should +report multiple-run medians and allocation changes, not a single fastest +result. Retained-heap failures should be confirmed with a profiler before +blocking a change. + +The run task also exits non-zero if a requested benchmark group fails. Its JSON +file is still written first so the captured error remains available for +diagnosis. + +## Measurement boundaries + +- Warmup is never included in samples. +- Each sample times a batch and divides by its logical operation count. +- Database fixture setup occurs outside timed regions and is rolled back. +- Thread allocation counts measure churn, not retained object size. +- Retained heap is a post-GC estimate with a reference-array control. It is + directional and should be confirmed with JFR or another allocation profiler. +- The framework is intentionally not part of normal CI. A dedicated, + pinned-runner performance job can consume the JSON and comparison gate later. diff --git a/tests/performance/Run.cfc b/tests/performance/Run.cfc new file mode 100644 index 00000000..d670eaa2 --- /dev/null +++ b/tests/performance/Run.cfc @@ -0,0 +1,89 @@ +component { + + function run( + string url = "http://127.0.0.1:60299/tests/performance/runner.cfm", + numeric warmup = 5, + numeric samples = 9, + numeric iterations = 25, + numeric databaseRows = 1000, + numeric retainedItems = 250, + boolean includeDatabase = true, + boolean includeRetained = true, + string only = "", + string output = "tests/results/performance-latest.json" + ) { + var targetUrl = arguments.url & "?" & [ + "warmup=#urlEncodedFormat( arguments.warmup )#", + "samples=#urlEncodedFormat( arguments.samples )#", + "iterations=#urlEncodedFormat( arguments.iterations )#", + "databaseRows=#urlEncodedFormat( arguments.databaseRows )#", + "retainedItems=#urlEncodedFormat( arguments.retainedItems )#", + "includeDatabase=#urlEncodedFormat( arguments.includeDatabase )#", + "includeRetained=#urlEncodedFormat( arguments.includeRetained )#", + "only=#urlEncodedFormat( arguments.only )#" + ].toList( "&" ); + + cfhttp( + url = targetUrl, + method = "GET", + timeout = 900, + throwOnError = false, + result = "local.response" + ); + + var statusCode = response.keyExists( "status_code" ) ? val( response.status_code ) : val( response.statusCode ); + if ( statusCode < 200 || statusCode >= 300 ) { + throw( + type = "PerformanceRunnerRequestFailed", + message = "Benchmark endpoint [#targetUrl#] returned HTTP #response.statusCode#.", + detail = response.fileContent + ); + } + + var payload = deserializeJSON( response.fileContent ); + if ( payload.keyExists( "error" ) && payload.error ) { + throw( + type = payload.type, + message = payload.message, + detail = payload.detail + ); + } + + if ( len( arguments.output ) ) { + var outputDirectory = getDirectoryFromPath( arguments.output ); + if ( len( outputDirectory ) && !directoryExists( outputDirectory ) ) { + directoryCreate( outputDirectory, true ); + } + fileWrite( arguments.output, serializeJSON( payload ) ); + } + + if ( !payload.errors.isEmpty() ) { + var benchmarkErrors = []; + for ( var benchmarkError in payload.errors ) { + benchmarkErrors.append( + benchmarkError.category & ": " & benchmarkError.type & " - " & benchmarkError.message + ); + } + throw( + type = "PerformanceBenchmarkErrors", + message = "One or more requested benchmark groups failed: " & benchmarkErrors.toList( "; " ) + ); + } + + print.line( "Quick performance benchmark complete" ); + print.line( "Engine: #payload.environment.engine.name# #payload.environment.engine.version#" ); + print.line( "Duration: #payload.durationMs# ms" ); + for ( var benchmark in payload.benchmarks ) { + var allocation = benchmark.allocation.supported + ? numberFormat( benchmark.allocation.summary.median, "0.00" ) & " B/op" + : "allocation unavailable"; + print.line( + "#benchmark.name#: #numberFormat( benchmark.wallTime.median / 1000, "0.00" )# us/op, #allocation#" + ); + } + if ( len( arguments.output ) ) { + print.line( "JSON: #arguments.output#" ); + } + } + +} diff --git a/tests/performance/runner.cfm b/tests/performance/runner.cfm new file mode 100644 index 00000000..6cab62b1 --- /dev/null +++ b/tests/performance/runner.cfm @@ -0,0 +1,58 @@ + + +param name="url.warmup" default="5"; +param name="url.samples" default="9"; +param name="url.iterations" default="25"; +param name="url.databaseRows" default="1000"; +param name="url.retainedItems" default="250"; +param name="url.includeDatabase" default="true"; +param name="url.includeRetained" default="true"; +param name="url.only" default=""; + +function boundedInteger( + required any value, + required numeric minimum, + required numeric maximum +) { + if ( !isNumeric( arguments.value ) ) { + return arguments.minimum; + } + return max( arguments.minimum, min( arguments.maximum, int( arguments.value ) ) ); +} + +try { + controller = request.coldBoxVirtualApp.getController(); + moduleService = controller.getModuleService(); + if ( !moduleService.isModuleRegistered( "qb" ) ) { + moduleService.registerAndActivateModule( "qb", "root.modules" ); + } + if ( !moduleService.isModuleRegistered( "quick" ) ) { + moduleService.registerAndActivateModule( "quick", "testingModuleRoot" ); + } + + result = new tests.performance.QuickBenchmarkSuite( + wirebox = controller.getWireBox(), + config = { + "warmupIterations" : boundedInteger( url.warmup, 0, 1000 ), + "samples" : boundedInteger( url.samples, 1, 100 ), + "iterations" : boundedInteger( url.iterations, 1, 10000 ), + "databaseRows" : boundedInteger( url.databaseRows, 1, 10000 ), + "retainedItems" : boundedInteger( url.retainedItems, 1, 10000 ), + "includeDatabase" : isBoolean( url.includeDatabase ) && url.includeDatabase, + "includeRetained" : isBoolean( url.includeRetained ) && url.includeRetained, + "only" : url.only + } + ).run(); + responseStatus = 200; +} catch ( any e ) { + responseStatus = 500; + result = { + "error" : true, + "type" : e.type, + "message" : e.message, + "detail" : e.detail + }; +} + + +#serializeJSON( result )# diff --git a/tests/resources/InsertOnlyReturningGrammar.cfc b/tests/resources/InsertOnlyReturningGrammar.cfc new file mode 100644 index 00000000..4e1ca6cf --- /dev/null +++ b/tests/resources/InsertOnlyReturningGrammar.cfc @@ -0,0 +1,7 @@ +component extends="qb.models.Grammars.BaseGrammar" { + + public boolean function supportsReturningRowsOnInsert() { + return true; + } + +} diff --git a/tests/resources/ModuleIntegrationSpec.cfc b/tests/resources/ModuleIntegrationSpec.cfc index 7829aa36..88f5c9ac 100644 --- a/tests/resources/ModuleIntegrationSpec.cfc +++ b/tests/resources/ModuleIntegrationSpec.cfc @@ -5,6 +5,7 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/app" { function beforeAll() { super.beforeAll(); + getController().getModuleService().registerAndActivateModule( "qb", "root.modules" ); getController().getModuleService().registerAndActivateModule( "quick", "testingModuleRoot" ); param url.reloadDatabase = false; @@ -71,4 +72,19 @@ component extends="coldbox.system.testing.BaseTestCase" appMapping="/app" { return arraySlice( createObject( "java", "java.util.HashSet" ).init( arguments.items ).toArray(), 1 ); } + /** + * Formats database timestamps without relying on engine-specific date mask parsing. + */ + public string function formatTestTimestamp( required date timestamp ) { + return arrayToList( [ + year( arguments.timestamp ), + numberFormat( month( arguments.timestamp ), "00" ), + numberFormat( day( arguments.timestamp ), "00" ) + ], "-" ) & " " & arrayToList( [ + numberFormat( hour( arguments.timestamp ), "00" ), + numberFormat( minute( arguments.timestamp ), "00" ), + numberFormat( second( arguments.timestamp ), "00" ) + ], ":" ); + } + } diff --git a/tests/resources/app/models/AliasedComposite.cfc b/tests/resources/app/models/AliasedComposite.cfc new file mode 100644 index 00000000..7d9ec166 --- /dev/null +++ b/tests/resources/app/models/AliasedComposite.cfc @@ -0,0 +1,16 @@ +component table="composites" extends="quick.models.BaseEntity" accessors="true" { + + property name="groupId" column="a"; + property name="memberId" column="b"; + + variables._key = [ "a", "b" ]; + + this.memento = { + "defaultIncludes": [ "groupId", "memberId" ] + }; + + function keyType() { + return variables._wirebox.getInstance( "NullKeyType@quick" ); + } + +} diff --git a/tests/resources/app/models/AliasedUsernameUser.cfc b/tests/resources/app/models/AliasedUsernameUser.cfc new file mode 100644 index 00000000..044b6e14 --- /dev/null +++ b/tests/resources/app/models/AliasedUsernameUser.cfc @@ -0,0 +1,6 @@ +component extends="quick.models.BaseEntity" accessors="true" table="users" { + + property name="id"; + property name="username" column="first_name" sqltype="cf_sql_varchar"; + +} diff --git a/tests/resources/app/models/AnnotatedTimestampUser.cfc b/tests/resources/app/models/AnnotatedTimestampUser.cfc new file mode 100644 index 00000000..02914000 --- /dev/null +++ b/tests/resources/app/models/AnnotatedTimestampUser.cfc @@ -0,0 +1,17 @@ +component + extends ="quick.models.BaseEntity" + accessors ="true" + table ="users" + createdDateAttribute ="createdDate" + modifiedDateAttribute="modifiedDate" +{ + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property name="password"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + +} diff --git a/tests/resources/app/models/AutomaticTimestampUser.cfc b/tests/resources/app/models/AutomaticTimestampUser.cfc new file mode 100644 index 00000000..5bfcbc95 --- /dev/null +++ b/tests/resources/app/models/AutomaticTimestampUser.cfc @@ -0,0 +1,15 @@ +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property name="password"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + +} diff --git a/tests/resources/app/models/ColumnAliasCollision.cfc b/tests/resources/app/models/ColumnAliasCollision.cfc new file mode 100644 index 00000000..e9ba9908 --- /dev/null +++ b/tests/resources/app/models/ColumnAliasCollision.cfc @@ -0,0 +1,13 @@ +component extends="quick.models.BaseEntity" accessors="true" { + + property + name ="activoSN" + column="Activo" + type ="boolean"; + property + name ="activo" + column="Active" + type ="boolean" + setter="false"; + +} diff --git a/tests/resources/app/models/CompatUser.cfc b/tests/resources/app/models/CompatUser.cfc index 14ce1f7c..f16d4580 100644 --- a/tests/resources/app/models/CompatUser.cfc +++ b/tests/resources/app/models/CompatUser.cfc @@ -19,4 +19,8 @@ component extends="quick.models.CBORMCompatEntity" table="users" accessors="true inverse="true" lazy="extra"; + function posts() { + return hasMany( "Post", "user_id" ); + } + } diff --git a/tests/resources/app/models/CustomCastPhoneNumber.cfc b/tests/resources/app/models/CustomCastPhoneNumber.cfc new file mode 100644 index 00000000..46e61601 --- /dev/null +++ b/tests/resources/app/models/CustomCastPhoneNumber.cfc @@ -0,0 +1,10 @@ +component + extends ="quick.models.BaseEntity" + table ="phone_numbers" + accessors="true" +{ + + property name="id"; + property name="confirmed" casts="NullValueCast"; + +} diff --git a/tests/resources/app/models/CustomTimestampUser.cfc b/tests/resources/app/models/CustomTimestampUser.cfc new file mode 100644 index 00000000..7c3866d8 --- /dev/null +++ b/tests/resources/app/models/CustomTimestampUser.cfc @@ -0,0 +1,15 @@ +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ + + property name="id"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + + public array function timestampFields() { + return [ "createdDate" ]; + } + +} diff --git a/tests/resources/app/models/DatabaseGeneratedUser.cfc b/tests/resources/app/models/DatabaseGeneratedUser.cfc new file mode 100644 index 00000000..04abab13 --- /dev/null +++ b/tests/resources/app/models/DatabaseGeneratedUser.cfc @@ -0,0 +1,38 @@ +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property + name ="createdDate" + column ="created_date" + update ="false" + refreshOnSave="true"; + property + name ="type" + insert ="false" + update ="false" + refreshOnSave="true" + casts ="UppercaseCast"; + + variables.automaticTimestamps = false; + + function postLoad( eventData ) { + param request.databaseGeneratedUserPostLoadCount = 0; + request.databaseGeneratedUserPostLoadCount++; + } + + function postInsert( eventData ) { + request.databaseGeneratedUserPostInsertCreatedDate = arguments.eventData.entity.getCreatedDate(); + } + + function postUpdate( eventData ) { + request.databaseGeneratedUserPostUpdateCreatedDate = arguments.eventData.entity.getCreatedDate(); + } + +} diff --git a/tests/resources/app/models/DisabledAutomaticTimestampUser.cfc b/tests/resources/app/models/DisabledAutomaticTimestampUser.cfc new file mode 100644 index 00000000..f62223e0 --- /dev/null +++ b/tests/resources/app/models/DisabledAutomaticTimestampUser.cfc @@ -0,0 +1,17 @@ +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + property name="lastName" column="last_name"; + property name="password"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + + variables.automaticTimestamps = false; + +} diff --git a/tests/resources/app/models/DuplicateUsernamePropertyUser.cfc b/tests/resources/app/models/DuplicateUsernamePropertyUser.cfc new file mode 100644 index 00000000..7bb07458 --- /dev/null +++ b/tests/resources/app/models/DuplicateUsernamePropertyUser.cfc @@ -0,0 +1,14 @@ +component + extends ="quick.models.BaseEntity" + accessors="true" + table ="users" +{ + + property name="id"; + property + name ="username" + column ="first_name" + sqltype="cf_sql_varchar"; + property name="username"; + +} diff --git a/tests/resources/app/models/Link.cfc b/tests/resources/app/models/Link.cfc index ac80be42..e9d8c9dc 100644 --- a/tests/resources/app/models/Link.cfc +++ b/tests/resources/app/models/Link.cfc @@ -1,11 +1,18 @@ component extends="quick.models.BaseEntity" accessors="true" { - property name="wirebox" inject="wirebox" persistent="false"; + property + name ="wirebox" + inject ="wirebox" + persistent="false"; - property name="link_id" column="link_id"; - property name="url" column="link_url"; - property name="createdDate" column="created_date" readonly="true"; + property name="link_id" column="link_id"; + property name="url" column="link_url"; + property + name ="createdDate" + column ="created_date" + readonly="true"; - variables._key = "link_id"; + variables.automaticTimestamps = false; + variables._key = "link_id"; } diff --git a/tests/resources/app/models/NullValueCast.cfc b/tests/resources/app/models/NullValueCast.cfc new file mode 100644 index 00000000..afebdcbe --- /dev/null +++ b/tests/resources/app/models/NullValueCast.cfc @@ -0,0 +1,21 @@ +component singleton { + + public any function get( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) || arguments.entity.isNullValue( arguments.key, arguments.value ) + ? "casted-null" + : arguments.value; + } + + public any function set( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) ? javacast( "null", "" ) : arguments.value; + } + +} diff --git a/tests/resources/app/models/Permission.cfc b/tests/resources/app/models/Permission.cfc index 64dae8cb..47351ede 100644 --- a/tests/resources/app/models/Permission.cfc +++ b/tests/resources/app/models/Permission.cfc @@ -7,4 +7,8 @@ component extends="quick.models.BaseEntity" accessors="true" { return belongsToMany( "Role" ); } + function usersThroughRoles() { + return hasManyThrough( [ "roles", "users" ] ); + } + } diff --git a/tests/resources/app/models/Post.cfc b/tests/resources/app/models/Post.cfc index 9bdfc5ad..badd9f42 100644 --- a/tests/resources/app/models/Post.cfc +++ b/tests/resources/app/models/Post.cfc @@ -10,6 +10,8 @@ component property name="createdDate" column="created_date"; property name="modifiedDate" column="modified_date"; property name="publishedDate" column="published_date"; + property name="lifecycleEventVar" persistent="false" fillable="true"; + property name="internalLifecycleState" persistent="false"; variables._key = "post_pk"; @@ -43,6 +45,69 @@ component ); } + function tagsWithPivot() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ).withPivot( [ "context", "active" ] ); + } + + function tagsAsSubscriptions() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( "context" ) + .as( "subscription" ); + } + + function tagsWithCustomPivot() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .using( "PostTag" ) + .withPivot( [ "context", "active" ] ); + } + + function activeTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( [ "context", "active" ] ) + .wherePivot( "active", true ) + .orderByPivot( "context" ); + } + + function defaultActiveTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ) + .withPivot( [ "context", "active" ] ) + .withPivotValue( "active", true ); + } + + function timestampedTags() { + return belongsToMany( + "Tag", + "my_posts_tags", + "custom_post_pk", + "tag_id" + ).withTimestamps( "created_date", "modified_date" ); + } + function comments() { return polymorphicHasMany( "Comment", "commentable" ); } diff --git a/tests/resources/app/models/PostTag.cfc b/tests/resources/app/models/PostTag.cfc new file mode 100644 index 00000000..9dfc462b --- /dev/null +++ b/tests/resources/app/models/PostTag.cfc @@ -0,0 +1,18 @@ +component + extends ="quick.models.Relationships.Pivot" + accessors="true" + readonly ="false" +{ + + property name="customPostPk" column="custom_post_pk"; + property name="tagId" column="tag_id"; + property name="context"; + property name="active" casts="BooleanCast@quick"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; + + function describe() { + return "#getContext()#:#getTagId()#"; + } + +} diff --git a/tests/resources/app/models/PreSaveCastPhoneNumber.cfc b/tests/resources/app/models/PreSaveCastPhoneNumber.cfc new file mode 100644 index 00000000..93204e25 --- /dev/null +++ b/tests/resources/app/models/PreSaveCastPhoneNumber.cfc @@ -0,0 +1,16 @@ +component + extends ="quick.models.BaseEntity" + table ="phone_numbers" + accessors="true" +{ + + property name="id"; + property name="number" casts="YesNoCast"; + property name="active" casts="BooleanCast@quick"; + property name="confirmed" casts="BooleanCast@quick"; + + function preSave() { + assignAttribute( "number", getActive() ); + } + +} diff --git a/tests/resources/app/models/QualifiedColumnsCacheEntity.cfc b/tests/resources/app/models/QualifiedColumnsCacheEntity.cfc new file mode 100644 index 00000000..aaa013e6 --- /dev/null +++ b/tests/resources/app/models/QualifiedColumnsCacheEntity.cfc @@ -0,0 +1,21 @@ +component + table ="users" + extends ="quick.models.BaseEntity" + accessors="true" +{ + + property name="id"; + property name="username"; + property name="firstName" column="first_name"; + + public string function qualifyColumn( + required string column, + string tableName = this.tableName(), + boolean useParentLookup = true + ) { + param request.qualifiedColumnsCalls = 0; + request.qualifiedColumnsCalls++; + return super.qualifyColumn( argumentCollection = arguments ); + } + +} diff --git a/tests/resources/app/models/RelationshipLoadedUser.cfc b/tests/resources/app/models/RelationshipLoadedUser.cfc new file mode 100644 index 00000000..436e749a --- /dev/null +++ b/tests/resources/app/models/RelationshipLoadedUser.cfc @@ -0,0 +1,17 @@ +component + table ="users" + extends ="quick.models.BaseEntity" + accessors="true" +{ + + property name="id"; + + function posts() { + return hasMany( "Post", "user_id" ); + } + + function postsLoaded( entity ) { + arguments.entity.assignRelationship( "loadedByUser", this ); + } + +} diff --git a/tests/resources/app/models/SoftDeleteUser.cfc b/tests/resources/app/models/SoftDeleteUser.cfc new file mode 100644 index 00000000..37bbbd95 --- /dev/null +++ b/tests/resources/app/models/SoftDeleteUser.cfc @@ -0,0 +1,19 @@ +component + extends ="quick.models.BaseEntity" + accessors ="true" + table ="users" + softDeletes ="true" +{ + + property name="id"; + property name="username"; + property + name ="deletedDate" + column="email" + insert="false"; + + function postUpdate() { + request.softDeleteUserPostUpdateCalled = true; + } + +} diff --git a/tests/resources/app/models/Song.cfc b/tests/resources/app/models/Song.cfc index cdab6483..1211064d 100644 --- a/tests/resources/app/models/Song.cfc +++ b/tests/resources/app/models/Song.cfc @@ -1,77 +1,85 @@ component extends="quick.models.BaseEntity" accessors="true" { - property name="id"; - property name="title" nullValue="REALLY_NULL"; - property name="downloadUrl" column="download_url"; - property name="createdDate" column="created_date"; - property name="modifiedDate" column="modified_date"; + property name="id"; + property name="title" nullValue="REALLY_NULL"; + property name="downloadUrl" column="download_url"; + property name="createdDate" column="created_date"; + property name="modifiedDate" column="modified_date"; - function instanceReady( eventData ) { - request.instanceReadyCalled = eventData; - } + variables._dispatchesEvents = { + "postInsert" : "onSongCreated", + "postSave" : [ "onSongSaved", "onMediaSaved" ] + }; - function preLoad( eventData ) { - request.preLoadCalled = eventData; - } + function instanceReady( eventData ) { + request.instanceReadyCalled = eventData; + } - function postLoad( eventData ) { - request.postLoadCalled = eventData; - } + function preLoad( eventData ) { + request.preLoadCalled = eventData; + } - function preInsert( eventData ) { - request.preInsertCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function postLoad( eventData ) { + request.postLoadCalled = eventData; + } - function postInsert( eventData ) { - request.postInsertCalled = { - "entity": eventData.entity.getMemento(), - "isLoaded": eventData.entity.isLoaded() - }; - } + function postReplicate( eventData ) { + request.postReplicateCalled = eventData; + } - function preUpdate( eventData ) { - param request.preUpdateCalled = []; - arrayAppend( request.preUpdateCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function preInsert( eventData ) { + request.preInsertCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } - function postUpdate( eventData ) { - param request.postUpdateCalled = []; - arrayAppend( request.postUpdateCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function postInsert( eventData ) { + request.postInsertCalled = { + "entity" : eventData.entity.getMemento(), + "isLoaded" : eventData.entity.isLoaded() + }; + } - function preSave( eventData ) { - request.preSaveCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function preUpdate( eventData ) { + param request.preUpdateCalled = []; + arrayAppend( + request.preUpdateCalled, + { + "entity" : eventData.entity.getMemento(), + "originalAttributes" : eventData.originalAttributes, + "newAttributes" : eventData.newAttributes + } + ); + } - function postSave( eventData ) { - request.postSaveCalled = { - "entity": arguments.eventData.entity.getMemento(), - "isLoaded": arguments.eventData.entity.isLoaded() - }; - } + function postUpdate( eventData ) { + param request.postUpdateCalled = []; + arrayAppend( request.postUpdateCalled, { "entity" : eventData.entity.getMemento() } ); + } - function preDelete( eventData ) { - param request.preDeleteCalled = []; - arrayAppend( request.preDeleteCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function preSave( eventData ) { + request.preSaveCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } - function postDelete( eventData ) { - param request.postDeleteCalled = []; - arrayAppend( request.postDeleteCalled, { - "entity": eventData.entity.getMemento() - } ); - } + function postSave( eventData ) { + request.postSaveCalled = { + "entity" : arguments.eventData.entity.getMemento(), + "isLoaded" : arguments.eventData.entity.isLoaded() + }; + } + + function preDelete( eventData ) { + param request.preDeleteCalled = []; + arrayAppend( request.preDeleteCalled, { "entity" : eventData.entity.getMemento() } ); + } + + function postDelete( eventData ) { + param request.postDeleteCalled = []; + arrayAppend( request.postDeleteCalled, { "entity" : eventData.entity.getMemento() } ); + } } diff --git a/tests/resources/app/models/UppercaseCast.cfc b/tests/resources/app/models/UppercaseCast.cfc new file mode 100644 index 00000000..485b0559 --- /dev/null +++ b/tests/resources/app/models/UppercaseCast.cfc @@ -0,0 +1,19 @@ +component singleton { + + public any function get( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) ? javacast( "null", "" ) : uCase( arguments.value ); + } + + public any function set( + required any entity, + required string key, + any value + ) { + return isNull( arguments.value ) ? javacast( "null", "" ) : lCase( arguments.value ); + } + +} diff --git a/tests/resources/app/models/User.cfc b/tests/resources/app/models/User.cfc index 2c70b699..85507eca 100644 --- a/tests/resources/app/models/User.cfc +++ b/tests/resources/app/models/User.cfc @@ -17,6 +17,7 @@ component extends="quick.models.BaseEntity" accessors="true" { property name="type"; property name="externalID"; property name="favoritePost_id"; + property name="cacheMetadata" persistent="false"; property name ="address" @@ -70,6 +71,10 @@ component extends="quick.models.BaseEntity" accessors="true" { return qb.updateAll( { "password" : "" } ).result.recordcount; } + function incorrectlyNamedScope( qb ) { + return qb.where( "type", "admin" ); + } + function scopeWithLatestPostId( qb ) { qb.addSubselect( "latestPostId", @@ -93,6 +98,10 @@ component extends="quick.models.BaseEntity" accessors="true" { */ } + function scopeWithFullName( qb ) { + qb.appendVirtualAttribute( "fullName" ).selectRaw( "CONCAT(first_name, ' ', last_name) AS fullName" ); + } + function scopeWithLatestPostIdRelationship( qb ) { qb.addSubselect( "latestPostId", @@ -171,6 +180,10 @@ component extends="quick.models.BaseEntity" accessors="true" { return hasManyThrough( [ "roles", "permissions" ] ); } + function commentsThroughPosts() { + return hasManyThrough( [ "posts", "comments" ] ); + } + function permissionsDeep() { return hasManyDeep( relationName = "Permission", @@ -204,6 +217,14 @@ component extends="quick.models.BaseEntity" accessors="true" { return hasOne( "Post", "user_id" ).latest(); } + function favoritePostsComposite() { + return hasMany( + "Post", + [ "user_id", "post_pk" ], + [ "id", "favoritePost_id" ] + ); + } + function scopeWithLatestPost( qb ) { qb.addSubselect( "latestPostId", "posts.post_pk" ).with( "dynamicLatestPost" ); } @@ -216,6 +237,10 @@ component extends="quick.models.BaseEntity" accessors="true" { return hasOne( "Post", "post_pk", "favoritePost_id" ); } + function favoritePostAuthor() { + return hasOneThrough( [ "favoritePost", "author" ] ); + } + function latestPostWithEmptyDefault() { return hasOne( "Post", "user_id" ).latest().withDefault(); } diff --git a/tests/resources/app/models/UserFill.cfc b/tests/resources/app/models/UserFill.cfc index 573275e9..95cdb5fd 100644 --- a/tests/resources/app/models/UserFill.cfc +++ b/tests/resources/app/models/UserFill.cfc @@ -9,7 +9,7 @@ component extends="quick.models.BaseEntity" accessors="true" { property name="lastName"; property name="aboutMe"; property name="createdDate" readonly="true"; - property name="updatedDate"; + property name="updatedDate" type="date"; property name="lastLogin"; property name="avatarID"; property name="headerID"; diff --git a/tests/resources/app/models/UserWithGlobalScope.cfc b/tests/resources/app/models/UserWithGlobalScope.cfc index e0c5aae9..9c222173 100644 --- a/tests/resources/app/models/UserWithGlobalScope.cfc +++ b/tests/resources/app/models/UserWithGlobalScope.cfc @@ -8,8 +8,18 @@ component extends="User" table="users" accessors="true" { qb.addSubselect( "teamName", "team.name" ); } + function scopeWithBoundCountryName( qb ) { + qb.addSubselect( "boundCountryName", function( q ) { + q.select( "name" ) + .from( "countries" ) + .whereColumn( "countries.id", "users.country_id" ) + .where( "countries.id", "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + } ); + } + function applyGlobalScopes( qb ) { qb.withCountryName(); - qb.withTeamName(); + qb.withTeamName(); + qb.withBoundCountryName(); } } diff --git a/tests/resources/app/models/YesNoCast.cfc b/tests/resources/app/models/YesNoCast.cfc new file mode 100644 index 00000000..a995d031 --- /dev/null +++ b/tests/resources/app/models/YesNoCast.cfc @@ -0,0 +1,19 @@ +component singleton { + + public any function get( + required any entity, + required string key, + any value + ) { + return arguments.value == "Y"; + } + + public any function set( + required any entity, + required string key, + any value + ) { + return arguments.value ? "Y" : "N"; + } + +} diff --git a/tests/resources/app/models/issue262/HasManyDeepKeyTest_A.cfc b/tests/resources/app/models/issue262/HasManyDeepKeyTest_A.cfc index 6c037cd9..6ee5d428 100644 --- a/tests/resources/app/models/issue262/HasManyDeepKeyTest_A.cfc +++ b/tests/resources/app/models/issue262/HasManyDeepKeyTest_A.cfc @@ -20,4 +20,8 @@ component return hasManyThrough( [ "Bs", "Cs" ] ) } + function scopeWithCsCount( qb ) { + qb.withCount( "Cs as countOfCs" ); + } + } diff --git a/tests/resources/database/migrations/2020_08_11_102347_create_countries_table.cfc b/tests/resources/database/migrations/2020_08_11_102347_create_countries_table.cfc index 82ccde2a..d94a6fac 100755 --- a/tests/resources/database/migrations/2020_08_11_102347_create_countries_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102347_create_countries_table.cfc @@ -12,14 +12,14 @@ component { { "id": "02B84D66-0AA0-F7FB-1F71AFC954843861", "name": "United States", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" }, { "id": "02BA2DB0-EB1E-3F85-5F283AB5E45608C6", "name": "Argentina", - "created_date": createDateTime( 2017, 07, 29, 03, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 29, 03, 07, 00 ) + "created_date": "2017-07-29 03:07:00", + "modified_date": "2017-07-29 03:07:00" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102531_create_users_table.cfc b/tests/resources/database/migrations/2020_08_11_102531_create_users_table.cfc index 36805f9e..fa5db99d 100755 --- a/tests/resources/database/migrations/2020_08_11_102531_create_users_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102531_create_users_table.cfc @@ -31,8 +31,8 @@ component { "password": "5F4DCC3B5AA765D61D8327DEB882CF99", "country_id": "02B84D66-0AA0-F7FB-1F71AFC954843861", "team_id": 1, - "created_date": createDateTime( 2017, 07, 28, 02, 06, 36 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 06, 36 ), + "created_date": "2017-07-28 02:06:36", + "modified_date": "2017-07-28 02:06:36", "type": "admin", "externalId": "1234", "streetOne": "123 Elm Street", @@ -50,8 +50,8 @@ component { "password": "5F4DCC3B5AA765D61D8327DEB882CF99", "country_id": "02B84D66-0AA0-F7FB-1F71AFC954843861", "team_id": 1, - "created_date": createDateTime( 2017, 07, 28, 02, 07, 16 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 16 ), + "created_date": "2017-07-28 02:07:16", + "modified_date": "2017-07-28 02:07:16", "type": "limited", "externalId": "6789", "streetOne": "123 Elm Street", @@ -69,8 +69,8 @@ component { "password": "5F4DCC3B5AA765D61D8327DEB882CF99", "country_id": { "value": "", "null": true }, "team_id": 1, - "created_date": createDateTime( 2017, 07, 28, 02, 08, 16 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 08, 16 ), + "created_date": "2017-07-28 02:08:16", + "modified_date": "2017-07-28 02:08:16", "type": "limited", "externalId": "5555", "streetOne": "123 Elm Street", @@ -88,8 +88,8 @@ component { "password": "5F4DCC3B5AA765D61D8327DEB882CF99", "country_id": "02BA2DB0-EB1E-3F85-5F283AB5E45608C6", "team_id": 2, - "created_date": createDateTime( 2019, 06, 15, 12, 29, 36 ), - "modified_date": createDateTime( 2019, 06, 15, 12, 29, 36 ), + "created_date": "2019-06-15 12:29:36", + "modified_date": "2019-06-15 12:29:36", "type": "admin", "externalId": "1234", "streetOne": "123 Elm Street", @@ -107,8 +107,8 @@ component { "password": "5F4DCC3B5AA765D61D8327DEB882CF99", "country_id": "02BA2DB0-EB1E-3F85-5F283AB5E45608C6", "team_id": 3, - "created_date": createDateTime( 2020, 01, 14, 12, 29, 36 ), - "modified_date": createDateTime( 2020, 06, 22, 12, 29, 36 ), + "created_date": "2020-01-14 12:29:36", + "modified_date": "2020-06-22 12:29:36", "type": "limited", "externalId": { "value": "", "null": true }, "streetOne": "1725 Slough Avenue", diff --git a/tests/resources/database/migrations/2020_08_11_102557_create_my_posts_table.cfc b/tests/resources/database/migrations/2020_08_11_102557_create_my_posts_table.cfc index eb613ae5..8948c721 100755 --- a/tests/resources/database/migrations/2020_08_11_102557_create_my_posts_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102557_create_my_posts_table.cfc @@ -15,33 +15,33 @@ component { "post_pk": 1245, "user_id": 1, "body": "My awesome post body", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "published_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00", + "published_date": "2017-07-28 02:07:00" }, { "post_pk": 523526, "user_id": 1, "body": "My second awesome post body", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 36 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 36 ), + "created_date": "2017-07-28 02:07:36", + "modified_date": "2017-07-28 02:07:36", "published_date": { "value": "", "null": true } }, { "post_pk": 7777, "user_id": { "value": "", "null": true }, "body": "My post with no author", - "created_date": createDateTime( 2017, 07, 30, 07, 00, 22 ), - "modified_date": createDateTime( 2017, 07, 30, 07, 00, 22 ), + "created_date": "2017-07-30 07:00:22", + "modified_date": "2017-07-30 07:00:22", "published_date": { "value": "", "null": true } }, { "post_pk": 321, "user_id": 4, "body": "My post with a different author", - "created_date": createDateTime( 2017, 08, 28, 14, 22, 22 ), - "modified_date": createDateTime( 2017, 08, 28, 14, 22, 22 ), - "published_date": createDateTime( 2017, 08, 28, 14, 22, 22 ) + "created_date": "2017-08-28 14:22:22", + "modified_date": "2017-08-28 14:22:22", + "published_date": "2017-08-28 14:22:22" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102605_create_videos_table.cfc b/tests/resources/database/migrations/2020_08_11_102605_create_videos_table.cfc index 0aac2d38..7549eb85 100755 --- a/tests/resources/database/migrations/2020_08_11_102605_create_videos_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102605_create_videos_table.cfc @@ -16,16 +16,16 @@ component { "url": "https://www.youtube.com/watch?v=JDzIypmP0eo", "title": "Building KiteTail with Adam Wathan", "description": "Awesome live coding experience", - "created_date": createDateTime( 2017, 06, 28, 02, 07, 36 ), - "modified_date": createDateTime( 2017, 06, 30, 12, 17, 24 ) + "created_date": "2017-06-28 02:07:36", + "modified_date": "2017-06-30 12:17:24" }, { "id": 1245, "url": "https://www.youtube.com/watch?v=BgAlQuqzl8o", "title": "Cello Wars", "description": "Star Wars Cello Parody", - "created_date": createDateTime( 2017, 07, 02, 04, 14, 22 ), - "modified_date": createDateTime( 2017, 07, 02, 04, 14, 22 ) + "created_date": "2017-07-02 04:14:22", + "modified_date": "2017-07-02 04:14:22" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102612_create_comments_table.cfc b/tests/resources/database/migrations/2020_08_11_102612_create_comments_table.cfc index 563cb72e..92e5fc4b 100755 --- a/tests/resources/database/migrations/2020_08_11_102612_create_comments_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102612_create_comments_table.cfc @@ -20,8 +20,8 @@ component { "commentable_type": "Post", "designation": "public", "user_id": 1, - "created_date": createDateTime( 2017, 07, 02, 04, 14, 22 ), - "modified_date": createDateTime( 2017, 07, 02, 04, 14, 22 ) + "created_date": "2017-07-02 04:14:22", + "modified_date": "2017-07-02 04:14:22" }, { "id": 2, @@ -30,8 +30,8 @@ component { "commentable_type": "Post", "designation": "public", "user_id": 2, - "created_date": createDateTime( 2017, 07, 04, 04, 14, 22 ), - "modified_date": createDateTime( 2017, 07, 04, 04, 14, 22 ) + "created_date": "2017-07-04 04:14:22", + "modified_date": "2017-07-04 04:14:22" }, { "id": 3, @@ -40,8 +40,8 @@ component { "commentable_type": "Video", "designation": "public", "user_id": 1, - "created_date": createDateTime( 2017, 07, 02, 04, 14, 22 ), - "modified_date": createDateTime( 2017, 07, 02, 04, 14, 22 ) + "created_date": "2017-07-02 04:14:22", + "modified_date": "2017-07-02 04:14:22" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102613_create_internal_comments_table.cfc b/tests/resources/database/migrations/2020_08_11_102613_create_internal_comments_table.cfc index 52b8614f..3acf31dc 100644 --- a/tests/resources/database/migrations/2020_08_11_102613_create_internal_comments_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102613_create_internal_comments_table.cfc @@ -19,8 +19,8 @@ component { "commentable_type": "Post", "designation": "internal", "user_id": 1, - "created_date": createDateTime( 2017, 07, 02, 04, 14, 22 ), - "modified_date": createDateTime( 2017, 07, 02, 04, 14, 22 ) + "created_date": "2017-07-02 04:14:22", + "modified_date": "2017-07-02 04:14:22" } ] ); diff --git a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc index ec8fca79..60694416 100755 --- a/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102625_create_my_posts_tags_table.cfc @@ -4,6 +4,10 @@ component { schema.create( "my_posts_tags", function( t ) { t.unsignedInteger( "custom_post_pk" ); t.unsignedInteger( "tag_id" ); + t.string( "context" ).nullable(); + t.boolean( "active" ).default( false ); + t.timestamp( "created_date" ).nullable(); + t.timestamp( "modified_date" ).nullable(); t.primaryKey( [ "custom_post_pk", "tag_id" ] ); } ); @@ -11,25 +15,40 @@ component { .insert( [ { "custom_post_pk" : 1245, - "tag_id" : 1 + "tag_id" : 1, + "context" : "primary", + "active" : true }, { "custom_post_pk" : 1245, - "tag_id" : 2 + "tag_id" : 2, + "context" : "secondary", + "active" : false }, { "custom_post_pk" : 523526, - "tag_id" : 1 + "tag_id" : 1, + "context" : "archived", + "active" : false }, { "custom_post_pk" : 523526, - "tag_id" : 2 + "tag_id" : 2, + "context" : "review", + "active" : true }, { "custom_post_pk" : 523526, - "tag_id" : 3 + "tag_id" : 3, + "context" : "published", + "active" : true }, - { "custom_post_pk" : 321, "tag_id" : 2 } + { + "custom_post_pk" : 321, + "tag_id" : 2, + "context" : "legacy", + "active" : false + } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102636_create_links_table.cfc b/tests/resources/database/migrations/2020_08_11_102636_create_links_table.cfc index bfa5cd84..a7d8a2cd 100755 --- a/tests/resources/database/migrations/2020_08_11_102636_create_links_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102636_create_links_table.cfc @@ -12,7 +12,7 @@ component { { "link_id": 1, "link_url": "http://example.com/some-link", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102640_create_referrals_table.cfc b/tests/resources/database/migrations/2020_08_11_102640_create_referrals_table.cfc index ce9ec89a..45dd65fb 100755 --- a/tests/resources/database/migrations/2020_08_11_102640_create_referrals_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102640_create_referrals_table.cfc @@ -12,8 +12,8 @@ component { { "id": 1, "type": "external", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102649_create_songs_table.cfc b/tests/resources/database/migrations/2020_08_11_102649_create_songs_table.cfc index 244f0ec3..6a0b766b 100755 --- a/tests/resources/database/migrations/2020_08_11_102649_create_songs_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102649_create_songs_table.cfc @@ -19,15 +19,15 @@ component { "id": 1, "title": "Ode to Joy", "download_url": "https://open.spotify.com/track/4Nd5HJn4EExnLmHtClk4QV", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" }, { "id": 2, "title": "Open Arms", "download_url": "https://open.spotify.com/track/1m2INxep6LfNa25OEg5jZl", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" } ] ); } diff --git a/tests/resources/database/migrations/2020_08_11_102650_create_jingles_table.cfc b/tests/resources/database/migrations/2020_08_11_102650_create_jingles_table.cfc index 2f6687c8..ee3a92a7 100644 --- a/tests/resources/database/migrations/2020_08_11_102650_create_jingles_table.cfc +++ b/tests/resources/database/migrations/2020_08_11_102650_create_jingles_table.cfc @@ -16,8 +16,8 @@ component { "id": 3, "title": "I Wish I Was an Oscar Mayer Weiner", "download_url": "https://open.spotify.com/track/2wyg2ln6p4gEkdqM2mueLn?si=kWBpdUz1TLymdmTro-xjtw", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" } ] ); diff --git a/tests/resources/database/migrations/2024_06_13_134500_create_picture_comments_table.cfc b/tests/resources/database/migrations/2024_06_13_134500_create_picture_comments_table.cfc index b5b31e49..6220a8c4 100644 --- a/tests/resources/database/migrations/2024_06_13_134500_create_picture_comments_table.cfc +++ b/tests/resources/database/migrations/2024_06_13_134500_create_picture_comments_table.cfc @@ -19,8 +19,8 @@ component { "commentable_type": "Post", "designation": "picture", "user_id": 1, - "created_date": createDateTime( 2024, 06, 13, 13, 14, 22 ), - "modified_date": createDateTime( 2024, 06, 13, 13, 14, 22 ), + "created_date": "2024-06-13 13:14:22", + "modified_date": "2024-06-13 13:14:22", "sentimentAnalysis" : '{ "analyzed": true, "magnitude": 0.8, "score": 0.6 }' } ] ); diff --git a/tests/resources/database/migrations/2025_01_24_145656_create_actors_table.cfc b/tests/resources/database/migrations/2025_01_24_145656_create_actors_table.cfc index fb7319b8..6ed05af2 100644 --- a/tests/resources/database/migrations/2025_01_24_145656_create_actors_table.cfc +++ b/tests/resources/database/migrations/2025_01_24_145656_create_actors_table.cfc @@ -12,8 +12,8 @@ component { { "id": "5B8A472F-56E8-4BD6-A03D-6157662937E3", "name": "Tom Anks", - "created_date": createDateTime( 2017, 07, 28, 02, 07, 00 ), - "modified_date": createDateTime( 2017, 07, 28, 02, 07, 00 ) + "created_date": "2017-07-28 02:07:00", + "modified_date": "2017-07-28 02:07:00" } ] ); } diff --git a/tests/resources/factories/UserFactory.cfc b/tests/resources/factories/UserFactory.cfc new file mode 100644 index 00000000..fa4d4534 --- /dev/null +++ b/tests/resources/factories/UserFactory.cfc @@ -0,0 +1,27 @@ +component extends="quick.resources.testing.Factory" { + + property name="wirebox" inject="wirebox"; + + struct function definition() { + var suffix = structKeyExists( getFactoryContext(), "suffix" ) ? getFactoryContext().suffix : "default"; + return { + username : "factory-#suffix#-#lCase( createUUID() )#", + firstName : "Factory", + lastName : function( attributes, context ) { + return "User #context.index#"; + }, + email : "factory-#lCase( createUUID() )#@example.test", + password : hash( "password" ), + type : "limited" + }; + } + + any function administrator() { + return state( { type : "admin" } ); + } + + any function wired() { + return state( { firstName : isObject( variables.wirebox ) ? "Injected" : "Missing" } ); + } + +} diff --git a/tests/specs/integration/BaseEntity/AliasedCompositeKeySpec.cfc b/tests/specs/integration/BaseEntity/AliasedCompositeKeySpec.cfc new file mode 100644 index 00000000..529898b7 --- /dev/null +++ b/tests/specs/integration/BaseEntity/AliasedCompositeKeySpec.cfc @@ -0,0 +1,17 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "Aliased Composite Key Spec", function() { + it( "supports physical column names in a composite key with aliased properties", function() { + var composite = getInstance( "AliasedComposite" ).findOrFail( [ 1, 2 ] ); + + expect( composite.getGroupId() ).toBe( 1 ); + expect( composite.getMemberId() ).toBe( 2 ); + expect( composite.keyNames() ).toBe( [ "a", "b" ] ); + expect( composite.keyValues() ).toBe( [ 1, 2 ] ); + expect( composite.getMemento() ).toBe( { "groupId" : 1, "memberId" : 2 } ); + } ); + } ); + } + +} diff --git a/tests/specs/integration/BaseEntity/AsQuerySpec.cfc b/tests/specs/integration/BaseEntity/AsQuerySpec.cfc index d2dc845a..bf4dc5f2 100644 --- a/tests/specs/integration/BaseEntity/AsQuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/AsQuerySpec.cfc @@ -12,8 +12,18 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( results ).toHaveLength( 2 ); for ( var result in results ) { - result.createdDate = dateTimeFormat( result.createdDate, "yyyy-mm-dd hh:nn:ss" ); - result.modifiedDate = dateTimeFormat( result.modifiedDate, "yyyy-mm-dd hh:nn:ss" ); + result.createdDate = formatTestTimestamp( result.createdDate ); + result.modifiedDate = formatTestTimestamp( result.modifiedDate ); + var nullableKeys = [ + "email", + "streetTwo", + "favoritePost_id" + ]; + for ( var key in nullableKeys ) { + if ( isNull( result[ key ] ) ) { + result[ key ] = ""; + } + } } expect( results[ 1 ] ).toBeStruct(); @@ -74,7 +84,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( results[ 1 ][ "latestPostId" ] ).toBe( 523526 ); expect( results[ 2 ] ).toBeStruct(); expect( results[ 2 ] ).toHaveKey( "latestPostId" ); - expect( results[ 2 ][ "latestPostId" ] ).toBe( "" ); + expect( isNull( results[ 2 ][ "latestPostId" ] ) ? "" : results[ 2 ][ "latestPostId" ] ).toBe( "" ); + } ); + + it( "can select an aliased entity attribute when returning query data", function() { + var result = getInstance( "User" ) + .select( [ "firstName AS firstName" ] ) + .where( "id", 1 ) + .asQuery( withAliases = false ) + .first(); + + expect( result ).toBeStruct(); + expect( result ).toHaveKey( "firstName" ); + expect( result.firstName ).toBe( "Eric" ); } ); it( "can do eager loading", function() { diff --git a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc index ddd629cc..85c6df9c 100644 --- a/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeCastsSpec.cfc @@ -97,18 +97,27 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( theme.getConfig().primaryColor ).toBe( "orange" ); } ); - it( "can still allow nulls when using casts", () => { + it( "preserves null values when using BooleanCast", () => { var pn = getInstance( "PhoneNumber" ).find( 3 ); expect( pn.isNullAttribute( "confirmed" ) ).toBeTrue( "[confirmed] should be considered null" ); - expect( pn.getConfirmed() ).toBe( "" ); - // expect( function() { - pn.update( { "active" : false } ); - // } ).notToThrow( message = "PhoneNumber should be able to be saved with a `null` [confirmed] value" ); + + pn.update( { "active" : false } ).refresh(); + + expect( pn.isNullAttribute( "confirmed" ) ).toBeTrue( "[confirmed] should remain null after saving" ); + } ); + + it( "allows custom casts to handle null database values", () => { + var pn = getInstance( "CustomCastPhoneNumber" ).find( 3 ); + + expect( pn.getConfirmed() ).toBe( "casted-null" ); } ); - it( "correctly casts child entities", () => { + it( "casts single table discriminated child entities loaded through the parent", () => { var product = getInstance( "BaseProduct" ).firstOrFail(); + + expect( product ).toBeInstanceOf( "ProductBook" ); expect( product.getMetadata() ).toBeStruct(); + expect( product.getMemento().metadata ).toBeStruct(); } ); it( "can maintain casts when loading a discriminated child through the parent", () => { diff --git a/tests/specs/integration/BaseEntity/AttributeSpec.cfc b/tests/specs/integration/BaseEntity/AttributeSpec.cfc index 51bce09f..00cf5e2e 100644 --- a/tests/specs/integration/BaseEntity/AttributeSpec.cfc +++ b/tests/specs/integration/BaseEntity/AttributeSpec.cfc @@ -15,6 +15,50 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( attributesAfterSubselect ).toInclude( "latestPostId" ); expect( attributesFromNewEntity ).toInclude( "latestPostId" ); expect( attributesFromFresh ).notToInclude( "latestPostId" ); + expect( virtualAttributeEntity.get_Meta().attributes ).notToHaveKey( "latestPostId" ); + } ); + + it( "isolates virtual attributes added to spawned entities", function() { + var source = getInstance( "User" ).appendVirtualAttribute( "sourceVirtual" ); + var spawned = source.newEntity().appendVirtualAttribute( "spawnedVirtual" ); + var sibling = source.newEntity(); + + expect( spawned.hasAttribute( "sourceVirtual" ) ).toBeTrue(); + expect( spawned.hasAttribute( "spawnedVirtual" ) ).toBeTrue(); + expect( source.hasAttribute( "spawnedVirtual" ) ).toBeFalse(); + expect( sibling.hasAttribute( "spawnedVirtual" ) ).toBeFalse(); + expect( getInstance( "User" ).hasAttribute( "spawnedVirtual" ) ).toBeFalse(); + } ); + + it( "indexes deep runtime attributes and invalidates the index when extended", function() { + var user = getInstance( "User" ); + for ( var i = 1; i <= 10; i++ ) { + user.appendVirtualAttribute( "runtimeAttribute#i#" ); + } + + expect( user.hasAttribute( "runtimeAttribute1" ) ).toBeTrue(); + expect( user.retrieveAliasForColumn( "runtimeAttribute1" ) ).toBe( "runtimeAttribute1" ); + + user.appendVirtualAttribute( "runtimeAttribute11" ); + expect( user.hasAttribute( "runtimeAttribute11" ) ).toBeTrue(); + expect( user.hasAttribute( "runtimeAttribute1" ) ).toBeTrue(); + } ); + + it( "can provide a default for a virtual attribute", function() { + var user = getInstance( "User" ).appendVirtualAttribute( "hasPosts", false ); + var newUser = user.newEntity(); + + expect( serializeJSON( user.getHasPosts() ) ).toBe( "false" ); + expect( serializeJSON( user.getMemento().hasPosts ) ).toBe( "false" ); + expect( serializeJSON( newUser.getHasPosts() ) ).toBe( "false" ); + expect( serializeJSON( newUser.getMemento().hasPosts ) ).toBe( "false" ); + } ); + + it( "can exclude a defaulted virtual attribute from mementos", function() { + var user = getInstance( "User" ).appendVirtualAttribute( "internalFlag", false, true ); + + expect( serializeJSON( user.getInternalFlag() ) ).toBe( "false" ); + expect( user.getMemento() ).notToHaveKey( "internalFlag" ); } ); it( "can get any attribute using the `getColumnName` magic methods", function() { @@ -30,6 +74,28 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.getUsername() ).toBe( "new_username" ); } ); + it( "can assign a Quick entity key as an attribute value", function() { + var country = getInstance( "Country" ).findOrFail( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + var user = getInstance( "User" ).assignAttribute( "countryId", country ); + + expect( user.retrieveAttribute( "countryId" ) ).toBe( country.getId() ); + } ); + + it( "prioritizes attribute aliases over conflicting column names in magic accessors", function() { + var entity = getInstance( "ColumnAliasCollision" ); + entity.setActivoSN( false ); + entity.setActivo( true ); + + expect( entity.getActivo() ).toBeTrue(); + expect( entity.getActivoSN() ).toBeFalse(); + } ); + + it( "rejects duplicate property names", function() { + expect( function() { + getInstance( "DuplicateUsernamePropertyUser" ); + } ).toThrow(); + } ); + it( "can set a value to null using the `setColumnName` magic methods", function() { var user = getInstance( "User" ).find( 1 ); expect( user.getUsername() ).toBe( "elpete" ); @@ -95,6 +161,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.getUsername() ).toBe( "elpete" ); } ); + it( "resets the cached query when resetting an entity", function() { + var users = getInstance( "User" ); + + users.where( "id", 1 ); + + expect( users.reset().get() ).toHaveLength( 5 ); + } ); + it( "shows all the attributes in the memento of a newly created object", function() { var memento = getInstance( "User" ).getMemento(); if ( structCount( memento ) != 14 ) { @@ -167,8 +241,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "shows all the attributes in the component casing", function() { var memento = getInstance( "User" ).findOrFail( 1 ).getMemento(); - memento.createdDate = dateTimeFormat( memento.createdDate, "yyyy-mm-dd hh:nn:ss" ); - memento.modifiedDate = dateTimeFormat( memento.modifiedDate, "yyyy-mm-dd hh:nn:ss" ); + memento.createdDate = formatTestTimestamp( memento.createdDate ); + memento.modifiedDate = formatTestTimestamp( memento.modifiedDate ); + if ( isNull( memento.email ) ) { + memento.email = ""; + } + if ( isNull( memento.address.streetTwo ) ) { + memento.address.streetTwo = ""; + } expect( memento ).toBe( { "id" : 1, "username" : "elpete", @@ -181,7 +261,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { "modifiedDate" : "2017-07-28 02:06:36", "type" : "admin", "email" : "", - "externalId" : "1234", + "externalID" : "1234", "address" : { "streetOne" : "123 Elm Street", "streetTwo" : "", @@ -193,6 +273,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); } ); + it( "uses an explicit column when the property name is also a database column", function() { + var user = getInstance( "AliasedUsernameUser" ).findOrFail( 1 ); + + expect( user.getUsername() ).toBe( "Eric" ); + expect( user.retrieveAttributesData() ).toHaveKey( "first_name" ); + expect( user.retrieveAttributesData() ).notToHaveKey( "username" ); + } ); + // https://github.com/coldbox-modules/quick/issues/127 it( "can clear an attribute", () => { var elpete = getInstance( "User" ).findOrFail( 1 ); diff --git a/tests/specs/integration/BaseEntity/AutomaticTimestampsSpec.cfc b/tests/specs/integration/BaseEntity/AutomaticTimestampsSpec.cfc new file mode 100644 index 00000000..db2c9b70 --- /dev/null +++ b/tests/specs/integration/BaseEntity/AutomaticTimestampsSpec.cfc @@ -0,0 +1,189 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "AutomaticTimestampsSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "AutomaticTimestampsSpec" ); + super.afterAll(); + } + + function run() { + describe( "Automatic Timestamps", function() { + it( "uses the module setting as the per-entity default", function() { + var user = getInstance( "AutomaticTimestampUser" ); + + expect( user.get_automaticTimestampsDefault() ).toBeTrue(); + expect( user.usesAutomaticTimestamps() ).toBeTrue(); + expect( user.timestampFields() ).toBe( [ "createdDate", "modifiedDate" ] ); + } ); + + it( "touches the configured timestamp fields", function() { + var user = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ); + var originalCreatedDate = user.getCreatedDate(); + queryExecute( "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = 1" ); + user = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ); + var originalModifiedDate = user.getModifiedDate(); + + user.touch(); + var freshUser = user.fresh(); + + expect( dateCompare( freshUser.getCreatedDate(), originalCreatedDate ) ).toBe( 1 ); + expect( dateCompare( freshUser.getModifiedDate(), originalModifiedDate ) ).toBe( 1 ); + } ); + + it( "sets conventional timestamps during inserts and updates", function() { + var user = getInstance( "AutomaticTimestampUser" ).create( { + "username" : "automatic-timestamps", + "firstName" : "Automatic", + "lastName" : "Timestamps", + "password" : "secret" + } ); + + expect( user.getCreatedDate() ).toBeDate(); + expect( user.getModifiedDate() ).toBeDate(); + + queryExecute( + "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = :id", + { "id" : user.getId() } + ); + user = getInstance( "AutomaticTimestampUser" ).findOrFail( user.getId() ); + var previousModifiedDate = user.getModifiedDate(); + user.update( { "firstName" : "Updated" } ); + + expect( dateCompare( user.getModifiedDate(), previousModifiedDate ) ).toBe( 1 ); + } ); + + it( "sets conventional timestamps before insert and update events", function() { + structDelete( variables, "preInsertTimestamps" ); + structDelete( variables, "preUpdateTimestamps" ); + + var user = getInstance( "AutomaticTimestampUser" ).create( { + "username" : "automatic-timestamp-events", + "firstName" : "Automatic", + "lastName" : "Events", + "password" : "secret" + } ); + + expect( variables ).toHaveKey( "preInsertTimestamps" ); + expect( variables.preInsertTimestamps.createdDate ).toBeDate(); + expect( variables.preInsertTimestamps.modifiedDate ).toBeDate(); + + queryExecute( + "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = :id", + { "id" : user.getId() } + ); + user = getInstance( "AutomaticTimestampUser" ).findOrFail( user.getId() ); + var previousModifiedDate = user.getModifiedDate(); + user.update( { "firstName" : "Updated" } ); + + expect( variables ).toHaveKey( "preUpdateTimestamps" ); + expect( variables.preUpdateTimestamps.modifiedDate ).toBeDate(); + expect( dateCompare( variables.preUpdateTimestamps.modifiedDate, previousModifiedDate ) ).toBe( 1 ); + } ); + + it( "preserves explicitly assigned timestamps", function() { + var createdDate = dateAdd( "y", -1, now() ); + var modifiedDate = dateAdd( "m", -1, now() ); + var user = getInstance( "AutomaticTimestampUser" ).create( { + "username" : "explicit-timestamps", + "firstName" : "Explicit", + "lastName" : "Timestamps", + "password" : "secret", + "createdDate" : createdDate, + "modifiedDate" : modifiedDate + } ); + + expect( dateCompare( user.getCreatedDate(), createdDate ) ).toBe( 0 ); + expect( dateCompare( user.getModifiedDate(), modifiedDate ) ).toBe( 0 ); + } ); + + it( "supports component metadata timestamp attribute names", function() { + var user = getInstance( "AnnotatedTimestampUser" ).create( { + "username" : "annotated-timestamps", + "firstName" : "Annotated", + "lastName" : "Timestamps", + "password" : "secret" + } ); + + expect( user.getCreatedDate() ).toBeDate(); + expect( user.getModifiedDate() ).toBeDate(); + } ); + + it( "can disable automatic timestamps per entity", function() { + queryExecute( "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = 1" ); + var user = getInstance( "DisabledAutomaticTimestampUser" ).findOrFail( 1 ); + var originalModifiedDate = user.getModifiedDate(); + user.update( { "firstName" : "No Timestamp" } ); + + expect( user.usesAutomaticTimestamps() ).toBeFalse(); + expect( dateCompare( user.refresh().getModifiedDate(), originalModifiedDate ) ).toBe( 0 ); + } ); + + it( "does not add SQL for timestamp attributes missing from the entity", function() { + var country = getInstance( "Country" ).firstOrFail(); + country.update( { "name" : "No Blind Timestamp SQL" } ); + + expect( country.getName() ).toBe( "No Blind Timestamp SQL" ); + } ); + + it( "can disable automatic timestamps for a builder chain", function() { + queryExecute( "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = 1" ); + var originalModifiedDate = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ).getModifiedDate(); + + getInstance( "AutomaticTimestampUser" ) + .whereId( 1 ) + .withoutAutomaticTimestamps() + .updateAll( { "firstName" : "Builder Disabled" } ); + + var user = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ); + expect( dateCompare( user.getModifiedDate(), originalModifiedDate ) ).toBe( 0 ); + } ); + + it( "adds the update timestamp to normal bulk updates", function() { + queryExecute( "UPDATE users SET modified_date = DATE_SUB(CURRENT_TIMESTAMP, INTERVAL 1 DAY) WHERE id = 1" ); + var previousModifiedDate = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ).getModifiedDate(); + + getInstance( "AutomaticTimestampUser" ).whereId( 1 ).updateAll( { "firstName" : "Builder Timestamp" } ); + + var user = getInstance( "AutomaticTimestampUser" ).findOrFail( 1 ); + expect( dateCompare( user.getModifiedDate(), previousModifiedDate ) ).toBe( 1 ); + } ); + } ); + } + + function quickPreInsert( + event, + interceptData, + buffer, + rc, + prc + ) { + if ( + arguments.interceptData.attributes.keyExists( "created_date" ) + && arguments.interceptData.attributes.keyExists( "modified_date" ) + ) { + variables.preInsertTimestamps = { + "createdDate" : arguments.interceptData.attributes.created_date, + "modifiedDate" : arguments.interceptData.attributes.modified_date + }; + } + } + + function quickPreUpdate( + event, + interceptData, + buffer, + rc, + prc + ) { + if ( arguments.interceptData.newAttributes.keyExists( "modified_date" ) ) { + variables.preUpdateTimestamps = { "modifiedDate" : arguments.interceptData.newAttributes.modified_date }; + } + } + +} diff --git a/tests/specs/integration/BaseEntity/ChildClassSpec.cfc b/tests/specs/integration/BaseEntity/ChildClassSpec.cfc index 7b37ff1f..44523417 100644 --- a/tests/specs/integration/BaseEntity/ChildClassSpec.cfc +++ b/tests/specs/integration/BaseEntity/ChildClassSpec.cfc @@ -49,14 +49,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newMemento.keyArray() ).toBe( memento.keyArray() ); for ( var key in newMemento.keyArray() ) { if ( isDate( newMemento[ key ] ) ) { - expect( - dateCompare( - newMemento[ key ], - memento[ key ], - "s" - ) - ).toBe( - 0, + expect( dateTimeFormat( newMemento[ key ], "yyyy-mm-dd HH:nn:ss" ) ).toBe( + dateTimeFormat( memento[ key ], "yyyy-mm-dd HH:nn:ss" ), "Dates are not equal to the second. Left: #dateTimeFormat( newMemento[ key ], "MM/DD/YYYY HH:nn:ss" )# - Right: #dateTimeFormat( memento[ key ], "MM/DD/YYYY HH:nn:ss" )#" ); } else { @@ -384,7 +378,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { // comment id 4 = internal comment var internalComment = getInstance( "Comment" ) .findOrFail( 4 ) - .update( { reason : "Super private, ya know?" } ); + .update( { reason : "Super private, ya know?" } ); var uInternalComment = getInstance( "Comment" ).findOrFail( 4 ); @@ -392,7 +386,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( uInternalComment.getReason() ).toBe( "Super private, ya know?" ); // comment id 5 = picture comment - var pictureComment = getInstance( "Comment" ).findOrFail( 5 ).update( { filename : "Lenna.jpeg" } ); + var pictureComment = getInstance( "Comment" ).findOrFail( 5 ).update( { filename : "Lenna.jpeg" } ); var uPictureComment = getInstance( "Comment" ).findOrFail( 5 ); diff --git a/tests/specs/integration/BaseEntity/CloneSpec.cfc b/tests/specs/integration/BaseEntity/CloneSpec.cfc index a671cc17..ad4cdaa8 100644 --- a/tests/specs/integration/BaseEntity/CloneSpec.cfc +++ b/tests/specs/integration/BaseEntity/CloneSpec.cfc @@ -26,6 +26,29 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( clonedUser.isLoaded() ).toBeTrue( "The cloned user instance should be marked as loaded, but was not." ); } ); + it( "can replicate a loaded entity as a new entity without its key", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + var replica = user.replicate(); + + expect( replica.isLoaded() ).toBeFalse(); + expect( replica.retrieveAttributesData() ).notToHaveKey( "id" ); + expect( replica.getUsername() ).toBe( user.getUsername() ); + + replica + .setUsername( "replicated-user" ) + .setEmail( "replicated@example.com" ) + .save(); + expect( replica.isLoaded() ).toBeTrue(); + expect( replica.getId() ).notToBe( user.getId() ); + } ); + + it( "can exclude additional attributes when replicating", function() { + var replica = getInstance( "User" ).findOrFail( 1 ).replicate( [ "email" ] ); + + expect( replica.retrieveAttributesData() ).notToHaveKey( "id" ); + expect( replica.retrieveAttributesData() ).notToHaveKey( "email" ); + } ); + it( "can clone a QuickBuilder instance", function() { var userBuilder = getInstance( "User" ).orderBy( "id" ); var userBuilder2 = userBuilder.clone(); diff --git a/tests/specs/integration/BaseEntity/ColumnsSpec.cfc b/tests/specs/integration/BaseEntity/ColumnsSpec.cfc index f931e32b..0aef4432 100644 --- a/tests/specs/integration/BaseEntity/ColumnsSpec.cfc +++ b/tests/specs/integration/BaseEntity/ColumnsSpec.cfc @@ -2,6 +2,62 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Columns", function() { + it( "caches qualified columns across entity instances", function() { + request.qualifiedColumnsCalls = 0; + + var firstColumns = getInstance( "QualifiedColumnsCacheEntity" ).retrieveQualifiedColumns(); + var callsAfterFirstLookup = request.qualifiedColumnsCalls; + var secondColumns = getInstance( "QualifiedColumnsCacheEntity" ).retrieveQualifiedColumns(); + + expect( callsAfterFirstLookup ).toBeGT( 0 ); + expect( request.qualifiedColumnsCalls ).toBe( callsAfterFirstLookup ); + expect( secondColumns ).toBe( firstColumns ); + + firstColumns.append( "mutated.column" ); + expect( getInstance( "QualifiedColumnsCacheEntity" ).retrieveQualifiedColumns() ).notToInclude( + "mutated.column" + ); + } ); + + it( "caches qualified columns separately for table aliases", function() { + request.qualifiedColumnsCalls = 0; + + getInstance( "QualifiedColumnsCacheEntity" ).withAlias( "cached_user" ).retrieveQualifiedColumns(); + var callsAfterFirstLookup = request.qualifiedColumnsCalls; + var columns = getInstance( "QualifiedColumnsCacheEntity" ) + .withAlias( "cached_user" ) + .retrieveQualifiedColumns(); + + expect( callsAfterFirstLookup ).toBeGT( 0 ); + expect( request.qualifiedColumnsCalls ).toBe( callsAfterFirstLookup ); + expect( columns ).toInclude( "cached_user.id" ); + } ); + + it( "keeps runtime persistent attributes isolated from declared column caches", function() { + var dynamicEntity = getInstance( "QualifiedColumnsCacheEntity" ); + dynamicEntity.forceAssignAttribute( "runtime_column", "value" ); + + expect( dynamicEntity.retrieveQualifiedColumns() ).toInclude( "users.runtime_column" ); + expect( dynamicEntity.newEntity().retrieveQualifiedColumns() ).toInclude( "users.runtime_column" ); + expect( getInstance( "QualifiedColumnsCacheEntity" ).retrieveQualifiedColumns() ).notToInclude( + "users.runtime_column" + ); + expect( dynamicEntity.get_Meta().attributes ).notToHaveKey( "runtime_column" ); + } ); + + it( "isolates runtime persistent attributes added to spawned entities", function() { + var source = getInstance( "QualifiedColumnsCacheEntity" ); + source.forceAssignAttribute( "source_column", "source" ); + var spawned = source.newEntity(); + spawned.forceAssignAttribute( "spawned_column", "spawned" ); + + expect( spawned.retrieveQualifiedColumns() ).toInclude( "users.source_column" ); + expect( spawned.retrieveQualifiedColumns() ).toInclude( "users.spawned_column" ); + expect( source.retrieveQualifiedColumns() ).toInclude( "users.source_column" ); + expect( source.retrieveQualifiedColumns() ).notToInclude( "users.spawned_column" ); + expect( source.newEntity().retrieveQualifiedColumns() ).notToInclude( "users.spawned_column" ); + } ); + it( "can access the attributes by their alias", function() { var link = getInstance( "Link" ).findOrFail( 1 ); @@ -30,9 +86,21 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ).notToThrow(); } ); + it( "does not mass assign injected non-persistent properties", function() { + expect( function() { + getInstance( "Link" ).fill( { "wirebox" : "not-the-injector" } ); + } ).toThrow( "AttributeNotFound" ); + } ); + + it( "does not mass assign non-persistent properties unless explicitly fillable", function() { + expect( function() { + getInstance( "Post" ).fill( { "internalLifecycleState" : "internal-only" } ); + } ).toThrow( "AttributeNotFound" ); + } ); + it( "translates attributes to their column names", function() { expect( function() { - getInstance( "Link" ).create( { url : "https://example.com" } ); + getInstance( "Link" ).create( { url : "https://example.com" } ); } ).notToThrow(); } ); @@ -48,6 +116,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( bindings[ 1 ] ).toBeStruct(); expect( bindings[ 1 ].value ).toBe( "firstName" ); } ); + + it( "preserves the entity key when selecting specific columns", function() { + var user = getInstance( "User" ).select( "username" ).findOrFail( 1 ); + + expect( user.getId() ).toBe( 1 ); + expect( user.getUsername() ).toBe( "elpete" ); + } ); + + it( "preserves every composite key column when selecting specific columns", function() { + var composite = getInstance( "Composite" ).select( "a" ).findOrFail( [ 1, 2 ] ); + + expect( composite.keyValues() ).toBe( [ 1, 2 ] ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/CreateSpec.cfc b/tests/specs/integration/BaseEntity/CreateSpec.cfc index 5b1c7b32..9344aacc 100644 --- a/tests/specs/integration/BaseEntity/CreateSpec.cfc +++ b/tests/specs/integration/BaseEntity/CreateSpec.cfc @@ -36,6 +36,25 @@ component extends="tests.resources.ModuleIntegrationSpec" { ).notToBeNull(); } ); + it( "creates only the root while retaining filled relationships in memory", function() { + var user = getInstance( "User" ).create( { + "username" : "aggregate-user", + "first_name" : "Aggregate", + "last_name" : "User", + "password" : hash( "password" ), + "posts" : [ + { "body" : "First child" }, + { "body" : "Second child" } + ] + } ); + + expect( user.isLoaded() ).toBeTrue(); + expect( user.getPosts() ).toHaveLength( 2 ); + expect( user.getPosts()[ 1 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 2 ].isLoaded() ).toBeFalse(); + expect( user.fresh().getPosts() ).toBeEmpty(); + } ); + it( "can create a new entity with a json cast", () => { var newTheme = getInstance( "Theme" ).create( { slug : "theme-new", @@ -48,6 +67,33 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newTheme.isNullAttribute( "config" ) ).toBeFalse(); expect( newTheme.getConfig() ).toBe( { "message" : "I should be cast to JSON" } ); } ); + + it( "can create and return multiple entities", function() { + var users = getInstance( "User" ).createAll( [ + { + "username" : "first-new-user", + "firstName" : "First", + "lastName" : "User" + }, + { + "username" : "second-new-user", + "firstName" : "Second", + "lastName" : "User" + } + ] ); + + expect( users ).toBeArray(); + expect( users ).toHaveLength( 2 ); + expect( users[ 1 ].isLoaded() ).toBeTrue(); + expect( users[ 1 ].getId() ).notToBeNull(); + expect( users[ 1 ].getFirstName() ).toBe( "First" ); + expect( users[ 2 ].isLoaded() ).toBeTrue(); + expect( users[ 2 ].getId() ).notToBeNull(); + expect( users[ 2 ].getFirstName() ).toBe( "Second" ); + expect( getInstance( "User" ).whereIn( "username", [ "first-new-user", "second-new-user" ] ).count() ).toBe( + 2 + ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/EntityDefinitionRegistrySpec.cfc b/tests/specs/integration/BaseEntity/EntityDefinitionRegistrySpec.cfc new file mode 100644 index 00000000..6f6deefa --- /dev/null +++ b/tests/specs/integration/BaseEntity/EntityDefinitionRegistrySpec.cfc @@ -0,0 +1,118 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "EntityDefinitionRegistry", function() { + beforeEach( function() { + variables.registry = getInstance( "EntityDefinitionRegistry@quick" ); + variables.registry.clear(); + } ); + + afterEach( function() { + variables.registry.clear(); + } ); + + it( "compiles one definition once", function() { + var compilationCount = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); + var first = variables.registry.getOrCreateDefinition( "User", () => { + compilationCount.incrementAndGet(); + return { "token" : createUUID() }; + } ); + var second = variables.registry.getOrCreateDefinition( "User", () => { + compilationCount.incrementAndGet(); + return { "token" : createUUID() }; + } ); + + expect( compilationCount.get() ).toBe( 1 ); + expect( second.token ).toBe( first.token ); + expect( variables.registry.getStats().definitionCount ).toBe( 1 ); + } ); + + it( "compiles one definition under concurrent access", function() { + var compilationCount = createObject( "java", "java.util.concurrent.atomic.AtomicInteger" ).init( 0 ); + var definitionTokens = createObject( "java", "java.util.concurrent.ConcurrentHashMap" ).init(); + var threadNames = []; + var sharedKey = "quickDefinitionRegistry#replace( createUUID(), "-", "", "all" )#"; + server[ sharedKey ] = { + "registry" : variables.registry, + "compilationCount" : compilationCount, + "definitionTokens" : definitionTokens + }; + try { + for ( var index = 1; index <= 16; index++ ) { + var threadName = "quickDefinitionRegistry#replace( createUUID(), "-", "", "all" )#"; + threadNames.append( threadName ); + cfthread( + action = "run", + name = threadName, + sharedKey = sharedKey, + resultKey = threadName + ) { + var shared = server[ attributes.sharedKey ]; + var definition = shared.registry.getOrCreateDefinition( "ConcurrentUser", () => { + shared.compilationCount.incrementAndGet(); + sleep( 25 ); + return { "token" : createUUID() }; + } ); + shared.definitionTokens.put( attributes.resultKey, definition.token ); + } + } + cfthread( + action = "join", + name = threadNames.toList(), + timeout = 10000 + ); + + var tokens = {}; + for ( var threadName in threadNames ) { + expect( cfthread[ threadName ].status ).toBe( "COMPLETED" ); + tokens[ definitionTokens.get( threadName ) ] = true; + } + expect( compilationCount.get() ).toBe( 1 ); + expect( definitionTokens.size() ).toBe( threadNames.len() ); + expect( structCount( tokens ) ).toBe( 1 ); + } finally { + server.delete( sharedKey ); + } + } ); + + it( "bounds derived entries without evicting definitions", function() { + variables.registry.getOrCreateDefinition( "User", () => { + return { "token" : "definition" }; + } ); + for ( var index = 1; index <= 12; index++ ) { + variables.registry.getOrCreateDerived( + mapping = "User", + group = "qualifiedColumns", + variant = "shape#formatBaseN( index, 10 )#", + factory = () => { + return [ index ]; + }, + limit = 4 + ); + } + + var stats = variables.registry.getStats(); + expect( stats.definitionCount ).toBe( 1 ); + expect( stats.derivedEntryCount ).toBeLTE( 4 ); + expect( stats.derivedEvictionCount ).toBeGTE( 8 ); + expect( variables.registry.hasDefinition( "User" ) ).toBeTrue(); + } ); + + it( "keeps entity definitions warm when CacheBox entries are cleared", function() { + var metadataCache = getInstance( "User" ).get_cache(); + variables.registry.clear(); + metadataCache.clearAll(); + + var first = getInstance( "User" ); + var compilationCount = variables.registry.getStats().definitionCompilationCount; + metadataCache.clearAll(); + var second = getInstance( "User" ); + + expect( compilationCount ).toBe( 1 ); + expect( variables.registry.getStats().definitionCompilationCount ).toBe( compilationCount ); + expect( second.get_Meta().mapping ).toBe( first.get_Meta().mapping ); + } ); + } ); + } + +} diff --git a/tests/specs/integration/BaseEntity/Events/CustomEventsSpec.cfc b/tests/specs/integration/BaseEntity/Events/CustomEventsSpec.cfc new file mode 100644 index 00000000..8674af3c --- /dev/null +++ b/tests/specs/integration/BaseEntity/Events/CustomEventsSpec.cfc @@ -0,0 +1,60 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( + interceptorObject = this, + interceptorName = "CustomEventsSpec", + customPoints = [ + "onSongCreated", + "onSongSaved", + "onMediaSaved" + ] + ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "CustomEventsSpec" ); + super.afterAll(); + } + + function run() { + describe( "custom entity events", function() { + beforeEach( function() { + variables.customEvents = []; + } ); + + it( "dispatches string and array interception points for lifecycle events", function() { + var song = getInstance( "Song" ).create( { + title : "Rainbow Connection", + download_url : "https://open.spotify.com/track/1SJ4ycWow4yz6z4oFz8NAG" + } ); + + expect( variables.customEvents ).toBe( [ + "onSongCreated", + "onSongSaved", + "onMediaSaved" + ] ); + expect( variables.customEventEntity.getId() ).toBe( song.getId() ); + } ); + } ); + } + + function onSongCreated( event, interceptData ) { + variables.customEvents.append( "onSongCreated" ); + variables.customEventEntity = arguments.interceptData.entity; + } + + function onSongSaved( event, interceptData ) { + variables.customEvents.append( "onSongSaved" ); + variables.customEventEntity = arguments.interceptData.entity; + } + + function onMediaSaved( event, interceptData ) { + variables.customEvents.append( "onMediaSaved" ); + variables.customEventEntity = arguments.interceptData.entity; + } + +} diff --git a/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc b/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc new file mode 100644 index 00000000..a6bd5ad6 --- /dev/null +++ b/tests/specs/integration/BaseEntity/Events/PostReplicateSpec.cfc @@ -0,0 +1,51 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "PostReplicateSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "PostReplicateSpec" ); + super.afterAll(); + } + + function run() { + describe( "postReplicate spec", function() { + it( "announces a quickPostReplicate interception point", function() { + var original = getInstance( "Song" ).findOrFail( 1 ); + var replica = original.replicate(); + + expect( variables ).toHaveKey( "quickPostReplicateCalled" ); + expect( variables.quickPostReplicateCalled.entity.isLoaded() ).toBeFalse(); + expect( variables.quickPostReplicateCalled.entity.getTitle() ).toBe( replica.getTitle() ); + expect( variables.quickPostReplicateCalled.original.getId() ).toBe( original.getId() ); + structDelete( variables, "quickPostReplicateCalled" ); + } ); + + it( "calls a postReplicate method on the replicated component", function() { + var original = getInstance( "Song" ).findOrFail( 1 ); + var replica = original.replicate(); + + expect( request ).toHaveKey( "postReplicateCalled" ); + expect( request.postReplicateCalled.entity.isLoaded() ).toBeFalse(); + expect( request.postReplicateCalled.entity.getTitle() ).toBe( replica.getTitle() ); + expect( request.postReplicateCalled.original.getId() ).toBe( original.getId() ); + structDelete( request, "postReplicateCalled" ); + } ); + } ); + } + + function quickPostReplicate( + event, + interceptData, + buffer, + rc, + prc + ) { + variables.quickPostReplicateCalled = arguments.interceptData; + } + +} diff --git a/tests/specs/integration/BaseEntity/Events/PreSaveSpec.cfc b/tests/specs/integration/BaseEntity/Events/PreSaveSpec.cfc index c41a6acc..ce1e13cc 100644 --- a/tests/specs/integration/BaseEntity/Events/PreSaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/Events/PreSaveSpec.cfc @@ -75,6 +75,12 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); structDelete( request, "preSaveCalled" ); } ); + + it( "casts attributes assigned by a preSave method", function() { + var phoneNumber = getInstance( "PreSaveCastPhoneNumber" ).create( { "active" : true, "confirmed" : false } ); + + expect( phoneNumber.fresh().getNumber() ).toBeTrue(); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/Events/PreUpdateSpec.cfc b/tests/specs/integration/BaseEntity/Events/PreUpdateSpec.cfc index e0c09528..0cc1945e 100644 --- a/tests/specs/integration/BaseEntity/Events/PreUpdateSpec.cfc +++ b/tests/specs/integration/BaseEntity/Events/PreUpdateSpec.cfc @@ -52,6 +52,12 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( request.preUpdateCalled[ 1 ].entity.downloadUrl ).toBe( "https://open.spotify.com/track/0GHGd3jYqChGNxzjqgRZSv" ); + expect( request.preUpdateCalled[ 1 ].originalAttributes.download_url ).toBe( + "https://open.spotify.com/track/4Nd5HJn4EExnLmHtClk4QV" + ); + expect( request.preUpdateCalled[ 1 ].newAttributes.download_url ).toBe( + "https://open.spotify.com/track/0GHGd3jYqChGNxzjqgRZSv" + ); structDelete( request, "preUpdateCalled" ); } ); } ); diff --git a/tests/specs/integration/BaseEntity/Events/RelationshipLoadedSpec.cfc b/tests/specs/integration/BaseEntity/Events/RelationshipLoadedSpec.cfc new file mode 100644 index 00000000..f86e6885 --- /dev/null +++ b/tests/specs/integration/BaseEntity/Events/RelationshipLoadedSpec.cfc @@ -0,0 +1,65 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "RelationshipLoadedSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "RelationshipLoadedSpec" ); + super.afterAll(); + } + + function run() { + describe( "relationshipLoaded", function() { + beforeEach( function() { + variables.relationshipLoadedEvents = []; + } ); + + it( "calls a relationship-specific method for lazily loaded entities", function() { + var user = getInstance( "RelationshipLoadedUser" ).findOrFail( 1 ); + var posts = user.getPosts(); + + expect( posts ).toHaveLength( 2 ); + posts.each( function( post ) { + expect( post.retrieveRelationship( "loadedByUser" ).isSameAs( user ) ).toBeTrue(); + } ); + } ); + + it( "calls a relationship-specific method for eagerly loaded entities", function() { + var user = getInstance( "RelationshipLoadedUser" ).with( "posts" ).findOrFail( 1 ); + + expect( user.getPosts() ).toHaveLength( 2 ); + user.getPosts() + .each( function( post ) { + expect( post.retrieveRelationship( "loadedByUser" ).isSameAs( user ) ).toBeTrue(); + } ); + } ); + + it( "announces a relationshipLoaded interception point for each related entity", function() { + var user = getInstance( "RelationshipLoadedUser" ).findOrFail( 1 ); + user.getPosts(); + + expect( variables.relationshipLoadedEvents ).toHaveLength( 2 ); + variables.relationshipLoadedEvents.each( function( eventData ) { + expect( eventData.relationshipName ).toBe( "posts" ); + expect( eventData.parent.isSameAs( user ) ).toBeTrue(); + expect( eventData.entity ).toBeInstanceOf( "Post" ); + } ); + } ); + } ); + } + + function quickRelationshipLoaded( + event, + interceptData, + buffer, + rc, + prc + ) { + variables.relationshipLoadedEvents.append( arguments.interceptData ); + } + +} diff --git a/tests/specs/integration/BaseEntity/FillSpec.cfc b/tests/specs/integration/BaseEntity/FillSpec.cfc index 6455eb0a..1a1d54db 100644 --- a/tests/specs/integration/BaseEntity/FillSpec.cfc +++ b/tests/specs/integration/BaseEntity/FillSpec.cfc @@ -51,6 +51,47 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.retrieveAttribute( "lastName" ) ).toBe( "Vila" ); expect( user.retrieveAttribute( "email" ) ).toBe( "bob@vila.com" ); } ); + + it( "converts configured null values before invoking typed setters", function() { + var user = getInstance( "UserFill" ); + + expect( function() { + user.fill( { "updatedDate" : "" } ); + } ).notToThrow(); + expect( user.isNullAttribute( "updatedDate" ) ).toBeTrue(); + } ); + + it( "can fill relationships on a new entity without persisting the aggregate", function() { + var user = getInstance( "User" ).fill( { + "posts" : [ + getInstance( "Post" ).fill( { "body" : "Entity child" } ), + { "body" : "Struct child" } + ] + } ); + + expect( user.isLoaded() ).toBeFalse(); + expect( user.getPosts() ).toHaveLength( 2 ); + expect( user.getPosts()[ 1 ] ).toBeInstanceOf( "Post" ); + expect( user.getPosts()[ 1 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 1 ].getBody() ).toBe( "Entity child" ); + expect( user.getPosts()[ 2 ] ).toBeInstanceOf( "Post" ); + expect( user.getPosts()[ 2 ].isLoaded() ).toBeFalse(); + expect( user.getPosts()[ 2 ].getBody() ).toBe( "Struct child" ); + } ); + + it( "creates new relationship instances when filling the same relationship multiple times", function() { + var user = getInstance( "User" ); + + user.fill( { "posts" : [ { "body" : "First fill" } ] } ); + var firstPost = user.getPosts()[ 1 ]; + + user.fill( { "posts" : [ { "body" : "Second fill" } ] } ); + var secondPost = user.getPosts()[ 1 ]; + + expect( user.getPosts() ).toHaveLength( 1 ); + expect( secondPost.getBody() ).toBe( "Second fill" ); + expect( firstPost.getBody() ).toBe( "First fill" ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/GUIDPrimaryKeySpec.cfc b/tests/specs/integration/BaseEntity/GUIDPrimaryKeySpec.cfc index a52bef89..490f6832 100644 --- a/tests/specs/integration/BaseEntity/GUIDPrimaryKeySpec.cfc +++ b/tests/specs/integration/BaseEntity/GUIDPrimaryKeySpec.cfc @@ -8,6 +8,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { var country = getInstance( "Actor" ).create( { "name" : "Tina Fey" } ); expect( country.getId() ).notToBeNumeric(); + expect( country.getId() ).toHaveLength( 36 ); + expect( + reFindNoCase( + "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$", + country.getId() + ) + ).toBe( 1 ); + expect( getInstance( "Actor" ).find( country.getId() ) ).notToBeNull(); } ); }, skip = !server.keyExists( "lucee" ) && !server.keyExists( "boxlang" ) diff --git a/tests/specs/integration/BaseEntity/GetSpec.cfc b/tests/specs/integration/BaseEntity/GetSpec.cfc index 6d44af93..4439498d 100644 --- a/tests/specs/integration/BaseEntity/GetSpec.cfc +++ b/tests/specs/integration/BaseEntity/GetSpec.cfc @@ -1,5 +1,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "BaseEntityGetSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "BaseEntityGetSpec" ); + super.afterAll(); + } + function run() { describe( "Get Spec", function() { it( "finds an entity by the primary key", function() { @@ -7,6 +19,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.isLoaded() ).toBeTrue( "The user instance should be found and loaded, but was not." ); } ); + it( "passes query options when finding an entity by primary key", function() { + structDelete( request, "baseEntityGetSpecPreQBExecute" ); + + var user = getInstance( "User" ).find( 1, { datasource : "quick" } ); + var executionsWithDatasource = request.baseEntityGetSpecPreQBExecute.filter( function( execution ) { + return execution.options.keyExists( "datasource" ); + } ); + + expect( user ).notToBeNull(); + expect( executionsWithDatasource.len() ).toBeGT( 0 ); + expect( executionsWithDatasource[ 1 ].options.datasource ).toBe( "quick" ); + } ); + it( "returns null if the record cannot be found", function() { expect( getInstance( "User" ).find( 999 ) ).toBeNull( "The user instance should be null because it could not be found, but was not." @@ -28,6 +53,33 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.getUsername() ).toBe( "new_username" ); } ); + it( "reapplies scoped projections when refreshing", function() { + var user = getInstance( "User" ).withFullName().findOrFail( 1 ); + expect( user.getFullName() ).toBe( "Eric Peterson" ); + + queryExecute( "UPDATE `users` SET `first_name` = ? WHERE `id` = ?", [ "Ada", 1 ] ); + user.refresh(); + + expect( user.getFirstName() ).toBe( "Ada" ); + expect( user.getFullName() ).toBe( "Ada Peterson" ); + } ); + + it( "reapplies subselects when retrieving fresh and refreshed entities", function() { + var user = getInstance( "User" ).withLatestPostId().findOrFail( 1 ); + expect( user.getLatestPostId() ).toBe( 523526 ); + + queryExecute( + "UPDATE `my_posts` SET `created_date` = ? WHERE `post_pk` = ?", + [ "2030-01-01 00:00:00", 1245 ] + ); + + var freshUser = user.fresh(); + expect( freshUser.getLatestPostId() ).toBe( 1245 ); + + user.refresh(); + expect( user.getLatestPostId() ).toBe( 1245 ); + } ); + it( "can get a fresh instance from the database", function() { var user = getInstance( "User" ).find( 1 ); expect( user.getUsername() ).toBe( "elpete" ); @@ -180,6 +232,33 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( p.results[ p.results.len() ].getId() ).toBe( 45 ); } ); + it( "can retrieve entities in chunks", function() { + var chunks = []; + var query = getInstance( "User" ) + .orderBy( "id" ) + .chunk( 2, function( users ) { + chunks.append( { + "size" : users.len(), + "firstId" : users[ 1 ].getId(), + "quickEntity" : users[ 1 ].isQuickEntity + } ); + return chunks.len() < 2; + } ); + + expect( query.isQuickBuilder ).toBeTrue(); + expect( chunks ).toHaveLength( 2 ); + expect( chunks[ 1 ] ).toBe( { + "size" : 2, + "firstId" : 1, + "quickEntity" : true + } ); + expect( chunks[ 2 ] ).toBe( { + "size" : 2, + "firstId" : 3, + "quickEntity" : true + } ); + } ); + it( "can eager load and paginate a Quick query", function() { queryExecute( "TRUNCATE TABLE `a`" ); queryExecute( "TRUNCATE TABLE `b`" ); @@ -299,6 +378,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.isLoaded() ).toBeTrue(); var attrs = newUser.retrieveAttributesData( aliased = true ); attrs.delete( "id" ); + attrs.delete( "createdDate" ); + attrs.delete( "modifiedDate" ); expect( attrs ).toBe( { "username" : "doesntexist", "firstName" : "doesnt", @@ -319,6 +400,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.isLoaded() ).toBeTrue(); var attrs = newUser.retrieveAttributesData( aliased = true ); attrs.delete( "id" ); + attrs.delete( "createdDate" ); + attrs.delete( "modifiedDate" ); expect( attrs ).toBe( { "username" : "doesntexist", "firstName" : "doesnt", @@ -406,6 +489,8 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.isLoaded() ).toBeTrue(); var attrs = newUser.retrieveAttributesData( aliased = true ); attrs.delete( "id" ); + attrs.delete( "createdDate" ); + attrs.delete( "modifiedDate" ); expect( attrs ).toBe( { "username" : "doesntexist", "firstName" : "doesnt", @@ -438,4 +523,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); } + function preQBExecute( + event, + interceptData, + buffer, + rc, + prc + ) { + param request.baseEntityGetSpecPreQBExecute = []; + request.baseEntityGetSpecPreQBExecute.append( duplicate( arguments.interceptData ) ); + } + } diff --git a/tests/specs/integration/BaseEntity/GlobalScopeSpec.cfc b/tests/specs/integration/BaseEntity/GlobalScopeSpec.cfc index c8cd5fe3..e0c844ee 100644 --- a/tests/specs/integration/BaseEntity/GlobalScopeSpec.cfc +++ b/tests/specs/integration/BaseEntity/GlobalScopeSpec.cfc @@ -65,6 +65,32 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.countryName ).toBe( "United States" ); } ); + it( "preserves binding order when chunking a query with a bound global-scope subselect", function() { + var startDate = createDateTime( 2017, 1, 1, 0, 0, 0 ); + var endDate = createDateTime( 2018, 1, 1, 0, 0, 0 ); + var query = getInstance( "UserWithGlobalScope" ) + .whereBetween( "created_date", startDate, endDate ) + .where( "type", "admin" ) + .activateGlobalScopes() + .retrieveQuery(); + var bindingsBeforeChunk = query.getBindings().map( ( binding ) => binding.value ); + var rows = []; + + query.chunk( 1, function( chunk ) { + rows.append( chunk, true ); + } ); + + expect( rows ).toHaveLength( 1 ); + expect( rows[ 1 ].username ).toBe( "elpete" ); + expect( query.getBindings().map( ( binding ) => binding.value ) ).toBe( bindingsBeforeChunk ); + expect( bindingsBeforeChunk ).toBe( [ + "02B84D66-0AA0-F7FB-1F71AFC954843861", + startDate, + endDate, + "admin" + ] ); + } ); + it( "subsequent entity calls using withoutGlobalScope do not cache memento keys", function() { var userA = getInstance( "UserWithGlobalScope" ).findOrFail( 1 ).getMemento(); diff --git a/tests/specs/integration/BaseEntity/HydrateSpec.cfc b/tests/specs/integration/BaseEntity/HydrateSpec.cfc index a1535aa8..4b7d2a3f 100644 --- a/tests/specs/integration/BaseEntity/HydrateSpec.cfc +++ b/tests/specs/integration/BaseEntity/HydrateSpec.cfc @@ -20,6 +20,28 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.retrieveAttribute( "last_name" ) ).toBe( "Peterson" ); } ); + it( "ignores non-persistent struct properties when checking hydrated entities for changes", function() { + var memento = { + "cacheMetadata" : { + "source" : "cache", + "tags" : [ "one", "two" ] + }, + "id" : 4, + "username" : "elpete2", + "first_name" : "Another", + "last_name" : "Peterson" + }; + var user = getInstance( "User" ).hydrate( memento ); + var updatedCacheMetadata = { + "source" : "refreshed-cache", + "tags" : [ "three" ] + }; + user.setCacheMetadata( updatedCacheMetadata ); + + expect( user.getCacheMetadata() ).toBe( updatedCacheMetadata ); + expect( user.isDirty() ).toBeFalse(); + } ); + it( "can hydrate multiple entities at once from an array of structs", function() { var mementos = [ { diff --git a/tests/specs/integration/BaseEntity/IsDirtySpec.cfc b/tests/specs/integration/BaseEntity/IsDirtySpec.cfc index d976c894..b753c7e4 100644 --- a/tests/specs/integration/BaseEntity/IsDirtySpec.cfc +++ b/tests/specs/integration/BaseEntity/IsDirtySpec.cfc @@ -17,6 +17,39 @@ component extends="tests.resources.ModuleIntegrationSpec" { user.fill( { "last_name" : "Peterson" } ); expect( user.isDirty() ).toBeFalse(); } ); + + it( "can check whether a specific attribute alias or column is dirty", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + + expect( user.isDirty( "username" ) ).toBeFalse(); + expect( user.isDirty( "firstName" ) ).toBeFalse(); + expect( user.isDirty( "first_name" ) ).toBeFalse(); + + user.setUsername( "updated-username" ); + expect( user.isDirty( "username" ) ).toBeTrue(); + expect( user.isDirty( "firstName" ) ).toBeFalse(); + + user.setFirstName( "Updated" ); + expect( user.isDirty( "firstName" ) ).toBeTrue(); + expect( user.isDirty( "first_name" ) ).toBeTrue(); + + user.setUsername( "elpete" ); + expect( user.isDirty( "username" ) ).toBeFalse(); + expect( user.isDirty() ).toBeTrue(); + } ); + + it( "can test whether the entity or a specific attribute is clean", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + + expect( user.isClean() ).toBeTrue(); + expect( user.isClean( "username" ) ).toBeTrue(); + expect( user.isClean( "first_name" ) ).toBeTrue(); + + user.setUsername( "updated-username" ); + expect( user.isClean() ).toBeFalse(); + expect( user.isClean( "username" ) ).toBeFalse(); + expect( user.isClean( "firstName" ) ).toBeTrue(); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/MementoSpec.cfc b/tests/specs/integration/BaseEntity/MementoSpec.cfc index 4792cd8d..a747d758 100644 --- a/tests/specs/integration/BaseEntity/MementoSpec.cfc +++ b/tests/specs/integration/BaseEntity/MementoSpec.cfc @@ -41,13 +41,23 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); it( "returns retrieved relationships", function() { - var post = getInstance( "Post" ).with( "author" ).findOrFail( 1245 ); - var memento = post.getMemento( includes = "author" ); - memento.createdDate = dateTimeFormat( memento.createdDate, "yyyy-mm-dd hh:nn:ss" ); - memento.modifiedDate = dateTimeFormat( memento.modifiedDate, "yyyy-mm-dd hh:nn:ss" ); - memento.publishedDate = dateTimeFormat( memento.publishedDate, "yyyy-mm-dd hh:nn:ss" ); - memento.author.createdDate = dateTimeFormat( memento.author.createdDate, "yyyy-mm-dd hh:nn:ss" ); - memento.author.modifiedDate = dateTimeFormat( memento.author.modifiedDate, "yyyy-mm-dd hh:nn:ss" ); + var post = getInstance( "Post" ).with( "author" ).findOrFail( 1245 ); + var memento = post.getMemento( includes = "author" ); + memento.createdDate = formatTestTimestamp( memento.createdDate ); + memento.modifiedDate = formatTestTimestamp( memento.modifiedDate ); + memento.publishedDate = formatTestTimestamp( memento.publishedDate ); + memento.author.createdDate = formatTestTimestamp( memento.author.createdDate ); + memento.author.modifiedDate = formatTestTimestamp( memento.author.modifiedDate ); + memento.post_pk = memento.post_pk & ""; + memento.user_id = memento.user_id & ""; + memento.author.id = memento.author.id & ""; + memento.author.favoritePost_id = memento.author.favoritePost_id & ""; + if ( isNull( memento.author.email ) ) { + memento.author.email = ""; + } + if ( isNull( memento.author.address.streetTwo ) ) { + memento.author.address.streetTwo = ""; + } expect( memento ).toBe( { "post_pk" : "1245", "body" : "My awesome post body", diff --git a/tests/specs/integration/BaseEntity/MetadataSpec.cfc b/tests/specs/integration/BaseEntity/MetadataSpec.cfc index d5777e17..9ed128bd 100644 --- a/tests/specs/integration/BaseEntity/MetadataSpec.cfc +++ b/tests/specs/integration/BaseEntity/MetadataSpec.cfc @@ -114,6 +114,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { }, skip = !server.keyExists( "boxlang" ) ); + + it( + title = "does not persist uninitialized BoxLang accessor values", + body = function() { + var user = getInstance( "User" ); + expect( user.retrieveAttributesData() ).notToHaveKey( "created_date" ); + }, + skip = !server.keyExists( "boxlang" ) + ); } ); } ); } diff --git a/tests/specs/integration/BaseEntity/NullValuesSpec.cfc b/tests/specs/integration/BaseEntity/NullValuesSpec.cfc index d894fa79..0d6a9288 100644 --- a/tests/specs/integration/BaseEntity/NullValuesSpec.cfc +++ b/tests/specs/integration/BaseEntity/NullValuesSpec.cfc @@ -2,10 +2,10 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Null Values Spec", function() { - it( "returns null values as a string by default", function() { + it( "returns database nulls as null attributes by default", function() { var user = getInstance( "User" ).findOrFail( 3 ); - expect( user.getCountryId() ).toBe( "" ); - expect( user.getMemento().countryId ).toBe( "" ); + expect( user.isNullAttribute( "countryId" ) ).toBeTrue(); + expect( user.isNullValue( "countryId", user.getMemento().countryId ) ).toBeTrue(); } ); it( "saves a column containing an empty string as null in the database by default", function() { diff --git a/tests/specs/integration/BaseEntity/QuerySpec.cfc b/tests/specs/integration/BaseEntity/QuerySpec.cfc index 0592f37d..38a2e938 100644 --- a/tests/specs/integration/BaseEntity/QuerySpec.cfc +++ b/tests/specs/integration/BaseEntity/QuerySpec.cfc @@ -41,6 +41,102 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users[ 2 ].getId() ).toBe( 4 ); expect( users[ 2 ].getUsername() ).toBe( "elpete2" ); } ); + + it( "can pass simple values directly to QuickQB update", function() { + var sql = getInstance( "User" ) + .newQuery() + .getQB() + .where( "id", 1 ) + .update( values = { "username" : "someValue" }, toSql = true ); + + expect( sql ).toInclude( "UPDATE `users` SET `username` = ?" ); + } ); + + it( "can upsert records through the entity query API", function() { + var result = getInstance( "User" ).upsert( + values = [ + { + "id" : 1, + "username" : "elpete", + "firstName" : "Updated", + "lastName" : "Peterson" + }, + { + "id" : 99, + "username" : "new-user", + "firstName" : "New", + "lastName" : "User" + } + ], + target = "id", + update = [ "firstName" ], + matchNulls = false + ); + + expect( result ).toBeStruct(); + expect( arrayFindNoCase( structKeyArray( result ), "query" ) ).toBeGT( 0 ); + expect( arrayFindNoCase( structKeyArray( result ), "result" ) ).toBeGT( 0 ); + expect( getInstance( "User" ).findOrFail( 1 ).getFirstName() ).toBe( "Updated" ); + expect( getInstance( "User" ).findOrFail( 99 ).getFirstName() ).toBe( "New" ); + } ); + + it( "guards read-only entities and attributes when upserting", function() { + expect( function() { + getInstance( "Referral" ).upsert( + values = [ { "id" : 1, "type" : "external" } ], + target = "id", + update = [ "type" ], + toSql = true + ); + } ).toThrow( "QuickReadOnlyException" ); + + expect( function() { + getInstance( "Link" ).upsert( + values = [ + { + "link_id" : 1, + "url" : "https://example.com", + "createdDate" : now() + } + ], + target = "link_id", + update = [ "url" ], + toSql = true + ); + } ).toThrow( "QuickReadOnlyException" ); + + expect( function() { + getInstance( "Link" ).upsert( + values = [ + { + "link_id" : 1, + "url" : "https://example.com" + } + ], + target = "link_id", + update = { "createdDate" : now() }, + toSql = true + ); + } ).toThrow( "QuickReadOnlyException" ); + } ); + + it( "can force an upsert of read-only attributes like updateAll", function() { + var sql = getInstance( "Link" ).upsert( + values = [ + { + "link_id" : 1, + "url" : "https://example.com", + "createdDate" : now() + } + ], + target = "link_id", + update = { "createdDate" : now() }, + toSql = true, + force = true + ); + + expect( sql ).toInclude( "`created_date`" ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/ReadOnlyEntitySpec.cfc b/tests/specs/integration/BaseEntity/ReadOnlyEntitySpec.cfc index dfce8b77..9bbef248 100644 --- a/tests/specs/integration/BaseEntity/ReadOnlyEntitySpec.cfc +++ b/tests/specs/integration/BaseEntity/ReadOnlyEntitySpec.cfc @@ -12,7 +12,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "prevents create from being performed on new instances", function() { expect( function() { - getInstance( "Referral" ).create( { type : "internal" } ); + getInstance( "Referral" ).create( { type : "internal" } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); @@ -27,13 +27,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "prevents updates from being performed on existing instances", function() { var referral = getInstance( "Referral" ).findOrFail( 1 ); expect( function() { - referral.update( { type : "external" } ); + referral.update( { type : "external" } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); it( "prevents mass updates from being performed on existing instances", function() { expect( function() { - getInstance( "Referral" ).updateAll( { type : "external" } ); + getInstance( "Referral" ).updateAll( { type : "external" } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); diff --git a/tests/specs/integration/BaseEntity/ReadOnlyPropertySpec.cfc b/tests/specs/integration/BaseEntity/ReadOnlyPropertySpec.cfc index 2ba88a3c..9243b76f 100644 --- a/tests/specs/integration/BaseEntity/ReadOnlyPropertySpec.cfc +++ b/tests/specs/integration/BaseEntity/ReadOnlyPropertySpec.cfc @@ -1,11 +1,23 @@ component extends="tests.resources.ModuleIntegrationSpec" { + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "ReadOnlyPropertySpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "ReadOnlyPropertySpec" ); + super.afterAll(); + } + function run() { describe( "Read-only properties", function() { it( "prevents read-only properties from being saved", function() { var link = getInstance( "Link" ).findOrFail( 1 ); expect( link.getUrl() ).toBe( "http://example.com/some-link" ); - expect( dateTimeFormat( link.getCreatedDate(), "YYYY-MM-dd HH:nn:ss" ) ).toBe( "2017-07-28 02:07:00" ); + expect( formatTestTimestamp( link.getCreatedDate() ) ).toBe( "2017-07-28 02:07:00" ); link.setUrl( "https://example.com/" ) .setCreatedDate( now() ) @@ -14,12 +26,12 @@ component extends="tests.resources.ModuleIntegrationSpec" { link.refresh(); expect( link.getUrl() ).toBe( "https://example.com/" ); - expect( dateTimeFormat( link.getCreatedDate(), "YYYY-MM-dd HH:nn:ss" ) ).toBe( "2017-07-28 02:07:00" ); + expect( formatTestTimestamp( link.getCreatedDate() ) ).toBe( "2017-07-28 02:07:00" ); } ); it( "prevents create from setting read-only properties", function() { expect( function() { - getInstance( "Link" ).create( { createdDate : now() } ); + getInstance( "Link" ).create( { createdDate : now() } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); @@ -33,23 +45,55 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "prevents fill from being called containing a read-only property", function() { var link = getInstance( "Link" ).findOrFail( 1 ); expect( function() { - link.fill( { createdDate : now() } ); + link.fill( { createdDate : now() } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); it( "prevents updates from being performed on a read-only property", function() { var link = getInstance( "Link" ).findOrFail( 1 ); expect( function() { - link.update( { createdDate : now() } ); + link.update( { createdDate : now() } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); it( "prevents mass updates from being performed on read-only properties", function() { expect( function() { - getInstance( "Link" ).updateAll( { createdDate : now() } ); + getInstance( "Link" ).updateAll( { createdDate : now() } ); } ).toThrow( type = "QuickReadOnlyException" ); } ); + + it( "excludes read-only properties from generated update statements", function() { + var link = getInstance( "Link" ).findOrFail( 1 ); + structDelete( request, "readOnlyPropertySpecPreQBExecute" ); + + link.setUrl( "https://example.com/updated" ).save(); + + expect( request.readOnlyPropertySpecPreQBExecute ).toHaveLength( 1 ); + expect( request.readOnlyPropertySpecPreQBExecute[ 1 ].sql ).notToInclude( "created_date" ); + } ); + + it( "excludes read-only properties from generated insert statements", function() { + var link = getInstance( "Link" ); + link.setUrl( "https://example.com/new" ).forceAssignAttribute( "createdDate", now() ); + structDelete( request, "readOnlyPropertySpecPreQBExecute" ); + + link.save(); + + expect( request.readOnlyPropertySpecPreQBExecute ).toHaveLength( 1 ); + expect( request.readOnlyPropertySpecPreQBExecute[ 1 ].sql ).notToInclude( "created_date" ); + } ); } ); } + function preQBExecute( + event, + interceptData, + buffer, + rc, + prc + ) { + param request.readOnlyPropertySpecPreQBExecute = []; + request.readOnlyPropertySpecPreQBExecute.append( duplicate( arguments.interceptData ) ); + } + } diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc index e1801fde..c760872d 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToManySpec.cfc @@ -19,6 +19,201 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toBeArray(); expect( posts ).toHaveLength( 2 ); } ); + + it( "hydrates declared pivot columns on a pivot model", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + var relationship = post.tagsWithPivot(); + var tag = relationship.get()[ 1 ]; + var pivot = tag.getPivot(); + + expect( pivot ).toBeInstanceOf( "quick.models.Relationships.Pivot" ); + expect( pivot.isLoaded() ).toBeTrue(); + expect( pivot.getCustom_post_pk() ).toBe( post.getPost_pk() ); + expect( pivot.getTag_id() ).toBe( tag.getId() ); + expect( pivot.getContext() ).toBe( "primary" ); + expect( pivot.getActive() ).toBeTrue(); + expect( pivot.getPivotParent().getPost_pk() ).toBe( post.getPost_pk() ); + expect( pivot.getPivotRelated().getId() ).toBe( tag.getId() ); + expect( relationship.getForeignPivotKeys() ).toBe( [ "custom_post_pk" ] ); + expect( relationship.getRelatedPivotKeys() ).toBe( [ "tag_id" ] ); + expect( pivot.keyNames() ).toBe( [ "custom_post_pk", "tag_id" ] ); + expect( pivot.retrieveAttributeNames( withVirtualAttributes = true ) ).toInclude( "custom_post_pk" ); + expect( pivot.get_Attributes().custom_post_pk.exclude ).toBeFalse(); + expect( pivot.memento.defaultIncludes ).toInclude( "custom_post_pk" ); + var memento = pivot.getMemento(); + expect( memento.custom_post_pk ).toBe( 1245 ); + expect( memento.tag_id ).toBe( 1 ); + expect( memento.context ).toBe( "primary" ); + expect( function() { + pivot.setContext( "not persisted" ).save(); + } ).toThrow( "QuickReadOnlyException" ); + } ); + + it( "hydrates the correct pivot for every eagerly loaded parent", function() { + var posts = getInstance( "Post" ) + .with( "tagsWithPivot" ) + .whereIn( "post_pk", [ 1245, 523526 ] ) + .orderBy( "post_pk" ) + .get(); + + var firstPostTag = posts[ 1 ].getTagsWithPivot()[ 1 ]; + var secondPostTag = posts[ 2 ].getTagsWithPivot()[ 1 ]; + + expect( firstPostTag.getPivot().getCustom_post_pk() ).toBe( posts[ 1 ].getPost_pk() ); + expect( secondPostTag.getPivot().getCustom_post_pk() ).toBe( posts[ 2 ].getPost_pk() ); + expect( firstPostTag.getPivot().getContext() ).notToBe( secondPostTag.getPivot().getContext() ); + } ); + + it( "can customize the pivot accessor", function() { + var tag = getInstance( "Post" ).findOrFail( 1245 ).getTagsAsSubscriptions()[ 1 ]; + + expect( tag.isRelationshipLoaded( "subscription" ) ).toBeTrue(); + expect( tag.getSubscription().getContext() ).toBe( "primary" ); + } ); + + it( "can hydrate a custom pivot model with casts and behavior", function() { + var pivot = getInstance( "Post" ).findOrFail( 1245 ).getTagsWithCustomPivot()[ 1 ].getPivot(); + + expect( pivot ).toBeInstanceOf( "app.models.PostTag" ); + expect( pivot.getActive() ).toBeBoolean().toBeTrue(); + expect( pivot.describe() ).toBe( "primary:1" ); + + pivot.setContext( "saved through custom pivot" ).save(); + var refreshed = getInstance( "Post" ).findOrFail( 1245 ).getTagsWithCustomPivot()[ 1 ].getPivot(); + expect( refreshed.getContext() ).toBe( "saved through custom pivot" ); + } ); + + it( "can constrain and order by pivot columns", function() { + var tags = getInstance( "Post" ).findOrFail( 523526 ).getActiveTags(); + + expect( tags ).toHaveLength( 2 ); + expect( tags[ 1 ].getPivot().getContext() ).toBe( "published" ); + expect( tags[ 2 ].getPivot().getContext() ).toBe( "review" ); + } ); + + it( "supports the pivot query helper family", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + + expect( + post.tagsWithPivot() + .wherePivotIn( "tag_id", [ 1 ] ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotNotIn( "tag_id", [ 1 ] ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotBetween( "tag_id", 1, 2 ) + .get() + ).toHaveLength( 2 ); + expect( + post.tagsWithPivot() + .wherePivotNotBetween( "tag_id", 2, 2 ) + .get() + ).toHaveLength( 1 ); + expect( + post.tagsWithPivot() + .wherePivotNull( "created_date" ) + .get() + ).toHaveLength( 2 ); + expect( + post.tagsWithPivot() + .wherePivotNotNull( "context" ) + .get() + ).toHaveLength( 2 ); + } ); + + it( "writes and updates additional pivot attributes", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + + post.tagsWithPivot().attach( 3, { "context" : "new", "active" : true } ); + var attached = post.tagsWithPivot().findOrFail( 3 ); + expect( attached.getPivot().getContext() ).toBe( "new" ); + expect( attached.getPivot().getActive() ).toBeTrue(); + + var updateAttributes = { + "custom_post_pk" : 321, + "tag_id" : 2, + "context" : "updated", + "active" : false + }; + post.tagsWithPivot().updateExistingPivot( 3, updateAttributes ); + var updated = post.tagsWithPivot().findOrFail( 3 ); + expect( updated.getPivot().getContext() ).toBe( "updated" ); + expect( updated.getPivot().getActive() ).toBeFalse(); + expect( updated.getPivot().getCustom_post_pk() ).toBe( 1245 ); + expect( updated.getPivot().getTag_id() ).toBe( 3 ); + expect( updateAttributes.custom_post_pk ).toBe( 321 ); + expect( updateAttributes.tag_id ).toBe( 2 ); + } ); + + it( "applies configured pivot values to constraints and writes", function() { + var post = getInstance( "Post" ).findOrFail( 321 ); + + post.defaultActiveTags().attach( 3, { "context" : "defaulted" } ); + var tag = post.defaultActiveTags().findOrFail( 3 ); + + expect( tag.getPivot().getActive() ).toBeTrue(); + expect( tag.getPivot().getContext() ).toBe( "defaulted" ); + } ); + + it( "keeps configured and supplied pivot values isolated", function() { + var post = getInstance( "Post" ).findOrFail( 321 ); + var relationship = post.defaultActiveTags(); + var suppliedAttributes = { + "context" : "overridden", + "active" : false + }; + + relationship.attach( 3, suppliedAttributes ); + + expect( relationship.getPivotValues() ).toHaveKey( "active" ); + expect( relationship.getPivotValues().active ).toBeTrue(); + expect( suppliedAttributes.context ).toBe( "overridden" ); + expect( suppliedAttributes.active ).toBeFalse(); + + var attached = post.tagsWithPivot().findOrFail( 3 ); + expect( attached.getPivot().getContext() ).toBe( "overridden" ); + expect( attached.getPivot().getActive() ).toBeFalse(); + } ); + + it( "maintains configured pivot timestamps", function() { + var post = getInstance( "Post" ).findOrFail( 321 ); + var pivotAttributes = {}; + + post.timestampedTags().attach( 1, pivotAttributes ); + var pivot = post + .timestampedTags() + .findOrFail( 1 ) + .getPivot(); + + expect( pivot.getCreated_date() ).notToBeNull(); + expect( pivot.getModified_date() ).notToBeNull(); + expect( pivotAttributes ).toBeEmpty(); + } ); + + it( "creates and attaches a related entity", function() { + var post = getInstance( "Post" ).findOrFail( 1245 ); + var tag = post + .tagsWithPivot() + .create( + { "name" : "testing" }, + { + "context" : "created through relationship", + "active" : true + } + ); + + expect( tag ).toBeInstanceOf( "Tag" ); + expect( tag.isLoaded() ).toBeTrue(); + + var attached = post.tagsWithPivot().findOrFail( tag.getId() ); + expect( attached.getPivot().getContext() ).toBe( "created through relationship" ); + expect( attached.getPivot().getActive() ).toBeTrue(); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToSpec.cfc index 84a2aea1..56e1123e 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToSpec.cfc @@ -83,6 +83,23 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.posts().count() ).toBe( 3 ); } ); + it( "assigns a newly created related entity to the belongsTo relationship", function() { + var post = getInstance( "Post" ).findOrFail( 7777 ); + var newAuthor = post + .author() + .create( { + "username" : "relationship-author", + "first_name" : "Relationship", + "last_name" : "Author", + "password" : hash( "password" ) + } ); + + expect( post.isRelationshipLoaded( "author" ) ).toBeTrue(); + expect( post.getAuthor().isSameAs( newAuthor ) ).toBeTrue(); + expect( post.getUser_Id() ).toBe( newAuthor.getId() ); + expect( post.fresh().getAuthor() ).toBeNull(); + } ); + it( "can set the associated relationship by calling a relationship setter", function() { var user = getInstance( "User" ).find( 1 ); var newPost = getInstance( "Post" ).create( { @@ -142,7 +159,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { post.author() .dissociate() .save(); - expect( post.retrieveAttribute( "user_id" ) ).toBe( "" ); + expect( post.isNullAttribute( "user_id" ) ).toBeTrue(); expect( getInstance( "User" ) .find( userId ) diff --git a/tests/specs/integration/BaseEntity/Relationships/BelongsToThroughSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/BelongsToThroughSpec.cfc index f55ed920..11090bcc 100644 --- a/tests/specs/integration/BaseEntity/Relationships/BelongsToThroughSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/BelongsToThroughSpec.cfc @@ -2,12 +2,27 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Belongs To Through Spec", function() { + it( "can eager load the owning entity through other relationships", function() { + var post = getInstance( "Post" ).with( "country" ).findOrFail( 523526 ); + + expect( post.getCountry() ).notToBeNull(); + expect( post.getCountry().getId() ).toBe( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + } ); + it( "can get the owning entity through other relationships", function() { var post = getInstance( "Post" ).findOrFail( 523526 ); expect( post.getCountry() ).notToBeNull(); expect( post.getCountry() ).notToBeArray(); expect( post.getCountry().getId() ).toBe( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); } ); + + it( "can eager load the owning entity through other relationships", function() { + var post = getInstance( "Post" ).with( "country" ).findOrFail( 523526 ); + + expect( post.getCountry() ).notToBeNull(); + expect( post.getCountry() ).notToBeArray(); + expect( post.getCountry().getId() ).toBe( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc index f9024786..4d45318a 100644 --- a/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/EagerLoadingSpec.cfc @@ -34,6 +34,33 @@ component extends="tests.resources.ModuleIntegrationSpec" { } } ); + it( "preserves numeric binding types when eager loading a belongs to relationship", function() { + getInstance( "Post" ).with( "author" ).get(); + + expect( variables.queries ).toHaveLength( 2 ); + var bindingTypes = extractBindingTypes( variables.queries[ 2 ] ); + + expect( bindingTypes ).notToBeEmpty(); + for ( var bindingType in bindingTypes ) { + expect( bindingType ).notToInclude( "varchar" ); + expect( bindingType ).toInclude( "integer" ); + } + } ); + + it( "keeps belongs to eager keys that differ only by case", function() { + var post = getInstance( "Post" ).firstOrFail(); + var relationship = post.author(); + var keys = relationship.getEagerEntityKeys( + [ + { "user_id" : "ABC" }, + { "user_id" : "abc" } + ], + post + ); + + expect( keys ).toHaveLength( 2 ); + } ); + it( "can eager load a belongs to relationship using a composite key", function() { var compositeChildren = getInstance( "CompositeChild" ).with( "parent" ).get(); expect( compositeChildren ).toBeArray(); @@ -243,6 +270,34 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); } ); + it( "preserves numeric binding types when eager loading a belongs to many relationship", function() { + getInstance( "Post" ).with( "tags" ).get(); + + expect( variables.queries ).toHaveLength( 2 ); + var bindingTypes = extractBindingTypes( variables.queries[ 2 ] ); + + expect( bindingTypes ).notToBeEmpty(); + for ( var bindingType in bindingTypes ) { + expect( bindingType ).notToInclude( "varchar" ); + expect( bindingType ).toInclude( "integer" ); + } + } ); + + it( "keeps relationship eager keys that differ only by case", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + var relationship = user.externalThings(); + var keys = relationship.getKeys( + [ + { "externalID" : "ABC" }, + { "externalID" : "abc" } + ], + [ "externalID" ], + user + ); + + expect( keys ).toHaveLength( 2 ); + } ); + it( "can eager load a has many through relationship", function() { var countries = getInstance( "Country" ).with( "posts" ).get(); expect( countries ).toBeArray(); @@ -260,6 +315,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); } ); + it( "preserves constraints from intermediate has many relationships", function() { + var country = getInstance( "Country" ) + .with( "publishedPostTags" ) + .findOrFail( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + + expect( country.getPublishedPostTags() ).toHaveLength( 2 ); + expect( country.getPublishedPostTags()[ 1 ].getName() ).toBe( "programming" ); + expect( country.getPublishedPostTags()[ 2 ].getName() ).toBe( "music" ); + expect( variables.queries ).toHaveLength( 2, "Only two queries should have been executed." ); + } ); + it( "can eager load a long has many through relationship", function() { var countries = getInstance( "Country" ).with( "comments" ).get(); expect( countries ).toBeArray(); @@ -526,7 +592,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ).notToThrow(); } ); - it( "can provides default models if they are defined for the relationship", () => { + it( "can provide default models if they are defined for the relationship", () => { var categories = getInstance( "Category" ) .with( "parent" ) .orderByAsc( "id" ) @@ -543,6 +609,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( categories[ 2 ].getParent().getId() ).toBe( 1 ); } ); + it( "caches a default model for an unmatched eager loaded relationship", () => { + var category = getInstance( "Category" ).with( "parent" ).findOrFail( 1 ); + + expect( category.isRelationshipLoaded( "parent" ) ).toBeTrue(); + expect( function() { + return category.getParent().isLoaded(); + } ).notToThrow(); + expect( category.getParent() ).toBeInstanceOf( "Category" ); + expect( category.getParent().isLoaded() ).toBeFalse(); + } ); + describe( "handling lazy loading", () => { it( "can completely disable lazy loading", () => { var posts = getInstance( "Post" ).preventLazyLoading().get(); @@ -608,6 +685,41 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); } } ); + + it( "can disable an automatically eager loaded relationship", () => { + var posts = getInstance( "EagerLoadedPost" ) + .without( "comments" ) + .preventLazyLoading() + .get(); + + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); + expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); + expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); + } ); + + it( "does not clear eager loads when without is called without arguments", () => { + var posts = getInstance( "EagerLoadedPost" ) + .without() + .preventLazyLoading() + .get(); + + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeTrue(); + expect( variables.queries ).toHaveLength( 2 ); + } ); + + it( "can explicitly clear all eager loads", () => { + var posts = getInstance( "EagerLoadedPost" ) + .clearEagerLoads() + .preventLazyLoading() + .get(); + + expect( posts ).toHaveLength( 4 ); + expect( posts[ 1 ].isRelationshipLoaded( "comments" ) ).toBeFalse(); + expect( () => posts[ 1 ].getComments() ).toThrow( type = "QuickLazyLoadingException" ); + expect( variables.queries ).toHaveLength( 1, "Only the posts query should execute." ); + } ); } ); describe( "multiple nested eager loads", () => { @@ -747,4 +859,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { arrayAppend( variables.queries, interceptData ); } + private array function extractBindingTypes( required struct queryLogEntry ) { + return arguments.queryLogEntry.bindings + .filter( function( binding ) { + return isStruct( binding ) && ( binding.keyExists( "cfsqltype" ) || binding.keyExists( "sqltype" ) ); + } ) + .map( function( binding ) { + return lCase( binding.keyExists( "cfsqltype" ) ? binding[ "cfsqltype" ] : binding[ "sqltype" ] ); + } ); + } + } diff --git a/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc index 9afd63ab..b323dd5d 100644 --- a/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/HasManyDeepSpec.cfc @@ -147,7 +147,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "withCount via hasManyThrough and a mixture of non-composite and composite keys works", () => { var v = getInstance( "HasManyDeepKeyTest_A" ) - .withCount( "Cs as countOfCs" ) + .withCsCount() .where( "aID", "a1" ) .firstOrFail(); diff --git a/tests/specs/integration/BaseEntity/Relationships/HasManySpec.cfc b/tests/specs/integration/BaseEntity/Relationships/HasManySpec.cfc index b0e61399..a35a6832 100644 --- a/tests/specs/integration/BaseEntity/Relationships/HasManySpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/HasManySpec.cfc @@ -63,6 +63,34 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts[ 2 ].getAuthor().getId() ).toBe( user.getId() ); } ); + it( "updates an already-loaded relationship after saving many entities", function() { + var user = getInstance( "User" ).find( 1 ); + expect( user.getPosts() ).toHaveLength( 2 ); + + var savedPosts = user + .posts() + .saveMany( [ + getInstance( "Post" ).fill( { "body" : "A cached post" } ), + getInstance( "Post" ).fill( { "body" : "Another cached post" } ) + ] ); + + expect( user.getPosts() ).toHaveLength( 4 ); + expect( user.getPosts()[ 3 ].isSameAs( savedPosts[ 1 ] ) ).toBeTrue(); + expect( user.getPosts()[ 4 ].isSameAs( savedPosts[ 2 ] ) ).toBeTrue(); + } ); + + it( "clears an already-loaded relationship after deleting all related entities", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + expect( user.getPosts() ).toHaveLength( 2 ); + + var result = user.posts().deleteAll(); + + expect( result.result.recordCount ).toBe( 2 ); + expect( user.isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( user.getPosts() ).toBeArray().toBeEmpty(); + expect( user.fresh().getPosts() ).toBeArray().toBeEmpty(); + } ); + it( "can save many ids at a time", function() { var newPostA = getInstance( "Post" ); newPostA.setBody( "A new post by me!" ); @@ -96,7 +124,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( user.getPosts() ).toHaveLength( 2 ); var posts = user.setPosts( newPost ); - var posts = user.fresh().getPosts(); + var posts = user.getPosts(); expect( posts ).toBeArray(); expect( posts ).toHaveLength( 1 ); expect( posts[ 1 ].keyValues() ).toBe( newPost.keyValues() ); @@ -105,11 +133,54 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "can create new related entities directly", function() { var user = getInstance( "User" ).find( 1 ); expect( user.getPosts() ).toHaveLength( 2 ); - var newPost = user.posts().create( { "body" : "A new post created directly here!" } ); + var newPost = user.posts().create( { "body" : "A new post created directly here!" }, "author" ); expect( newPost.isLoaded() ).toBeTrue(); expect( newPost.retrieveAttribute( "user_id" ) ).toBe( user.getId() ); expect( newPost.getBody() ).toBe( "A new post created directly here!" ); - expect( user.fresh().getPosts() ).toHaveLength( 3 ); + expect( newPost.isRelationshipLoaded( "author" ) ).toBeTrue(); + expect( newPost.getAuthor().isSameAs( user ) ).toBeTrue(); + expect( user.getPosts() ).toHaveLength( 3 ); + expect( user.getPosts()[ 3 ].isSameAs( newPost ) ).toBeTrue(); + } ); + + it( "does not initialize an unloaded parent relationship when creating", function() { + var user = getInstance( "User" ).find( 1 ); + + user.posts().create( { "body" : "A new post without loading the collection" } ); + + expect( user.isRelationshipLoaded( "posts" ) ).toBeFalse(); + } ); + + it( "rejects an unknown inverse relationship when creating", function() { + var user = getInstance( "User" ).find( 1 ); + + expect( function() { + user.posts().create( { "body" : "This should not be saved" }, "missingRelationship" ); + } ).toThrow( "RelationshipNotFound" ); + } ); + + it( "can set non-persistent properties when creating related entities", function() { + var user = getInstance( "User" ).find( 1 ); + var newPost = user + .posts() + .create( { + "body" : "A post with transient lifecycle state", + "lifecycleEventVar" : "skip-events" + } ); + + expect( newPost.isLoaded() ).toBeTrue(); + expect( newPost.getLifecycleEventVar() ).toBe( "skip-events" ); + expect( newPost.fresh().getLifecycleEventVar() ).toBeNull(); + } ); + + it( "deletes related entities using the actual foreign key value", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + expect( user.posts().count() ).toBe( 2 ); + + user.posts().deleteAll(); + + expect( getInstance( "Post" ).where( "user_id", 1 ).count() ).toBe( 0 ); + expect( getInstance( "Post" ).where( "user_id", 4 ).count() ).toBeGT( 0 ); } ); it( "can first off of the relationship", function() { diff --git a/tests/specs/integration/BaseEntity/Relationships/HasOneThroughSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/HasOneThroughSpec.cfc index 216ea16d..8abea797 100644 --- a/tests/specs/integration/BaseEntity/Relationships/HasOneThroughSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/HasOneThroughSpec.cfc @@ -2,6 +2,15 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Has One Through Spec", function() { + it( "can eager load the related entity through another entity", function() { + var country = getInstance( "Country@something" ) + .with( "latestPost" ) + .findOrFail( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + + expect( country.getLatestPost() ).notToBeNull(); + expect( country.getLatestPost().getPost_Pk() ).toBe( 523526 ); + } ); + it( "can get the related entity through another entity", function() { var country = getInstance( "Country@something" ).find( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); expect( country.getLatestPost() ).notToBeNull(); @@ -9,6 +18,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( country.getLatestPost().getPost_Pk() ).toBe( 523526 ); expect( country.getLatestPost().getBody() ).toBe( "My second awesome post body" ); } ); + + it( "can start with a hasOne relationship", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + + expect( user.getFavoritePostAuthor() ).notToBeNull(); + expect( user.getFavoritePostAuthor().getId() ).toBe( user.getId() ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/PolymorphicBelongsToSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/PolymorphicBelongsToSpec.cfc index 46c938a0..795a2541 100644 --- a/tests/specs/integration/BaseEntity/Relationships/PolymorphicBelongsToSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/PolymorphicBelongsToSpec.cfc @@ -15,6 +15,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( commentC.getCommentable() ).toBeInstanceOf( "app.models.Video" ); expect( commentC.getCommentable().getTitle() ).toBe( "Cello Wars" ); } ); + + it( "can eager load polymorphic entities with different primary key names", function() { + var comments = getInstance( "Comment" ) + .with( "commentable" ) + .orderBy( "id" ) + .get(); + + expect( comments ).toHaveLength( 5 ); + expect( comments[ 1 ].getCommentable() ).toBeInstanceOf( "app.models.Post" ); + expect( comments[ 1 ].getCommentable().getPost_Pk() ).toBe( 1245 ); + expect( comments[ 3 ].getCommentable() ).toBeInstanceOf( "app.models.Video" ); + expect( comments[ 3 ].getCommentable().getId() ).toBe( 1245 ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc index b71997a9..b497a19c 100644 --- a/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/QueryingRelationshipsSpec.cfc @@ -2,6 +2,60 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Querying Relationships Spec", function() { + describe( "whereBelongsTo", function() { + it( "constrains a query using a named belongsTo relationship", function() { + var author = getInstance( "User" ).findOrFail( 1 ); + var posts = getInstance( "Post" ).whereBelongsTo( "author", author ).get(); + + expect( posts ).toHaveLength( 2 ); + expectAll( posts ).toSatisfy( function( post ) { + return post.getUser_Id() == author.getId(); + } ); + } ); + + it( "infers a conventional belongsTo relationship name", function() { + var country = getInstance( "Country" ).findOrFail( "02B84D66-0AA0-F7FB-1F71AFC954843861" ); + var users = getInstance( "User" ).whereBelongsTo( country ).get(); + + expect( users ).toHaveLength( 2 ); + expectAll( users ).toSatisfy( function( user ) { + return user.getCountry_Id() == country.getId(); + } ); + } ); + + it( "constrains a query to an array of related entities", function() { + var authors = getInstance( "User" ).whereIn( "id", [ 1, 4 ] ).get(); + var posts = getInstance( "Post" ).whereBelongsTo( "author", authors ).get(); + + expect( posts ).toHaveLength( 3 ); + expectAll( posts ).toSatisfy( function( post ) { + return arrayFind( [ 1, 4 ], post.getUser_Id() ) > 0; + } ); + } ); + + it( "supports composite belongsTo relationships", function() { + var parent = getInstance( "Composite" ) + .where( "a", 1 ) + .where( "b", 2 ) + .firstOrFail(); + var children = getInstance( "CompositeChild" ).whereBelongsTo( "parent", parent ).get(); + + expect( children ).toHaveLength( 1 ); + expect( children[ 1 ].getComposite_A() ).toBe( 1 ); + expect( children[ 1 ].getComposite_B() ).toBe( 2 ); + } ); + + it( "supports an OR combinator", function() { + var author = getInstance( "User" ).findOrFail( 1 ); + var posts = getInstance( "Post" ) + .where( "post_pk", 7777 ) + .orWhereBelongsTo( "author", author ) + .get(); + + expect( posts ).toHaveLength( 3 ); + } ); + } ); + describe( "has", function() { describe( "hasMany", function() { it( "can find only entities that have one or more related entities", function() { @@ -38,6 +92,23 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users ).toHaveLength( 1 ); } ); + it( "uses a whereIn subquery table inside whereHas", function() { + queryExecute( + "INSERT INTO family_parents ( parentID, familyID ) VALUES ( :parentID, :familyID )", + { "parentID" : 1, "familyID" : 2 }, + { "datasource" : "quick" } + ); + + var query = getInstance( "Registration" ).whereHas( "child", function( q ) { + q.whereIn( "familyID", function( subquery ) { + subquery.from( "family_parents" ).select( "familyID" ); + } ); + } ); + + expect( query.toSQL() ).toInclude( "SELECT `family_parents`.`familyID` FROM `family_parents`" ); + expect( query.get() ).toBeEmpty(); + } ); + it( "automatically groups where clauses with an OR combinator inside whereHas", function() { var sql = getInstance( "User" ) .whereHas( "posts", function( q ) { @@ -91,6 +162,34 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users ).toHaveLength( 1 ); } ); + it( "can compare a column on a nested relationship using whereHasValue", function() { + var posts = getInstance( "Post" ) + .whereHasValue( + "author.country", + "name", + "United States" + ) + .orderBy( "post_pk" ) + .get(); + + expect( posts ).toHaveLength( 2 ); + expect( posts[ 1 ].getPost_Pk() ).toBe( 1245 ); + expect( posts[ 2 ].getPost_Pk() ).toBe( 523526 ); + } ); + + it( "supports custom comparison operators in whereHasValue", function() { + var posts = getInstance( "Post" ) + .whereHasValue( + "author.country", + "name", + "<>", + "Argentina" + ) + .get(); + + expect( posts ).toHaveLength( 2 ); + } ); + it( "can nest multiple levels", function() { var countries = getInstance( "Country" ) .whereHas( "users.posts.comments", function( q ) { @@ -194,6 +293,14 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( posts ).toBeArray(); expect( posts ).notToBeEmpty(); } ); + + it( "can traverse two belongsToMany relationships before a hasMany relationship", function() { + var posts = getInstance( "Post" ).has( "tags.posts.comments" ).get(); + + expect( posts ).toBeArray(); + expect( posts ).toHaveLength( 3 ); + } ); + it( "can join after populating a cloned query builder", function() { var posts = getInstance( "Post" ).newQuery(); posts.populateQuery( posts.getQB().clone() ); @@ -213,6 +320,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( countries ).toHaveLength( 2 ); } ); + it( "applies the same constraints as the expanded relationship path", function() { + var throughCountries = getInstance( "Country" ) + .whereHas( "posts", ( q ) => q.where( "post_pk", 321 ) ) + .get(); + var expandedCountries = getInstance( "Country" ) + .whereHas( "users.posts", ( q ) => q.where( "post_pk", 321 ) ) + .get(); + + expect( throughCountries ).toHaveLength( 1 ); + expect( throughCountries[ 1 ].getName() ).toBe( "Argentina" ); + expect( throughCountries[ 1 ].getId() ).toBe( expandedCountries[ 1 ].getId() ); + } ); + it( "can find only entities that have a related hasManyThrough entity through multiple levels", function() { var countries = getInstance( "Country" ).has( "comments" ).get(); expect( countries ).toBeArray(); @@ -224,6 +344,21 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( countries ).toBeArray(); expect( countries ).toHaveLength( 2 ); } ); + + it( "can eager load and check three nested hasManyThrough relationships", function() { + var query = getInstance( "Country" ) + .with( "permissions.usersThroughRoles.commentsThroughPosts" ) + .has( "permissions.usersThroughRoles.commentsThroughPosts" ); + + expect( query.toSQL() ).toInclude( "EXISTS" ); + + var countries = getInstance( "Country" ) + .has( "permissions.usersThroughRoles.commentsThroughPosts" ) + .get(); + + expect( countries ).toBeArray(); + expect( countries ).toHaveLength( 2 ); + } ); } ); describe( "hasOne", function() { diff --git a/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc index 78897ba0..12e3691e 100644 --- a/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/RelationshipLoadingSpec.cfc @@ -18,6 +18,57 @@ component extends="tests.resources.ModuleIntegrationSpec" { variables.queries = []; } ); + it( "returns empty relationship values for new entities", function() { + var post = getInstance( "Post" ); + var user = getInstance( "User" ); + + expect( post.getAuthor() ).toBeNull(); + expect( user.getLatestPost() ).toBeNull(); + expect( user.getPosts() ).toBeArray().toBeEmpty(); + expect( post.getTags() ).toBeArray().toBeEmpty(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "includes empty relationships in mementos for new entities", function() { + var memento = getInstance( "User" ).getMemento( includes = [ "latestPost", "posts" ] ); + + expect( memento.latestPost ).toBe( "" ); + expect( memento.posts ).toBeArray().toBeEmpty(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "retrieves and caches the relationship type default without querying", function() { + var user = getInstance( "User" ); + var post = getInstance( "Post" ); + + expect( user.retrieveRelationship( "posts" ) ).toBeArray().toBeEmpty(); + expect( post.retrieveRelationship( "author" ) ).toBeNull(); + expect( post.retrieveRelationship( "authorWithEmptyDefault" ) ).toBeInstanceOf( "User" ); + expect( user.isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( post.isRelationshipLoaded( "author" ) ).toBeTrue(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "accepts a default relationship value", function() { + var user = getInstance( "User" ); + var seededPost = getInstance( "Post" ).fill( { "body" : "seeded" } ); + + var posts = user.retrieveRelationship( "posts", [ seededPost ] ); + + expect( posts ).toHaveLength( 1 ); + expect( posts[ 1 ].getBody() ).toBe( "seeded" ); + expect( user.isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( variables.queries ).toBeEmpty(); + } ); + + it( "throws when retrieving an unknown relationship", function() { + var post = getInstance( "Post" ); + + expect( function() { + post.retrieveRelationship( "missingRelationship" ); + } ).toThrow( "RelationshipNotFound" ); + } ); + describe( "Eager Loading Spec", function() { it( "can load a relationship for an entity", function() { var elpete = getInstance( "User" ).where( "username", "elpete" ).firstOrFail(); @@ -78,14 +129,42 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); } ); + it( "does not query a hasMany relationship when any composite local key is null", function() { + var user = getInstance( "User" ).findOrFail( 2 ); + + expect( variables.queries ).toHaveLength( 1 ); + expect( user.getFavoritePostsComposite() ).toBeArray().toBeEmpty(); + expect( variables.queries ).toHaveLength( 1 ); + } ); + + it( "queries a hasMany relationship when all composite local keys have values", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + + var favoritePosts = user.getFavoritePostsComposite(); + + expect( favoritePosts ).toHaveLength( 1 ); + expect( favoritePosts[ 1 ].getPost_Pk() ).toBe( 1245 ); + expect( variables.queries ).toHaveLength( 2 ); + } ); + it( "gets a new instance of an entity when calling fill", () => { - var elpete = getInstance( "User" ).findOrFail( 1 ); - var newPost = elpete.posts().fill( { "body" : "test body" } ); + var elpete = getInstance( "User" ).findOrFail( 1 ); + var relationship = elpete.posts(); + var newPost = relationship.fill( { "body" : "test body" } ); + var anotherPost = relationship.fill( { "body" : "another body" } ); expect( newPost ).notToBeNull(); expect( newPost ).toBeInstanceOf( "Post" ); expect( newPost.isLoaded() ).toBeFalse(); expect( newPost.getBody() ).toBe( "test body" ); expect( newPost.getUser_Id() ).toBe( 1 ); + expect( anotherPost ).toBeInstanceOf( "Post" ); + expect( anotherPost.isLoaded() ).toBeFalse(); + expect( anotherPost.getBody() ).toBe( "another body" ); + expect( anotherPost.getUser_Id() ).toBe( 1 ); + expect( variables.queries ).toHaveLength( + 1, + "Filling and associating the new post must not persist it." + ); } ); it( "can call fetch methods on the relationship builder", () => { diff --git a/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc b/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc index d4406113..95c634a9 100644 --- a/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc +++ b/tests/specs/integration/BaseEntity/Relationships/WithDefaultSpec.cfc @@ -2,12 +2,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "WithDefault Spec", function() { - it( "will throw an exception when retrieving a relation on an unloaded entity", function() { - var post = getInstance( "Post" ); + it( "returns a configured default for a relation on an unloaded entity", function() { + var post = getInstance( "Post" ); + var author = post.getAuthorWithEmptyDefault(); - expect( function() { - post.getAuthor(); - } ).toThrow( message = "Retrieving an unloaded entity should throw an exception" ); + expect( author ).toBeInstanceOf( "User" ); + expect( author.isLoaded() ).toBeFalse( "A default model is not loaded" ); + expect( author.retrieveAttributesData() ).toBeEmpty(); } ); it( "can load a entity and return a default entity if there is no owning entity", function() { @@ -28,6 +29,22 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( author.isLoaded() ).toBeFalse( "A default model is not loaded" ); expect( author.retrieveAttributesData() ).toBeEmpty(); } ); + + it( "returns the default entity as a memento when requested from the relationship", function() { + var user = getInstance( "User" ).findOrFail( 2 ); + var expectedMemento = user + .latestPostWithEmptyDefault() + .get() + .getMemento(); + var postMemento = user + .latestPostWithEmptyDefault() + .asMemento() + .get(); + + expect( postMemento ).toBeStruct(); + expect( postMemento ).notToBeComponent(); + expect( postMemento ).toBe( expectedMemento ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/SaveSpec.cfc b/tests/specs/integration/BaseEntity/SaveSpec.cfc index 61a9d2ef..a78063c6 100644 --- a/tests/specs/integration/BaseEntity/SaveSpec.cfc +++ b/tests/specs/integration/BaseEntity/SaveSpec.cfc @@ -56,6 +56,184 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( newUser.retrieveAttributesData() ).toHaveKey( "id" ); } ); + it( "refreshes database-generated attributes after inserts with one fallback read", function() { + structDelete( request, "saveSpecPreQBExecute" ); + structDelete( request, "databaseGeneratedUserPostLoadCount" ); + structDelete( request, "databaseGeneratedUserPostInsertCreatedDate" ); + + var newUser = getInstance( "DatabaseGeneratedUser" ) + .setUsername( "database-timestamp-user" ) + .setFirstName( "Database" ) + .setLastName( "Timestamp" ) + .save( { "timeout" : 30 } ); + + expect( newUser.getCreatedDate() ).notToBe( "" ); + expect( newUser.getCreatedDate() ).toBeDate(); + expect( newUser.getType() ).toBe( "LIMITED" ); + expect( newUser.isDirty( "createdDate" ) ).toBeFalse(); + expect( request.saveSpecPreQBExecute ).toHaveLength( 2 ); + expect( request.saveSpecPreQBExecute[ 1 ].options.timeout ).toBe( 30 ); + expect( request.saveSpecPreQBExecute[ 2 ].options.timeout ).toBe( 30 ); + expect( request.databaseGeneratedUserPostLoadCount ).toBe( 1 ); + expect( request.databaseGeneratedUserPostInsertCreatedDate ).toBe( newUser.getCreatedDate() ); + } ); + + it( "refreshes database-generated attributes after updates with one fallback read", function() { + var existingUser = getInstance( "DatabaseGeneratedUser" ).findOrFail( 1 ); + var originalCreatedDate = existingUser.getCreatedDate(); + + structDelete( request, "saveSpecPreQBExecute" ); + structDelete( request, "databaseGeneratedUserPostLoadCount" ); + structDelete( request, "databaseGeneratedUserPostUpdateCreatedDate" ); + + existingUser + .setCreatedDate( dateAdd( "d", 1, originalCreatedDate ) ) + .setType( "POISONED" ) + .setFirstName( "Updated" ) + .save(); + + expect( dateCompare( existingUser.getCreatedDate(), originalCreatedDate ) ).toBe( 0 ); + expect( existingUser.getType() ).toBe( "ADMIN" ); + expect( existingUser.isDirty( "createdDate" ) ).toBeFalse(); + expect( request.saveSpecPreQBExecute ).toHaveLength( 2 ); + expect( request.databaseGeneratedUserPostLoadCount ).toBe( 1 ); + expect( request.databaseGeneratedUserPostUpdateCreatedDate ).toBe( existingUser.getCreatedDate() ); + } ); + + it( "can disable the refresh-on-save fallback read for one save", function() { + structDelete( request, "saveSpecPreQBExecute" ); + + var newUser = getInstance( "DatabaseGeneratedUser" ) + .setUsername( "database-timestamp-without-fallback" ) + .setFirstName( "Database" ) + .setLastName( "No Fallback" ) + .save( refreshOnSaveFallback = false ); + + expect( request.saveSpecPreQBExecute ).toHaveLength( 1 ); + expect( newUser.retrieveAttributesData() ).notToHaveKey( "created_date" ); + } ); + + it( "uses the injected global refresh-on-save fallback setting", function() { + structDelete( request, "saveSpecPreQBExecute" ); + var newUser = getInstance( "DatabaseGeneratedUser" ); + expect( newUser.get_refreshOnSaveFallback() ).toBeTrue(); + + newUser + .set_refreshOnSaveFallback( false ) + .setUsername( "database-timestamp-global-without-fallback" ) + .setFirstName( "Database" ) + .setLastName( "Global No Fallback" ) + .save(); + + expect( request.saveSpecPreQBExecute ).toHaveLength( 1 ); + expect( newUser.retrieveAttributesData() ).notToHaveKey( "created_date" ); + } ); + + it( "uses native returning support without replacing existing returning columns", function() { + var entity = getInstance( "DatabaseGeneratedUser" ); + makePublic( entity, "retrieveRefreshOnSaveAttributes" ); + makePublic( entity, "configureRefreshOnSaveReturning" ); + + expect( getInstance( "PostgresGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "PostgresGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "SQLiteGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "SQLiteGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "SqlServerGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeTrue(); + expect( getInstance( "SqlServerGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeTrue(); + expect( getInstance( "MySQLGrammar@qb" ).supportsReturningRowsOnInsert() ).toBeFalse(); + expect( getInstance( "MySQLGrammar@qb" ).supportsReturningRowsOnUpdate() ).toBeFalse(); + + var builder = entity.newQuery(); + builder + .getQB() + .setGrammar( getInstance( "PostgresGrammar@qb" ) ) + .returning( "id" ); + entity.configureRefreshOnSaveReturning( + builder = builder, + attributes = entity.retrieveRefreshOnSaveAttributes(), + operation = "insert", + includeKeyColumns = true + ); + + var returning = builder.getQB().getReturning(); + var returningValues = []; + for ( var returningColumn in returning ) { + returningValues.append( returningColumn.value ); + } + expect( returningValues ).toHaveLength( 3 ); + expect( arrayFindNoCase( returningValues, "id" ) ).toBeGT( 0 ); + expect( arrayFindNoCase( returningValues, "created_date" ) ).toBeGT( 0 ); + expect( arrayFindNoCase( returningValues, "type" ) ).toBeGT( 0 ); + + var returningSql = builder.getQB().insert( values = { "username" : "native-returning" }, toSql = true ); + expect( returningSql ).toInclude( "RETURNING" ); + expect( returningSql ).toInclude( '"id"' ); + expect( returningSql ).toInclude( '"created_date"' ); + expect( returningSql ).toInclude( '"type"' ); + + var updateBuilder = entity.newQuery(); + updateBuilder + .getQB() + .setGrammar( getInstance( "PostgresGrammar@qb" ) ) + .where( "id", 1 ); + entity.configureRefreshOnSaveReturning( + builder = updateBuilder, + attributes = entity.retrieveRefreshOnSaveAttributes(), + operation = "update" + ); + var updateReturningSql = updateBuilder.update( values = { "first_name" : "Native" }, toSql = true ); + expect( updateReturningSql ).toInclude( "RETURNING" ); + expect( updateReturningSql ).toInclude( '"created_date"' ); + expect( updateReturningSql ).toInclude( '"type"' ); + } ); + + it( "checks returning-row support for the current write operation", function() { + var entity = getInstance( "DatabaseGeneratedUser" ); + makePublic( entity, "retrieveRefreshOnSaveAttributes" ); + makePublic( entity, "configureRefreshOnSaveReturning" ); + var attributes = entity.retrieveRefreshOnSaveAttributes(); + var grammar = new tests.resources.InsertOnlyReturningGrammar(); + + var insertBuilder = entity.newQuery(); + insertBuilder.getQB().setGrammar( grammar ); + expect( + entity.configureRefreshOnSaveReturning( + builder = insertBuilder, + attributes = attributes, + operation = "insert" + ) + ).toBeTrue(); + expect( insertBuilder.getQB().getReturning() ).notToBeEmpty(); + + var updateBuilder = entity.newQuery(); + updateBuilder.getQB().setGrammar( grammar ); + expect( + entity.configureRefreshOnSaveReturning( + builder = updateBuilder, + attributes = attributes, + operation = "update" + ) + ).toBeFalse(); + expect( updateBuilder.getQB().getReturning() ).toBeEmpty(); + } ); + + it( "uses a returned key row for auto-incrementing entities", function() { + var returnedKeys = queryNew( "id", "integer" ); + queryAddRow( returnedKeys ); + querySetCell( returnedKeys, "id", 42 ); + var entity = getInstance( "DatabaseGeneratedUser" ); + + getInstance( "AutoIncrementingKeyType@quick" ).postInsert( + entity, + { + "query" : returnedKeys, + "result" : {} + } + ); + + expect( entity.getId() ).toBe( 42 ); + } ); + it( "a saved entity is not dirty", function() { var newUser = getInstance( "User" ); newUser.setUsername( "new_user" ); @@ -76,6 +254,68 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( userRowsPostSave ).toHaveLength( 5 ); } ); + it( "can touch an entity timestamp", function() { + var user = getInstance( "User" ).findOrFail( 1 ); + var originalCreated = user.getCreatedDate(); + var originalModified = user.getModifiedDate(); + var originalFirstName = user.getFirstName(); + var originalId = user.getId(); + var dirtyFirstName = "This must not be persisted"; + + user.setFirstName( dirtyFirstName ); + + user.touch(); + + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 0 ); + expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); + expect( user.getFirstName() ).toBe( dirtyFirstName ); + expect( user.isDirty( "firstName" ) ).toBeTrue(); + expect( user.isDirty( "createdDate" ) ).toBeFalse(); + expect( user.isDirty( "modifiedDate" ) ).toBeFalse(); + var freshUser = getInstance( "User" ).findOrFail( originalId ); + expect( dateCompare( freshUser.getCreatedDate(), originalCreated ) ).toBe( 1 ); + expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 1 ); + expect( freshUser.getFirstName() ).toBe( originalFirstName ); + } ); + + it( "can override the timestamp fields used by touch", function() { + var user = getInstance( "CustomTimestampUser" ).findOrFail( 1 ); + var originalCreated = user.getCreatedDate(); + var originalModified = user.getModifiedDate(); + + user.touch(); + + expect( dateCompare( user.getCreatedDate(), originalCreated ) ).toBe( 0 ); + expect( dateCompare( user.getModifiedDate(), originalModified ) ).toBe( 0 ); + var freshUser = user.fresh(); + expect( dateCompare( freshUser.getCreatedDate(), originalCreated ) ).toBe( 1 ); + expect( dateCompare( freshUser.getModifiedDate(), originalModified ) ).toBe( 0 ); + } ); + + it( "throws a helpful error when changing the key of a loaded entity", function() { + var existingUser = getInstance( "User" ).findOrFail( 1 ); + + expect( function() { + existingUser.setId( 2 ).save(); + } ).toThrow( type = "QuickPrimaryKeyMutationException", regex = "cannot change its primary key" ); + } ); + + it( "allows assigning the existing key value to a loaded entity", function() { + var existingUser = getInstance( "User" ).findOrFail( 1 ); + + expect( function() { + existingUser.setId( 1 ).save(); + } ).notToThrow(); + } ); + + it( "guards every part of a loaded composite key", function() { + var composite = getInstance( "Composite" ).findOrFail( [ 1, 2 ] ); + + expect( function() { + composite.setB( 1 ).save(); + } ).toThrow( type = "QuickPrimaryKeyMutationException", regex = "primary key \[b\]" ); + } ); + it( "does not allow updating of column where update=false in property", function() { var existingUser = getInstance( "User" ).find( 1 ); existingUser.setEmail( "test2@test.com" ); @@ -84,7 +324,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { existingUser.save(); var userRowsPostSave = queryExecute( "SELECT * FROM users" ); expect( userRowsPostSave ).toHaveLength( 5 ); - expect( userRowsPostSave.email ).toBe( "" ); + expect( getInstance( "User" ).findOrFail( 1 ).isNullAttribute( "email" ) ).toBeTrue(); } ); it( "uses the sqltype attribute if present for each column", function() { diff --git a/tests/specs/integration/BaseEntity/ScopeSpec.cfc b/tests/specs/integration/BaseEntity/ScopeSpec.cfc index ef9708c2..8e3cb899 100644 --- a/tests/specs/integration/BaseEntity/ScopeSpec.cfc +++ b/tests/specs/integration/BaseEntity/ScopeSpec.cfc @@ -2,6 +2,30 @@ component extends="tests.resources.ModuleIntegrationSpec" { function run() { describe( "Scope Spec", function() { + it( "surfaces the missing method inside a when callback", function() { + try { + getInstance( "User" ).when( true, function( q ) { + q.missingScopeInsideWhen(); + } ); + } catch ( any e ) { + expect( e.type ).toBe( "QuickMissingMethod" ); + expect( e.message ).toInclude( "[missingScopeInsideWhen]" ); + expect( e.message ).notToInclude( "[when]" ); + return; + } + + fail( "Expected a QuickMissingMethod exception" ); + } ); + + it( "suggests an entity function that may be missing the scope prefix", function() { + expect( function() { + getInstance( "User" ).newQuery().incorrectlyNamedScope(); + } ).toThrow( + type = "QuickMissingMethod", + regex = "An entity function named \[incorrectlyNamedScope\] exists.*scopeIncorrectlyNamedScope.*https://quick.ortusbooks.com/guide/getting-started/query-scopes-and-subselects" + ); + } ); + it( "looks for missing methods as scopes", function() { var users = getInstance( "User" ).latest().get(); expect( users ).toHaveLength( 5, "Five users should exist in the database and be returned." ); @@ -62,6 +86,18 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users[ 2 ].getUsername() ).toBe( "elpete2" ); } ); + it( "uses the subquery table for columns that are also entity attributes", function() { + var sql = getInstance( "User" ) + .whereNotIn( "country_id", function( q ) { + q.from( "countries" ).select( "id" ); + } ) + .toSQL(); + + expect( sql ).toInclude( "SELECT `countries`.`id` FROM `countries`" ); + expect( sql ).toInclude( "`users`.`country_id` NOT IN" ); + expect( sql ).notToInclude( "SELECT `users`.`id` FROM `countries`" ); + } ); + it( "wraps scopes in parenthesis automatically if the scope contains an or clause", function() { var sql = getInstance( "User" ).canView().toSQL(); expect( sql ).toBe( diff --git a/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc new file mode 100644 index 00000000..9a0fa73d --- /dev/null +++ b/tests/specs/integration/BaseEntity/SoftDeletesSpec.cfc @@ -0,0 +1,48 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "Soft Deletes", function() { + it( "can soft delete, query, restore, and force delete entities", function() { + var user = getInstance( "SoftDeleteUser" ).findOrFail( 1 ); + expect( user.retrieveSoftDeleteColumn() ).toBe( "deletedDate" ); + + user.delete(); + + expect( user.isLoaded() ).toBeTrue(); + expect( user.isTrashed() ).toBeTrue(); + expect( getInstance( "SoftDeleteUser" ).find( 1 ) ).toBeNull(); + expect( getInstance( "SoftDeleteUser" ).all() ).toHaveLength( 4 ); + expect( getInstance( "SoftDeleteUser" ).withTrashed().all() ).toHaveLength( 5 ); + expect( getInstance( "SoftDeleteUser" ).onlyTrashed().count() ).toBe( 1 ); + + var trashedUser = getInstance( "SoftDeleteUser" ).withTrashed().findOrFail( 1 ); + expect( trashedUser.isTrashed() ).toBeTrue(); + structDelete( request, "softDeleteUserPostUpdateCalled" ); + trashedUser.restore(); + + expect( trashedUser.isTrashed() ).toBeFalse(); + expect( request ).toHaveKey( "softDeleteUserPostUpdateCalled" ); + expect( getInstance( "SoftDeleteUser" ).findOrFail( 1 ).getUsername() ).toBe( "elpete" ); + + getInstance( "SoftDeleteUser" ).where( "id", 2 ).deleteAll(); + expect( getInstance( "SoftDeleteUser" ).find( 2 ) ).toBeNull(); + getInstance( "SoftDeleteUser" ).onlyTrashed().restoreAll(); + expect( getInstance( "SoftDeleteUser" ).findOrFail( 2 ).getUsername() ).toBe( "johndoe" ); + getInstance( "SoftDeleteUser" ).where( "id", 2 ).forceDeleteAll(); + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 2 ) ).toBeNull(); + + trashedUser.forceDelete(); + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 1 ) ).toBeNull(); + } ); + + it( "can force delete multiple entities by id", function() { + getInstance( "SoftDeleteUser" ).forceDeleteAll( [ 1, 2 ] ); + + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 1 ) ).toBeNull(); + expect( getInstance( "SoftDeleteUser" ).withTrashed().find( 2 ) ).toBeNull(); + expect( getInstance( "SoftDeleteUser" ).findOrFail( 3 ).getUsername() ).toBe( "janedoe" ); + } ); + } ); + } + +} diff --git a/tests/specs/integration/BaseEntity/SubqueriesSpec.cfc b/tests/specs/integration/BaseEntity/SubqueriesSpec.cfc index 8ccfc789..be61b497 100644 --- a/tests/specs/integration/BaseEntity/SubqueriesSpec.cfc +++ b/tests/specs/integration/BaseEntity/SubqueriesSpec.cfc @@ -29,6 +29,51 @@ component extends="tests.resources.ModuleIntegrationSpec" { ); } ); + it( "can paginate while ordering by a subquery attribute", function() { + var page = getInstance( "User" ) + .addSubselect( "latestPostId", ( qb ) => { + qb.from( "my_posts" ) + .select( "post_pk" ) + .whereColumn( "my_posts.user_id", "=", "users.id" ) + .orderByDesc( "published_date" ) + .limit( 1 ); + } ) + .orderByDesc( "latestPostId" ) + .paginate( page = 1, maxRows = 2 ); + + expect( page.results ).toHaveLength( 2 ); + expect( page.results[ 1 ].getLatestPostId() ).notToBeNull(); + expect( page.results[ 2 ].getLatestPostId() ).notToBeNull(); + } ); + + it( "uses native SQL Server pagination when ordering by a subquery attribute", function() { + var sql = getInstance( "User" ) + .addSubselect( "latestPostId", ( qb ) => { + qb.from( "my_posts" ) + .select( "post_pk" ) + .whereColumn( "my_posts.user_id", "=", "users.id" ) + .limit( 1 ); + } ) + .orderByDesc( "latestPostId" ) + .setGrammar( getInstance( "SqlServerGrammar@qb" ) ) + .forPage( page = 1, maxRows = 2 ) + .toSQL(); + + expect( sql ).toInclude( "ORDER BY [latestPostId] DESC OFFSET 0 ROWS FETCH NEXT 2 ROWS ONLY" ); + expect( sql ).notToInclude( "ROW_NUMBER()" ); + } ); + + it( "keeps scoped subselects when retrieving all entities", function() { + var users = getInstance( "User" ).withLatestPostId().all(); + + expect( users ).toHaveLength( 5 ); + expect( users[ 1 ].getLatestPostId() ).toBe( 523526 ); + expect( variables.queries ).toHaveLength( + 1, + "Only one query should have been executed. #arrayLen( variables.queries )# were instead." + ); + } ); + it( "can add a subquery to an entity using a relationship", function() { var elpete = getInstance( "User" ).withLatestPostIdRelationship().findOrFail( 1 ); expect( elpete.getLatestPostId() ).notToBeNull(); @@ -131,9 +176,11 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); it( "can add a subquery to an entity using a hasManyThrough relationship with table aliases", function() { - // expect( () => { var rmmeAs = getInstance( "RMME_A" ).asMemento( includes = [ "C" ] ).get(); - // } ).notToThrow(); + + expect( rmmeAs ).toHaveLength( 1 ); + expect( rmmeAs[ 1 ].C ).toHaveLength( 1 ); + expect( rmmeAs[ 1 ].C[ 1 ].inlined_dValue ).toBe( 42 ); } ); } ); } diff --git a/tests/specs/integration/BaseEntity/UUIDPrimaryKeySpec.cfc b/tests/specs/integration/BaseEntity/UUIDPrimaryKeySpec.cfc index aa6b5b5c..a1a4796a 100644 --- a/tests/specs/integration/BaseEntity/UUIDPrimaryKeySpec.cfc +++ b/tests/specs/integration/BaseEntity/UUIDPrimaryKeySpec.cfc @@ -6,6 +6,11 @@ component extends="tests.resources.ModuleIntegrationSpec" { var country = getInstance( "Country" ).create( { "name" : "Wakanda" } ); expect( country.getId() ).notToBeNumeric(); + expect( country.getId() ).toHaveLength( 35 ); + expect( reFindNoCase( "^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{16}$", country.getId() ) ).toBe( + 1 + ); + expect( getInstance( "Country" ).find( country.getId() ) ).notToBeNull(); } ); } ); } diff --git a/tests/specs/integration/BaseEntity/UpdateAllSpec.cfc b/tests/specs/integration/BaseEntity/UpdateAllSpec.cfc index baa2bd40..c01e5b82 100644 --- a/tests/specs/integration/BaseEntity/UpdateAllSpec.cfc +++ b/tests/specs/integration/BaseEntity/UpdateAllSpec.cfc @@ -17,6 +17,32 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( postA.getBody() ).toBe( "The new body" ); expect( postB.getBody() ).toBe( "The new body" ); } ); + + it( "can update date values after switching to query results", function() { + var originalDate = getInstance( "User" ).findOrFail( 1 ).getModifiedDate(); + var futureDate = now().add( "d", 1 ); + + var result = getInstance( "User" ) + .where( "id", 1 ) + .asQuery() + .update( { "modified_date" : futureDate } ); + + expect( result.result.recordCount ).toBe( 1 ); + expect( getInstance( "User" ).findOrFail( 1 ).getModifiedDate() ).notToBe( originalDate ); + } ); + + it( "can update date values through the underlying query", function() { + var originalDate = getInstance( "User" ).findOrFail( 1 ).getModifiedDate(); + var futureDate = now().add( "d", 2 ); + + var result = getInstance( "User" ) + .where( "id", 1 ) + .retrieveQuery() + .update( { "modified_date" : futureDate } ); + + expect( result.result.recordCount ).toBe( 1 ); + expect( getInstance( "User" ).findOrFail( 1 ).getModifiedDate() ).notToBe( originalDate ); + } ); } ); } diff --git a/tests/specs/integration/BaseEntity/UpdateSpec.cfc b/tests/specs/integration/BaseEntity/UpdateSpec.cfc index 0ba4cda9..2cfbe18b 100644 --- a/tests/specs/integration/BaseEntity/UpdateSpec.cfc +++ b/tests/specs/integration/BaseEntity/UpdateSpec.cfc @@ -37,6 +37,19 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); describe( "updateOrCreate", function() { + it( "uses attribute aliases when finding an existing entity", function() { + var post = getInstance( "PostAlt" ).updateOrCreate( + { "id" : 1245 }, + { "body" : "Updated through an aliased primary key" } + ); + + expect( post.getId() ).toBe( 1245 ); + expect( post.getBody() ).toBe( "Updated through an aliased primary key" ); + expect( getInstance( "PostAlt" ).where( "body", "Updated through an aliased primary key" ).count() ).toBe( + 1 + ); + } ); + it( "updates an existing entity", function() { var user = getInstance( "User" ).updateOrCreate( { "username" : "elpete" }, diff --git a/tests/specs/integration/BaseServiceSpec.cfc b/tests/specs/integration/BaseServiceSpec.cfc index 6e779f8c..b0e55ebe 100644 --- a/tests/specs/integration/BaseServiceSpec.cfc +++ b/tests/specs/integration/BaseServiceSpec.cfc @@ -1,16 +1,28 @@ component extends="tests.resources.ModuleIntegrationSpec" { + function beforeAll() { + super.beforeAll(); + controller + .getInterceptorService() + .registerInterceptor( interceptorObject = this, interceptorName = "BaseServiceSpec" ); + } + + function afterAll() { + controller.getInterceptorService().unregister( "BaseServiceSpec" ); + super.afterAll(); + } + function run() { describe( "BaseService Spec", function() { describe( "instantiation", function() { it( "can be instantiated with an entity", function() { var user = getInstance( "User" ); - var service = getInstance( name = "BaseService@quick", initArguments = { entity : user } ); + var service = getInstance( name = "BaseService@quick", initArguments = { entity : user } ); expect( service.entityName() ).toBe( "User" ); } ); it( "can be instantiated with a wirebox mapping", function() { - var service = getInstance( name = "BaseService@quick", initArguments = { entity : "User" } ); + var service = getInstance( name = "BaseService@quick", initArguments = { entity : "User" } ); expect( service.entityName() ).toBe( "User" ); } ); @@ -44,8 +56,31 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users ).toBeArray(); expect( users ).toHaveLength( 2 ); } ); + + it( "passes get options through a quickService query", function() { + structDelete( request, "baseServiceSpecPreQBExecute" ); + + var users = variables.service + .whereNotNull( "created_date" ) + .get( options = { datasource : "quick" } ); + + expect( users ).toBeArray(); + expect( request.baseServiceSpecPreQBExecute ).toHaveLength( 1 ); + expect( request.baseServiceSpecPreQBExecute[ 1 ].options.datasource ).toBe( "quick" ); + } ); } ); } ); } + function preQBExecute( + event, + interceptData, + buffer, + rc, + prc + ) { + param request.baseServiceSpecPreQBExecute = []; + request.baseServiceSpecPreQBExecute.append( duplicate( arguments.interceptData ) ); + } + } diff --git a/tests/specs/integration/CBORMCompatEntitySpec.cfc b/tests/specs/integration/CBORMCompatEntitySpec.cfc index 15199c63..8ab69960 100644 --- a/tests/specs/integration/CBORMCompatEntitySpec.cfc +++ b/tests/specs/integration/CBORMCompatEntitySpec.cfc @@ -27,7 +27,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { it( "list (with arguments)", function() { var users = user.list( - criteria = { lastName : "Doe" }, + criteria = { lastName : "Doe" }, sortOrder = "username", max = 2, offset = 1, @@ -101,7 +101,7 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ); it( "findWhere", function() { - var john = user.findWhere( { firstName : "John" } ); + var john = user.findWhere( { firstName : "John" } ); expect( john.getId() ).toBe( 2 ); expect( john.getUsername() ).toBe( "johndoe" ); } ); @@ -163,27 +163,36 @@ component extends="tests.resources.ModuleIntegrationSpec" { expect( users[ 2 ].getUsername() ).toBe( "janedoe" ); } ); + it( "can eager load relationships", function() { + var users = getInstance( "CompatUser" ).with( "posts" ).get(); + + expect( users ).toBeArray(); + expect( users ).toHaveLength( 5 ); + expect( users[ 1 ].isRelationshipLoaded( "posts" ) ).toBeTrue(); + expect( users[ 1 ].retrieveRelationship( "posts" ) ).toHaveLength( 2 ); + } ); + it( "new", function() { var newUser = user.new(); expect( newUser.isLoaded() ).toBeFalse(); } ); it( "new (with properties)", function() { - var newUser = user.new( { username : "new_username" } ); + var newUser = user.new( { username : "new_username" } ); expect( newUser.isLoaded() ).toBeFalse(); expect( newUser.getUsername() ).toBe( "new_username" ); } ); it( "populate", function() { var newUser = user.new(); - newUser.populate( { username : "new_username" } ); + newUser.populate( { username : "new_username" } ); expect( newUser.getUsername() ).toBe( "new_username" ); } ); describe( "criteria builder compatibility", function() { it( "between", function() { - var rightNow = dateFormat( now(), "mm/dd/yyyy" ); - var lastWeek = dateFormat( dateAdd( "d", -7, rightNow ), "mm/dd/yyyy" ); + var rightNow = now(); + var lastWeek = dateAdd( "d", -7, rightNow ); var actual = user .newCriteria() .between( "created_date", rightNow, lastWeek ) diff --git a/tests/specs/integration/FactorySpec.cfc b/tests/specs/integration/FactorySpec.cfc new file mode 100644 index 00000000..f1964d86 --- /dev/null +++ b/tests/specs/integration/FactorySpec.cfc @@ -0,0 +1,118 @@ +component extends="tests.resources.ModuleIntegrationSpec" { + + function run() { + describe( "Quick model factories", function() { + it( "makes unsaved entities from defaults and explicit overrides", function() { + var user = newFactoryManager( { suffix : "make" } ) + .factory( "User" ) + .make( { firstName : "Overridden" } ); + + expect( user ).toBeInstanceOf( "User" ); + expect( user.isLoaded() ).toBeFalse(); + expect( user.getUsername() ).toInclude( "factory-make-" ); + expect( user.getFirstName() ).toBe( "Overridden" ); + expect( user.getLastName() ).toBe( "User 0" ); + expect( getInstance( "User" ).where( "username", user.getUsername() ).count() ).toBe( 0 ); + } ); + + it( "combines counts, named states, sequences, and persisted Quick entities", function() { + var users = newFactoryManager( { suffix : "sequence" } ) + .factory( "User" ) + .count( 3 ) + .administrator() + .state( function( attributes, context ) { + return { firstName : "State #context.index#" }; + } ) + .sequence( [ + { lastName : "Sequence A" }, + function( attributes, context ) { + return { lastName : "Sequence #context.index#" }; + } + ] ) + .create(); + + expect( users ).toHaveLength( 3 ); + expect( users[ 1 ].getLastName() ).toBe( "Sequence A" ); + expect( users[ 2 ].getLastName() ).toBe( "Sequence 1" ); + expect( users[ 3 ].getLastName() ).toBe( "Sequence A" ); + expect( users[ 2 ].getFirstName() ).toBe( "State 1" ); + expect( users[ 1 ].getType() ).toBe( "admin" ); + expect( users[ 1 ].isLoaded() ).toBeTrue(); + expect( getInstance( "User" ).whereLike( "username", "factory-sequence-%" ).count() ).toBe( 3 ); + } ); + + it( "creates factories through WireBox so application dependencies are injected", function() { + var user = newFactoryManager() + .factory( "User" ) + .wired() + .make(); + + expect( user.getFirstName() ).toBe( "Injected" ); + } ); + + it( "runs one-use after-making and after-creating callbacks", function() { + var made = []; + var created = []; + var user = newFactoryManager() + .factory( "User" ) + .state( { username : "factory-callback" } ) + .afterMaking( function( entity, attributes ) { + arrayAppend( made, attributes.username ); + } ) + .afterCreating( function( entity, attributes ) { + arrayAppend( created, attributes.id ); + } ) + .create(); + + expect( made ).toBe( [ "factory-callback" ] ); + expect( created ).toHaveLength( 1 ); + expect( created[ 1 ] ).toBe( user.getId() ); + } ); + + it( "returns arrays whenever count is explicit", function() { + var one = newFactoryManager() + .factory( "User" ) + .count( 1 ) + .make(); + var none = newFactoryManager() + .factory( "User" ) + .count( 0 ) + .make(); + + expect( one ).toBeArray(); + expect( one ).toHaveLength( 1 ); + expect( none ).toBeArray(); + expect( none ).toBeEmpty(); + } ); + + it( "rejects invalid counts, states, sequences, callbacks, and factory names", function() { + var factory = newFactoryManager().factory( "User" ); + + expect( function() { + factory.count( -1 ); + } ).toThrow( type = "QuickFactory.InvalidCount" ); + expect( function() { + factory.state( "invalid" ); + } ).toThrow( type = "QuickFactory.InvalidState" ); + expect( function() { + factory.sequence( [] ); + } ).toThrow( type = "QuickFactory.EmptySequence" ); + expect( function() { + factory.afterCreating( "invalid" ); + } ).toThrow( type = "QuickFactory.InvalidCallback" ); + expect( function() { + newFactoryManager().factory( "User;drop" ); + } ).toThrow( type = "QuickFactory.InvalidFactoryName" ); + } ); + } ); + } + + private any function newFactoryManager( struct context = {} ) { + return new quick.resources.testing.FactoryManager( + wirebox = getWireBox(), + factoryPath = "tests.resources.factories", + context = arguments.context + ); + } + +} diff --git a/tests/specs/integration/GoodErrorMessagesSpec.cfc b/tests/specs/integration/GoodErrorMessagesSpec.cfc index b70a0adf..6605c0e3 100644 --- a/tests/specs/integration/GoodErrorMessagesSpec.cfc +++ b/tests/specs/integration/GoodErrorMessagesSpec.cfc @@ -35,20 +35,13 @@ component extends="tests.resources.ModuleIntegrationSpec" { } ).toThrow( type = "QuickEntityDefaultedKey" ); } ); - it( "throws a helpful error message when trying to access relationships on unloaded entities", function() { + it( "throws a helpful error message when trying to query relationships on unloaded entities", function() { expect( function() { getInstance( "User" ).posts(); } ).toThrow( type = "QuickEntityNotLoaded", regex = "This instance is not loaded so it cannot access the \[posts\] relationship\. Either load the entity from the database using a query executor \(like \`first\`\) or base your query off of the \[Post\] entity directly and use the \`has\` or \`whereHas\` methods to constrain it based on data in \[User\]\." ); - - expect( function() { - getInstance( "User" ).getPosts(); - } ).toThrow( - type = "QuickEntityNotLoaded", - regex = "This instance is not loaded so it cannot access the \[posts\] relationship\. Either load the entity from the database using a query executor \(like \`first\`\) or base your query off of the \[Post\] entity directly and use the \`has\` or \`whereHas\` methods to constrain it based on data in \[User\]\." - ); } ); it( @@ -64,18 +57,17 @@ component extends="tests.resources.ModuleIntegrationSpec" { skip = server.keyExists( "boxlang" ) ); - it( "throws a helpful error message when trying to set a belongsToMany relationship when the relationship is not loaded", function() { - expect( function() { - getInstance( "Post" ).create( { - "user_id" : 1, - "body" : "A new post body", - "publishedDate" : now(), - "tags" : [ 1, 2 ] - } ); - } ).toThrow( - type = "QuickEntityNotLoaded", - regex = "This instance is not loaded so it cannot set the \[tags\] relationship\. Save the new entity first before trying to save related entities\." - ); + it( "does not persist a filled belongsToMany relationship when creating the parent", function() { + var post = getInstance( "Post" ).create( { + "user_id" : 1, + "body" : "A new post body", + "publishedDate" : now(), + "tags" : [ 1, 2 ] + } ); + + expect( post.isLoaded() ).toBeTrue(); + expect( post.getTags() ).toBe( [ 1, 2 ] ); + expect( post.fresh().getTags() ).toBeEmpty(); } ); } ); } diff --git a/tests/specs/integration/QuickCollectionSpec.cfc b/tests/specs/integration/QuickCollectionSpec.cfc index 5a57a96c..4585cb2d 100644 --- a/tests/specs/integration/QuickCollectionSpec.cfc +++ b/tests/specs/integration/QuickCollectionSpec.cfc @@ -31,8 +31,34 @@ component extends="tests.resources.ModuleIntegrationSpec" { expectAll( posts.get() ).toSatisfy( function( post ) { return post.isRelationshipLoaded( "author" ); }, "The relationship should now be loaded." ); - }, - skip = server.keyExists( "boxlang" ) + } + ); + + it( + title = "can eager load relationships when retrieving a QuickCollection", + body = function() { + var posts = getInstance( "CollectionPost" ).with( [ "author", "tags" ] ).get(); + + expect( posts ).toBeInstanceOf( "extras.QuickCollection" ); + expectAll( posts.get() ).toSatisfy( function( post ) { + return post.isRelationshipLoaded( "author" ) && post.isRelationshipLoaded( "tags" ); + }, "Both relationships should be eager loaded." ); + expect( variables.queries ).toHaveLength( 3 ); + } + ); + + it( + title = "can load multiple relationships on an existing QuickCollection", + body = function() { + var posts = getInstance( "CollectionPost" ).all(); + + posts.load( [ "author", "tags" ] ); + + expectAll( posts.get() ).toSatisfy( function( post ) { + return post.isRelationshipLoaded( "author" ) && post.isRelationshipLoaded( "tags" ); + }, "Both relationships should be loaded." ); + expect( variables.queries ).toHaveLength( 3 ); + } ); } ); } diff --git a/tests/specs/performance/EntityCreationSpec.cfc b/tests/specs/performance/EntityCreationSpec.cfc deleted file mode 100644 index 6860cfc1..00000000 --- a/tests/specs/performance/EntityCreationSpec.cfc +++ /dev/null @@ -1,42 +0,0 @@ -component extends="tests.resources.ModuleIntegrationSpec" appMapping="/app" { - - function run() { - describe( "Entity Creation", function() { - it( "entity creation should take less than 1ms on average", function() { - var numEntities = 100; - var times = []; - var last = microsecondsTickCount(); - for ( var i = 1; i <= numEntities; i++ ) { - getInstance( "User" ); - var now = microsecondsTickCount(); - times.append( now - last ); - last = now; - } - arrayDeleteAt( times, 1 ); // ignore the first one. WireBox and Quick are both booting up there. - var averageDurationInMicroseconds = times.sum() / times.len(); - var averageDuration = averageDurationInMicroseconds / 1000; - debug( "Average duration: #averageDuration# ms" ); - // debug( times ); - } ); - - it( "can retrieve 1000 records", function() { - queryExecute( "TRUNCATE TABLE `a`" ); - for ( var i = 1; i <= 1000; i++ ) { - // create A - var a = queryExecute( "INSERT INTO `a` (`name`) VALUES (?)", [ "Instance #i#" ] ); - } - var start = microsecondsTickCount(); - var records = getInstance( "A" ).get(); - var end = microsecondsTickCount(); - debug( "Duration: #( end - start ) / 1000# ms" ); - expect( records ).toHaveLength( 1000 ); - } ); - } ); - } - - function microsecondsTickCount() { - param variables.javaSystem = createObject( "java", "java.lang.System" ); - return variables.javaSystem.nanoTime() / 1000; - } - -}