From be020f9de7258481c56799764811c59fa69810b9 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 4 Mar 2023 11:51:38 +0000 Subject: [PATCH 01/18] meta: update dependency rimraf to v4.3.0 (#15734) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- yarn.lock | 40 ++++++++++++++++++++++++++++++++++---- 2 files changed, 37 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index b3d04525ed16..41fbc1fefdac 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -102,7 +102,7 @@ "p-timeout": "4.1.0", "pg": "8.9.0", "pg-hstore": "2.3.4", - "rimraf": "4.1.3", + "rimraf": "4.3.0", "sinon": "15.0.1", "sinon-chai": "3.7.0", "snowflake-sdk": "1.6.19", diff --git a/yarn.lock b/yarn.lock index f962548b80b2..0b3a78471fe4 100644 --- a/yarn.lock +++ b/yarn.lock @@ -4388,6 +4388,16 @@ glob@^8.0.1: minimatch "^5.0.1" once "^1.3.0" +glob@^9.2.0: + version "9.2.1" + resolved "https://registry.yarnpkg.com/glob/-/glob-9.2.1.tgz#f47e34e1119e7d4f93a546e75851ba1f1e68de50" + integrity sha512-Pxxgq3W0HyA3XUvSXcFhRSs+43Jsx0ddxcFrbjxNGkL2Ak5BAUBxLqI5G6ADDeCHLfzzXFhe0b1yYcctGmytMA== + dependencies: + fs.realpath "^1.0.0" + minimatch "^7.4.1" + minipass "^4.2.4" + path-scurry "^1.6.1" + glob@~8.0.3: version "8.0.3" resolved "https://registry.yarnpkg.com/glob/-/glob-8.0.3.tgz#415c6eb2deed9e502c68fa44a272e6da6eeca42e" @@ -6050,6 +6060,13 @@ minimatch@^7.1.3: dependencies: brace-expansion "^2.0.1" +minimatch@^7.4.1: + version "7.4.2" + resolved "https://registry.yarnpkg.com/minimatch/-/minimatch-7.4.2.tgz#157e847d79ca671054253b840656720cb733f10f" + integrity sha512-xy4q7wou3vUoC9k1xGTXc+awNdGaGVHtFUaey8tiX4H1QRc04DZ/rmDFwNm2EBsuYEhAZ6SgMmYf3InGY6OauA== + dependencies: + brace-expansion "^2.0.1" + minimist-options@4.1.0: version "4.1.0" resolved "https://registry.yarnpkg.com/minimist-options/-/minimist-options-4.1.0.tgz#c0655713c53a8a2ebd77ffa247d342c40f010619" @@ -6134,6 +6151,11 @@ minipass@^4.0.0: resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.0.3.tgz#00bfbaf1e16e35e804f4aa31a7c1f6b8d9f0ee72" integrity sha512-OW2r4sQ0sI+z5ckEt5c1Tri4xTgZwYDxpE54eqWlQloQRoWtXjqt9udJ5Z4dSv7wK+nfFI7FRXyCpBSft+gpFw== +minipass@^4.0.2, minipass@^4.2.4: + version "4.2.4" + resolved "https://registry.yarnpkg.com/minipass/-/minipass-4.2.4.tgz#7d0d97434b6a19f59c5c3221698b48bbf3b2cd06" + integrity sha512-lwycX3cBMTvcejsHITUgYj6Gy6A7Nh4Q6h9NP4sTHY1ccJlC7yKzDmiShEHsJ16Jf1nKGDEaiHxiltsJEvk0nQ== + minizlib@^2.0.0, minizlib@^2.1.1, minizlib@^2.1.2: version "2.1.2" resolved "https://registry.yarnpkg.com/minizlib/-/minizlib-2.1.2.tgz#e90d3466ba209b932451508a11ce3d3632145931" @@ -7190,6 +7212,14 @@ path-parse@^1.0.7: resolved "https://registry.yarnpkg.com/path-parse/-/path-parse-1.0.7.tgz#fbc114b60ca42b30d9daf5858e4bd68bbedb6735" integrity sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw== +path-scurry@^1.6.1: + version "1.6.1" + resolved "https://registry.yarnpkg.com/path-scurry/-/path-scurry-1.6.1.tgz#dab45f7bb1d3f45a0e271ab258999f4ab7e23132" + integrity sha512-OW+5s+7cw6253Q4E+8qQ/u1fVvcJQCJo/VFD8pje+dbJCF1n5ZRMV2AEHbGp+5Q7jxQIYJxkHopnj6nzdGeZLA== + dependencies: + lru-cache "^7.14.1" + minipass "^4.0.2" + path-to-regexp@^1.7.0: version "1.8.0" resolved "https://registry.yarnpkg.com/path-to-regexp/-/path-to-regexp-1.8.0.tgz#887b3ba9d84393e87a0a0b9f4cb756198b53548a" @@ -7835,10 +7865,12 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@4.1.3: - version "4.1.3" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.1.3.tgz#e8ace19d5f009b4fa6108deeaffe39ef68bba194" - integrity sha512-iyzalDLo3l5FZxxaIGUY7xI4Bf90Xt7pCipc1Mr7RsdU7H3538z+M0tlsUDrz0aHeGS9uNqiKHUJyTewwRP91Q== +rimraf@4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.3.0.tgz#cd4d9a918c1735197e0ae8217e5eeb4657d42154" + integrity sha512-5qVDXPbByA1qSJEWMv1qAwKsoS22vVpsL2QyxCKBw4gf6XiFo1K3uRLY6uSOOBFDwnqAZtnbILqWKKlzh8bkGg== + dependencies: + glob "^9.2.0" rimraf@^3.0.0, rimraf@^3.0.2: version "3.0.2" From e06270f06822f18bf49220d7c63f105f6dbceec3 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 4 Mar 2023 15:05:41 +0000 Subject: [PATCH 02/18] meta: update dependency esbuild to v0.17.11 (#15740) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 270 +++++++++++++++++++++++++-------------------------- 2 files changed, 136 insertions(+), 136 deletions(-) diff --git a/package.json b/package.json index 6efe399a61d1..b210ed91f816 100644 --- a/package.json +++ b/package.json @@ -47,7 +47,7 @@ "@ephys/eslint-config-typescript": "18.0.1", "@rushstack/eslint-patch": "1.2.0", "chai": "4.3.7", - "esbuild": "0.17.10", + "esbuild": "0.17.11", "eslint": "8.35.0", "eslint-plugin-jsdoc": "40.0.1", "eslint-plugin-mocha": "10.1.0", diff --git a/yarn.lock b/yarn.lock index 0b3a78471fe4..27cd75e57a67 100644 --- a/yarn.lock +++ b/yarn.lock @@ -466,115 +466,115 @@ esquery "^1.4.0" jsdoc-type-pratt-parser "~3.1.0" -"@esbuild/android-arm64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.10.tgz#ad2ee47dd021035abdfb0c38848ff77a1e1918c4" - integrity sha512-ht1P9CmvrPF5yKDtyC+z43RczVs4rrHpRqrmIuoSvSdn44Fs1n6DGlpZKdK6rM83pFLbVaSUwle8IN+TPmkv7g== - -"@esbuild/android-arm@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.10.tgz#bb5a68af8adeb94b30eadee7307404dc5237d076" - integrity sha512-7YEBfZ5lSem9Tqpsz+tjbdsEshlO9j/REJrfv4DXgKTt1+/MHqGwbtlyxQuaSlMeUZLxUKBaX8wdzlTfHkmnLw== - -"@esbuild/android-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.10.tgz#751d5d8ae9ece1efa9627b689c888eb85b102360" - integrity sha512-CYzrm+hTiY5QICji64aJ/xKdN70IK8XZ6iiyq0tZkd3tfnwwSWTYH1t3m6zyaaBxkuj40kxgMyj1km/NqdjQZA== - -"@esbuild/darwin-arm64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.10.tgz#85601ee7efb2129cd3218d5bcbe8da1173bc1e8b" - integrity sha512-3HaGIowI+nMZlopqyW6+jxYr01KvNaLB5znXfbyyjuo4lE0VZfvFGcguIJapQeQMS4cX/NEispwOekJt3gr5Dg== - -"@esbuild/darwin-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.10.tgz#362c7e988c61fe72d5edef4f717e4b4fc728da98" - integrity sha512-J4MJzGchuCRG5n+B4EHpAMoJmBeAE1L3wGYDIN5oWNqX0tEr7VKOzw0ymSwpoeSpdCa030lagGUfnfhS7OvzrQ== - -"@esbuild/freebsd-arm64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.10.tgz#e8a85a46ede7c3a048a12f16b9d551d25adc8bb1" - integrity sha512-ZkX40Z7qCbugeK4U5/gbzna/UQkM9d9LNV+Fro8r7HA7sRof5Rwxc46SsqeMvB5ZaR0b1/ITQ/8Y1NmV2F0fXQ== - -"@esbuild/freebsd-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.10.tgz#cd0a1b68bffbcb5b65e65b3fd542e8c7c3edd86b" - integrity sha512-0m0YX1IWSLG9hWh7tZa3kdAugFbZFFx9XrvfpaCMMvrswSTvUZypp0NFKriUurHpBA3xsHVE9Qb/0u2Bbi/otg== - -"@esbuild/linux-arm64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.10.tgz#13b183f432512ed9d9281cc89476caeebe9e9123" - integrity sha512-g1EZJR1/c+MmCgVwpdZdKi4QAJ8DCLP5uTgLWSAVd9wlqk9GMscaNMEViG3aE1wS+cNMzXXgdWiW/VX4J+5nTA== - -"@esbuild/linux-arm@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.10.tgz#dd11e0a5faa3ea94dc80278a601c3be7b4fdf1da" - integrity sha512-whRdrrl0X+9D6o5f0sTZtDM9s86Xt4wk1bf7ltx6iQqrIIOH+sre1yjpcCdrVXntQPCNw/G+XqsD4HuxeS+2QA== - -"@esbuild/linux-ia32@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.10.tgz#4d836f87b92807d9292379963c4888270d282405" - integrity sha512-1vKYCjfv/bEwxngHERp7huYfJ4jJzldfxyfaF7hc3216xiDA62xbXJfRlradiMhGZbdNLj2WA1YwYFzs9IWNPw== - -"@esbuild/linux-loong64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.10.tgz#92eb2ee200c17ef12c7fb3b648231948699e7a4c" - integrity sha512-mvwAr75q3Fgc/qz3K6sya3gBmJIYZCgcJ0s7XshpoqIAIBszzfXsqhpRrRdVFAyV1G9VUjj7VopL2HnAS8aHFA== - -"@esbuild/linux-mips64el@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.10.tgz#14f7d50c40fe7f7ee545a9bd07c6f6e4cba5570e" - integrity sha512-XilKPgM2u1zR1YuvCsFQWl9Fc35BqSqktooumOY2zj7CSn5czJn279j9TE1JEqSqz88izJo7yE4x3LSf7oxHzg== - -"@esbuild/linux-ppc64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.10.tgz#1ab5802e93ae511ce9783e1cb95f37df0f84c4af" - integrity sha512-kM4Rmh9l670SwjlGkIe7pYWezk8uxKHX4Lnn5jBZYBNlWpKMBCVfpAgAJqp5doLobhzF3l64VZVrmGeZ8+uKmQ== - -"@esbuild/linux-riscv64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.10.tgz#4fae25201ef7ad868731d16c8b50b0e386c4774a" - integrity sha512-r1m9ZMNJBtOvYYGQVXKy+WvWd0BPvSxMsVq8Hp4GzdMBQvfZRvRr5TtX/1RdN6Va8JMVQGpxqde3O+e8+khNJQ== - -"@esbuild/linux-s390x@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.10.tgz#126254d8335bb3586918b1ca60beb4abb46e6d54" - integrity sha512-LsY7QvOLPw9WRJ+fU5pNB3qrSfA00u32ND5JVDrn/xG5hIQo3kvTxSlWFRP0NJ0+n6HmhPGG0Q4jtQsb6PFoyg== - -"@esbuild/linux-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.10.tgz#7fa4667b2df81ea0538e1b75e607cf04e526ce91" - integrity sha512-zJUfJLebCYzBdIz/Z9vqwFjIA7iSlLCFvVi7glMgnu2MK7XYigwsonXshy9wP9S7szF+nmwrelNaP3WGanstEg== - -"@esbuild/netbsd-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.10.tgz#2d24727ddc2305619685bf237a46d6087a02ee9a" - integrity sha512-lOMkailn4Ok9Vbp/q7uJfgicpDTbZFlXlnKT2DqC8uBijmm5oGtXAJy2ZZVo5hX7IOVXikV9LpCMj2U8cTguWA== - -"@esbuild/openbsd-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.10.tgz#bf3fc38ee6ecf028c1f0cfe11f61d53cc75fef12" - integrity sha512-/VE0Kx6y7eekqZ+ZLU4AjMlB80ov9tEz4H067Y0STwnGOYL8CsNg4J+cCmBznk1tMpxMoUOf0AbWlb1d2Pkbig== - -"@esbuild/sunos-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.10.tgz#8deabd6dfec6256f80bb101bc59d29dbae99c69b" - integrity sha512-ERNO0838OUm8HfUjjsEs71cLjLMu/xt6bhOlxcJ0/1MG3hNqCmbWaS+w/8nFLa0DDjbwZQuGKVtCUJliLmbVgg== - -"@esbuild/win32-arm64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.10.tgz#1ec1ee04c788c4c57a83370b6abf79587b3e4965" - integrity sha512-fXv+L+Bw2AeK+XJHwDAQ9m3NRlNemG6Z6ijLwJAAVdu4cyoFbBWbEtyZzDeL+rpG2lWI51cXeMt70HA8g2MqIg== - -"@esbuild/win32-ia32@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.10.tgz#a362528d7f3ad5d44fa8710a96764677ef92ebe9" - integrity sha512-3s+HADrOdCdGOi5lnh5DMQEzgbsFsd4w57L/eLKKjMnN0CN4AIEP0DCP3F3N14xnxh3ruNc32A0Na9zYe1Z/AQ== - -"@esbuild/win32-x64@0.17.10": - version "0.17.10" - resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.10.tgz#ac779220f2da96afd480fb3f3148a292f66e7fc3" - integrity sha512-oP+zFUjYNaMNmjTwlFtWep85hvwUu19cZklB3QsBOcZSs6y7hmH4LNCJ7075bsqzYaNvZFXJlAVaQ2ApITDXtw== +"@esbuild/android-arm64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm64/-/android-arm64-0.17.11.tgz#52c3e6cabc19c5e4c1c0c01cb58f0442338e1c14" + integrity sha512-QnK4d/zhVTuV4/pRM4HUjcsbl43POALU2zvBynmrrqZt9LPcLA3x1fTZPBg2RRguBQnJcnU059yKr+bydkntjg== + +"@esbuild/android-arm@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-arm/-/android-arm-0.17.11.tgz#f3fc768235aecbeb840d0049fdf13cd28592105f" + integrity sha512-CdyX6sRVh1NzFCsf5vw3kULwlAhfy9wVt8SZlrhQ7eL2qBjGbFhRBWkkAzuZm9IIEOCKJw4DXA6R85g+qc8RDw== + +"@esbuild/android-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/android-x64/-/android-x64-0.17.11.tgz#443ed47771a7e917e4282469ba350d117473550c" + integrity sha512-3PL3HKtsDIXGQcSCKtWD/dy+mgc4p2Tvo2qKgKHj9Yf+eniwFnuoQ0OUhlSfAEpKAFzF9N21Nwgnap6zy3L3MQ== + +"@esbuild/darwin-arm64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-arm64/-/darwin-arm64-0.17.11.tgz#0e8c78d94d5759a48521dbfd83189d2ed3499a16" + integrity sha512-pJ950bNKgzhkGNO3Z9TeHzIFtEyC2GDQL3wxkMApDEghYx5Qers84UTNc1bAxWbRkuJOgmOha5V0WUeh8G+YGw== + +"@esbuild/darwin-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/darwin-x64/-/darwin-x64-0.17.11.tgz#2405cfdf70eb961c7cf973463ca7263dc2004c88" + integrity sha512-iB0dQkIHXyczK3BZtzw1tqegf0F0Ab5texX2TvMQjiJIWXAfM4FQl7D909YfXWnB92OQz4ivBYQ2RlxBJrMJOw== + +"@esbuild/freebsd-arm64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-arm64/-/freebsd-arm64-0.17.11.tgz#d5138e873e15f87bd4564c024dfa00ef37e623fd" + integrity sha512-7EFzUADmI1jCHeDRGKgbnF5sDIceZsQGapoO6dmw7r/ZBEKX7CCDnIz8m9yEclzr7mFsd+DyasHzpjfJnmBB1Q== + +"@esbuild/freebsd-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/freebsd-x64/-/freebsd-x64-0.17.11.tgz#e850b58b8fabf8e9ef0e125af3c25229ad2d6c38" + integrity sha512-iPgenptC8i8pdvkHQvXJFzc1eVMR7W2lBPrTE6GbhR54sLcF42mk3zBOjKPOodezzuAz/KSu8CPyFSjcBMkE9g== + +"@esbuild/linux-arm64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm64/-/linux-arm64-0.17.11.tgz#2bfb93d0809ec2357c12ebb27736b750c9ae0aa5" + integrity sha512-Qxth3gsWWGKz2/qG2d5DsW/57SeA2AmpSMhdg9TSB5Svn2KDob3qxfQSkdnWjSd42kqoxIPy3EJFs+6w1+6Qjg== + +"@esbuild/linux-arm@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-arm/-/linux-arm-0.17.11.tgz#e56fb3b76828317a704f4a167c5bd790fe5314e7" + integrity sha512-M9iK/d4lgZH0U5M1R2p2gqhPV/7JPJcRz+8O8GBKVgqndTzydQ7B2XGDbxtbvFkvIs53uXTobOhv+RyaqhUiMg== + +"@esbuild/linux-ia32@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ia32/-/linux-ia32-0.17.11.tgz#59fa1c49b271793d14eb5effc757e8c0d0cb2cab" + integrity sha512-dB1nGaVWtUlb/rRDHmuDQhfqazWE0LMro/AIbT2lWM3CDMHJNpLckH+gCddQyhhcLac2OYw69ikUMO34JLt3wA== + +"@esbuild/linux-loong64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-loong64/-/linux-loong64-0.17.11.tgz#89575bc189099c03a36daa54f3f481780c7fd502" + integrity sha512-aCWlq70Q7Nc9WDnormntGS1ar6ZFvUpqr8gXtO+HRejRYPweAFQN615PcgaSJkZjhHp61+MNLhzyVALSF2/Q0g== + +"@esbuild/linux-mips64el@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-mips64el/-/linux-mips64el-0.17.11.tgz#0e18ca039dc7e4645efd8edc1b10952933eb6b1b" + integrity sha512-cGeGNdQxqY8qJwlYH1BP6rjIIiEcrM05H7k3tR7WxOLmD1ZxRMd6/QIOWMb8mD2s2YJFNRuNQ+wjMhgEL2oCEw== + +"@esbuild/linux-ppc64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-ppc64/-/linux-ppc64-0.17.11.tgz#2d152cb3a253afb8c100a165ad132dc96f36cb11" + integrity sha512-BdlziJQPW/bNe0E8eYsHB40mYOluS+jULPCjlWiHzDgr+ZBRXPtgMV1nkLEGdpjrwgmtkZHEGEPaKdS/8faLDA== + +"@esbuild/linux-riscv64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-riscv64/-/linux-riscv64-0.17.11.tgz#c6ac494a81221d53d65b33e665c7df1747952d3c" + integrity sha512-MDLwQbtF+83oJCI1Cixn68Et/ME6gelmhssPebC40RdJaect+IM+l7o/CuG0ZlDs6tZTEIoxUe53H3GmMn8oMA== + +"@esbuild/linux-s390x@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-s390x/-/linux-s390x-0.17.11.tgz#4bad33894bc7415cea4be8fa90fe456226a424ad" + integrity sha512-4N5EMESvws0Ozr2J94VoUD8HIRi7X0uvUv4c0wpTHZyZY9qpaaN7THjosdiW56irQ4qnJ6Lsc+i+5zGWnyqWqQ== + +"@esbuild/linux-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/linux-x64/-/linux-x64-0.17.11.tgz#903fda743459f530a16a6c6ee8d2c0f6c1a12fc7" + integrity sha512-rM/v8UlluxpytFSmVdbCe1yyKQd/e+FmIJE2oPJvbBo+D0XVWi1y/NQ4iTNx+436WmDHQBjVLrbnAQLQ6U7wlw== + +"@esbuild/netbsd-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/netbsd-x64/-/netbsd-x64-0.17.11.tgz#b589239fe7d9b16ee03c5e191f3f5b640f1518a1" + integrity sha512-4WaAhuz5f91h3/g43VBGdto1Q+X7VEZfpcWGtOFXnggEuLvjV+cP6DyLRU15IjiU9fKLLk41OoJfBFN5DhPvag== + +"@esbuild/openbsd-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/openbsd-x64/-/openbsd-x64-0.17.11.tgz#b355019754116bef39ec688f8fd2fe6471b9779b" + integrity sha512-UBj135Nx4FpnvtE+C8TWGp98oUgBcmNmdYgl5ToKc0mBHxVVqVE7FUS5/ELMImOp205qDAittL6Ezhasc2Ev/w== + +"@esbuild/sunos-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/sunos-x64/-/sunos-x64-0.17.11.tgz#2ea47fb592e68406e5025a7696dc714fc6a115dc" + integrity sha512-1/gxTifDC9aXbV2xOfCbOceh5AlIidUrPsMpivgzo8P8zUtczlq1ncFpeN1ZyQJ9lVs2hILy1PG5KPp+w8QPPg== + +"@esbuild/win32-arm64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-arm64/-/win32-arm64-0.17.11.tgz#47e6fdab17c4c52e6e0d606dd9cb843b29826325" + integrity sha512-vtSfyx5yRdpiOW9yp6Ax0zyNOv9HjOAw8WaZg3dF5djEHKKm3UnoohftVvIJtRh0Ec7Hso0RIdTqZvPXJ7FdvQ== + +"@esbuild/win32-ia32@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-ia32/-/win32-ia32-0.17.11.tgz#a97273aa3164c8d8f501899f55cc75a4a79599a3" + integrity sha512-GFPSLEGQr4wHFTiIUJQrnJKZhZjjq4Sphf+mM76nQR6WkQn73vm7IsacmBRPkALfpOCHsopSvLgqdd4iUW2mYw== + +"@esbuild/win32-x64@0.17.11": + version "0.17.11" + resolved "https://registry.yarnpkg.com/@esbuild/win32-x64/-/win32-x64-0.17.11.tgz#9be796d93ae27b636da32d960899a4912bca27a1" + integrity sha512-N9vXqLP3eRL8BqSy8yn4Y98cZI2pZ8fyuHx6lKjiG2WABpT2l01TXdzq5Ma2ZUBzfB7tx5dXVhge8X9u0S70ZQ== "@eslint-community/eslint-utils@^4.1.2": version "4.1.2" @@ -3259,33 +3259,33 @@ es6-error@^4.0.1: resolved "https://registry.yarnpkg.com/es6-error/-/es6-error-4.1.1.tgz#9e3af407459deed47e9a91f9b885a84eb05c561d" integrity sha512-Um/+FxMr9CISWh0bi5Zv0iOD+4cFh5qLeks1qhAopKVAJw3drgKbKySikp7wGhDL0HPeaja0P5ULZrxLkniUVg== -esbuild@0.17.10: - version "0.17.10" - resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.10.tgz#3be050561b34c5dc05b46978f4e1f326d5cc9437" - integrity sha512-n7V3v29IuZy5qgxx25TKJrEm0FHghAlS6QweUcyIgh/U0zYmQcvogWROitrTyZId1mHSkuhhuyEXtI9OXioq7A== +esbuild@0.17.11: + version "0.17.11" + resolved "https://registry.yarnpkg.com/esbuild/-/esbuild-0.17.11.tgz#9f3122643b21d7e7731e42f18576c10bfa28152b" + integrity sha512-pAMImyokbWDtnA/ufPxjQg0fYo2DDuzAlqwnDvbXqHLphe+m80eF++perYKVm8LeTuj2zUuFXC+xgSVxyoHUdg== optionalDependencies: - "@esbuild/android-arm" "0.17.10" - "@esbuild/android-arm64" "0.17.10" - "@esbuild/android-x64" "0.17.10" - "@esbuild/darwin-arm64" "0.17.10" - "@esbuild/darwin-x64" "0.17.10" - "@esbuild/freebsd-arm64" "0.17.10" - "@esbuild/freebsd-x64" "0.17.10" - "@esbuild/linux-arm" "0.17.10" - "@esbuild/linux-arm64" "0.17.10" - "@esbuild/linux-ia32" "0.17.10" - "@esbuild/linux-loong64" "0.17.10" - "@esbuild/linux-mips64el" "0.17.10" - "@esbuild/linux-ppc64" "0.17.10" - "@esbuild/linux-riscv64" "0.17.10" - "@esbuild/linux-s390x" "0.17.10" - "@esbuild/linux-x64" "0.17.10" - "@esbuild/netbsd-x64" "0.17.10" - "@esbuild/openbsd-x64" "0.17.10" - "@esbuild/sunos-x64" "0.17.10" - "@esbuild/win32-arm64" "0.17.10" - "@esbuild/win32-ia32" "0.17.10" - "@esbuild/win32-x64" "0.17.10" + "@esbuild/android-arm" "0.17.11" + "@esbuild/android-arm64" "0.17.11" + "@esbuild/android-x64" "0.17.11" + "@esbuild/darwin-arm64" "0.17.11" + "@esbuild/darwin-x64" "0.17.11" + "@esbuild/freebsd-arm64" "0.17.11" + "@esbuild/freebsd-x64" "0.17.11" + "@esbuild/linux-arm" "0.17.11" + "@esbuild/linux-arm64" "0.17.11" + "@esbuild/linux-ia32" "0.17.11" + "@esbuild/linux-loong64" "0.17.11" + "@esbuild/linux-mips64el" "0.17.11" + "@esbuild/linux-ppc64" "0.17.11" + "@esbuild/linux-riscv64" "0.17.11" + "@esbuild/linux-s390x" "0.17.11" + "@esbuild/linux-x64" "0.17.11" + "@esbuild/netbsd-x64" "0.17.11" + "@esbuild/openbsd-x64" "0.17.11" + "@esbuild/sunos-x64" "0.17.11" + "@esbuild/win32-arm64" "0.17.11" + "@esbuild/win32-ia32" "0.17.11" + "@esbuild/win32-x64" "0.17.11" escalade@^3.1.1: version "3.1.1" From 6d9a58e7556de614133c2d16ba6ee8e8cebd5fa0 Mon Sep 17 00:00:00 2001 From: Vyacheslav Aristov Date: Sun, 5 Mar 2023 18:30:01 +0800 Subject: [PATCH 03/18] fix(postgres): sync with alter method fails with dataType enum (#15738) * fix(postgres): sync with alter method fails with dataType enum (#7649) * Fixing issues after code review * Fixing issues after code review --- .../src/dialects/postgres/query-generator.js | 2 +- .../core/test/integration/model/sync.test.js | 18 ++++++++++++++++++ .../dialects/postgres/query-generator.test.js | 2 +- packages/core/test/unit/sql/enum.test.js | 4 ++-- 4 files changed, 22 insertions(+), 4 deletions(-) diff --git a/packages/core/src/dialects/postgres/query-generator.js b/packages/core/src/dialects/postgres/query-generator.js index 54abdd2342e2..3f756f3000b2 100644 --- a/packages/core/src/dialects/postgres/query-generator.js +++ b/packages/core/src/dialects/postgres/query-generator.js @@ -791,7 +791,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { values = dataType.toString().match(/^ENUM\(.+\)/)[0]; } - let sql = `CREATE TYPE ${enumName} AS ${values};`; + let sql = `DO ${this.escape(`BEGIN CREATE TYPE ${enumName} AS ${values}; EXCEPTION WHEN duplicate_object THEN null; END`)};`; if (Boolean(options) && options.force === true) { sql = this.pgEnumDrop(tableName, attr) + sql; } diff --git a/packages/core/test/integration/model/sync.test.js b/packages/core/test/integration/model/sync.test.js index 4a0ec10646ee..e3cf5b819e34 100644 --- a/packages/core/test/integration/model/sync.test.js +++ b/packages/core/test/integration/model/sync.test.js @@ -637,6 +637,24 @@ describe(getTestDialectTeaser('Model.sync & Sequelize#sync'), () => { expect(results).to.have.length(1); }); } + + // TODO add support for db2 and mssql dialects + if (dialect !== 'db2' && dialect !== 'mssql') { + it('does not recreate existing enums (#7649)', async () => { + sequelize.define('Media', { + type: DataTypes.ENUM([ + 'video', 'audio', + ]), + }); + await sequelize.sync({ alter: true }); + sequelize.define('Media', { + type: DataTypes.ENUM([ + 'image', 'video', 'audio', + ]), + }); + await sequelize.sync({ alter: true }); + }); + } }); async function getNonPrimaryIndexes(model) { diff --git a/packages/core/test/unit/dialects/postgres/query-generator.test.js b/packages/core/test/unit/dialects/postgres/query-generator.test.js index 1037a23584f0..502f3c00ee4a 100644 --- a/packages/core/test/unit/dialects/postgres/query-generator.test.js +++ b/packages/core/test/unit/dialects/postgres/query-generator.test.js @@ -191,7 +191,7 @@ if (dialect.startsWith('postgres')) { col_1: 'ENUM(\'value 1\', \'value 2\') NOT NULL', col_2: 'ENUM(\'value 3\', \'value 4\') NOT NULL', }], - expectation: 'ALTER TABLE "myTable" ALTER COLUMN "col_1" SET NOT NULL;ALTER TABLE "myTable" ALTER COLUMN "col_1" DROP DEFAULT;CREATE TYPE "public"."enum_myTable_col_1" AS ENUM(\'value 1\', \'value 2\');ALTER TABLE "myTable" ALTER COLUMN "col_1" TYPE "public"."enum_myTable_col_1" USING ("col_1"::"public"."enum_myTable_col_1");ALTER TABLE "myTable" ALTER COLUMN "col_2" SET NOT NULL;ALTER TABLE "myTable" ALTER COLUMN "col_2" DROP DEFAULT;CREATE TYPE "public"."enum_myTable_col_2" AS ENUM(\'value 3\', \'value 4\');ALTER TABLE "myTable" ALTER COLUMN "col_2" TYPE "public"."enum_myTable_col_2" USING ("col_2"::"public"."enum_myTable_col_2");', + expectation: `ALTER TABLE "myTable" ALTER COLUMN "col_1" SET NOT NULL;ALTER TABLE "myTable" ALTER COLUMN "col_1" DROP DEFAULT;DO 'BEGIN CREATE TYPE "public"."enum_myTable_col_1" AS ENUM(''value 1'', ''value 2''); EXCEPTION WHEN duplicate_object THEN null; END';ALTER TABLE "myTable" ALTER COLUMN "col_1" TYPE "public"."enum_myTable_col_1" USING ("col_1"::"public"."enum_myTable_col_1");ALTER TABLE "myTable" ALTER COLUMN "col_2" SET NOT NULL;ALTER TABLE "myTable" ALTER COLUMN "col_2" DROP DEFAULT;DO 'BEGIN CREATE TYPE "public"."enum_myTable_col_2" AS ENUM(''value 3'', ''value 4''); EXCEPTION WHEN duplicate_object THEN null; END';ALTER TABLE "myTable" ALTER COLUMN "col_2" TYPE "public"."enum_myTable_col_2" USING ("col_2"::"public"."enum_myTable_col_2");`, }, ], diff --git a/packages/core/test/unit/sql/enum.test.js b/packages/core/test/unit/sql/enum.test.js index 39c429d9022c..e8c57b9d041c 100644 --- a/packages/core/test/unit/sql/enum.test.js +++ b/packages/core/test/unit/sql/enum.test.js @@ -43,13 +43,13 @@ describe(Support.getTestDialectTeaser('SQL'), () => { describe('pgEnum', () => { it('uses schema #3171', () => { expectsql(sql.pgEnum(FooUser.getTableName(), 'mood', FooUser.getAttributes().mood.type), { - postgres: 'CREATE TYPE "foo"."enum_users_mood" AS ENUM(\'happy\', \'sad\');', + postgres: `DO 'BEGIN CREATE TYPE "foo"."enum_users_mood" AS ENUM(''happy'', ''sad''); EXCEPTION WHEN duplicate_object THEN null; END';`, }); }); it('does add schema when public', () => { expectsql(sql.pgEnum(PublicUser.getTableName(), 'theirMood', PublicUser.getAttributes().mood.type), { - postgres: 'CREATE TYPE "public"."enum_users_theirMood" AS ENUM(\'happy\', \'sad\');', + postgres: `DO 'BEGIN CREATE TYPE "public"."enum_users_theirMood" AS ENUM(''happy'', ''sad''); EXCEPTION WHEN duplicate_object THEN null; END';`, }); }); }); From a52a741278a110a7b92bb1017429e154dc5fef81 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Mar 2023 07:17:11 +0000 Subject: [PATCH 04/18] meta: update dependency rimraf to v4.3.1 (#15744) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 41fbc1fefdac..239411df4ab9 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -102,7 +102,7 @@ "p-timeout": "4.1.0", "pg": "8.9.0", "pg-hstore": "2.3.4", - "rimraf": "4.3.0", + "rimraf": "4.3.1", "sinon": "15.0.1", "sinon-chai": "3.7.0", "snowflake-sdk": "1.6.19", diff --git a/yarn.lock b/yarn.lock index 27cd75e57a67..16e89cb1676f 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7865,10 +7865,10 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@4.3.0: - version "4.3.0" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.3.0.tgz#cd4d9a918c1735197e0ae8217e5eeb4657d42154" - integrity sha512-5qVDXPbByA1qSJEWMv1qAwKsoS22vVpsL2QyxCKBw4gf6XiFo1K3uRLY6uSOOBFDwnqAZtnbILqWKKlzh8bkGg== +rimraf@4.3.1: + version "4.3.1" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.3.1.tgz#ccb3525e39100478acb334fae6d23029b87912ea" + integrity sha512-GfHJHBzFQra23IxDzIdBqhOWfbtdgS1/dCHrDy+yvhpoJY5TdwdT28oWaHWfRpKFDLd3GZnGTx6Mlt4+anbsxQ== dependencies: glob "^9.2.0" From e0e7af4fac81ab95a68d4bfb6b8f6e0108b61f8c Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Tue, 7 Mar 2023 11:07:53 +0000 Subject: [PATCH 05/18] meta: update dependency pg to v8.10.0 (#15745) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- yarn.lock | 18 +++++++++--------- 2 files changed, 10 insertions(+), 10 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 239411df4ab9..62b4178a48e0 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -100,7 +100,7 @@ "p-props": "4.0.0", "p-settle": "4.1.1", "p-timeout": "4.1.0", - "pg": "8.9.0", + "pg": "8.10.0", "pg-hstore": "2.3.4", "rimraf": "4.3.1", "sinon": "15.0.1", diff --git a/yarn.lock b/yarn.lock index 16e89cb1676f..51897237b4c9 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7268,10 +7268,10 @@ pg-int8@1.0.1: resolved "https://registry.yarnpkg.com/pg-int8/-/pg-int8-1.0.1.tgz#943bd463bf5b71b4170115f80f8efc9a0c0eb78c" integrity sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw== -pg-pool@^3.5.2: - version "3.5.2" - resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.5.2.tgz#ed1bed1fb8d79f1c6fd5fb1c99e990fbf9ddf178" - integrity sha512-His3Fh17Z4eg7oANLob6ZvH8xIVen3phEZh2QuyrIl4dQSDVEabNducv6ysROKpDNPSD+12tONZVWfSgMvDD9w== +pg-pool@^3.6.0: + version "3.6.0" + resolved "https://registry.yarnpkg.com/pg-pool/-/pg-pool-3.6.0.tgz#3190df3e4747a0d23e5e9e8045bcd99bda0a712e" + integrity sha512-clFRf2ksqd+F497kWFyM21tMjeikn60oGDmqMT8UBrynEwVEX/5R5xd2sdvdo1cZCFlguORNpVuqxIj+aK4cfQ== pg-protocol@*, pg-protocol@^1.6.0: version "1.6.0" @@ -7289,15 +7289,15 @@ pg-types@^2.1.0, pg-types@^2.2.0: postgres-date "~1.0.4" postgres-interval "^1.1.0" -pg@8.9.0: - version "8.9.0" - resolved "https://registry.yarnpkg.com/pg/-/pg-8.9.0.tgz#73c5d77a854d36b0e185450dacb8b90c669e040b" - integrity sha512-ZJM+qkEbtOHRuXjmvBtOgNOXOtLSbxiMiUVMgE4rV6Zwocy03RicCVvDXgx8l4Biwo8/qORUnEqn2fdQzV7KCg== +pg@8.10.0: + version "8.10.0" + resolved "https://registry.yarnpkg.com/pg/-/pg-8.10.0.tgz#5b8379c9b4a36451d110fc8cd98fc325fe62ad24" + integrity sha512-ke7o7qSTMb47iwzOSaZMfeR7xToFdkE71ifIipOAAaLIM0DYzfOAXlgFFmYUIE2BcJtvnVlGCID84ZzCegE8CQ== dependencies: buffer-writer "2.0.0" packet-reader "1.0.0" pg-connection-string "^2.5.0" - pg-pool "^3.5.2" + pg-pool "^3.6.0" pg-protocol "^1.6.0" pg-types "^2.1.0" pgpass "1.x" From 38037e01f32dfb058d6c885b049e71f0c55f7706 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Thu, 9 Mar 2023 04:33:14 +0000 Subject: [PATCH 06/18] meta: update dependency rimraf to v4.4.0 (#15750) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 62b4178a48e0..6b26d1a4785b 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -102,7 +102,7 @@ "p-timeout": "4.1.0", "pg": "8.10.0", "pg-hstore": "2.3.4", - "rimraf": "4.3.1", + "rimraf": "4.4.0", "sinon": "15.0.1", "sinon-chai": "3.7.0", "snowflake-sdk": "1.6.19", diff --git a/yarn.lock b/yarn.lock index 51897237b4c9..a508560d6209 100644 --- a/yarn.lock +++ b/yarn.lock @@ -7865,10 +7865,10 @@ rimraf@2.6.3: dependencies: glob "^7.1.3" -rimraf@4.3.1: - version "4.3.1" - resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.3.1.tgz#ccb3525e39100478acb334fae6d23029b87912ea" - integrity sha512-GfHJHBzFQra23IxDzIdBqhOWfbtdgS1/dCHrDy+yvhpoJY5TdwdT28oWaHWfRpKFDLd3GZnGTx6Mlt4+anbsxQ== +rimraf@4.4.0: + version "4.4.0" + resolved "https://registry.yarnpkg.com/rimraf/-/rimraf-4.4.0.tgz#c7a9f45bb2ec058d2e60ef9aca5167974313d605" + integrity sha512-X36S+qpCUR0HjXlkDe4NAOhS//aHH0Z+h8Ckf2auGJk3PTnx5rLmrHkwNdbVQuCSUhOyFrlRvFEllZOYE+yZGQ== dependencies: glob "^9.2.0" From f1c15a7bdaf07e3fdde888fa31a54fdff09372c8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Fri, 10 Mar 2023 11:28:15 +0100 Subject: [PATCH 07/18] meta: update publish script (#15757) --- .github/workflows/ci.yml | 11 +++++++++++ package.json | 2 +- 2 files changed, 12 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index cf3f5f7c8a26..63c9a0b8d6c6 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -302,8 +302,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} NPM_TOKEN: ${{ secrets.NPM_TOKEN }} + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} steps: - uses: actions/checkout@ac593985615ec2ede58e132d2e21d2b1cbd6127c # v3.3.0 + with: + # Number of commits to fetch. 0 indicates all history for all branches and tags. + # We need the entire history to generate the changelog properly + fetch-depth: 0 - uses: actions/setup-node@64ed1c7eab4cce3362f8c340dee64e5eaeef8f7c # v3.6.0 with: node-version: 18.x @@ -313,6 +318,12 @@ jobs: name: install-build-artifact-node-18 - name: Extract artifact run: tar -xf install-build-node-18.tar + - name: Configure git + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "bot@sequelize.org" + - name: Set npm auth token + run: npm config set '//registry.npmjs.org/:_authToken' "${NPM_TOKEN}" - run: yarn publish-all - id: sequelize uses: sdepold/github-action-get-latest-release@aa12fcb2943e8899cbcc29ff6f73409b32b48fa1 # master diff --git a/package.json b/package.json index b210ed91f816..9adf54ae366c 100644 --- a/package.json +++ b/package.json @@ -3,7 +3,7 @@ "private": true, "scripts": { "prepare": "husky install", - "publish-all": "lerna publish --conventional-commits --no-private --yes --create-release github --no-changelog", + "publish-all": "lerna publish --conventional-commits --no-private --yes --create-release github", "lint": "eslint . --fix --report-unused-disable-directives", "lint-no-fix": "eslint . --quiet --report-unused-disable-directives", "test-typings": "yarn workspaces run test-typings", From b2fe30f8ed076c02b60beb0d88affa832e6d896b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Fri, 10 Mar 2023 12:55:13 +0100 Subject: [PATCH 08/18] fix: fix unnamed dollar string detection (v7) (#15758) --- packages/core/src/utils/sql.ts | 3 ++- packages/core/test/unit/utils/sql.test.ts | 10 +++++++++- 2 files changed, 11 insertions(+), 2 deletions(-) diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index c873ed4a88a0..e2f13d361c58 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -69,7 +69,7 @@ function mapBindParametersAndReplacements( const remainingString = sqlString.slice(i, sqlString.length); - const dollarStringStartMatch = remainingString.match(/^\$(?[a-z_][0-9a-z_])?(\$)/i); + const dollarStringStartMatch = remainingString.match(/^\$(?[a-z_][0-9a-z_]*)?(\$)/i); const tagName = dollarStringStartMatch?.groups?.name ?? ''; if (currentDollarStringTagName === tagName) { @@ -148,6 +148,7 @@ function mapBindParametersAndReplacements( const dollarStringStartMatch = remainingString.match(/^\$(?[a-z_][0-9a-z_]*)?\$/i); if (dollarStringStartMatch) { currentDollarStringTagName = dollarStringStartMatch.groups?.name ?? ''; + i += dollarStringStartMatch[0].length - 1; continue; } diff --git a/packages/core/test/unit/utils/sql.test.ts b/packages/core/test/unit/utils/sql.test.ts index c4eba9eb9b74..ad435ed10fc4 100644 --- a/packages/core/test/unit/utils/sql.test.ts +++ b/packages/core/test/unit/utils/sql.test.ts @@ -670,7 +670,15 @@ describe('injectReplacements (positional replacements)', () => { }); }); - it('does consider the token to be a bind parameter if it is located after a $ quoted string', () => { + it('does not consider the token to be a replacement if it is in an unnamed $ quoted string', () => { + const sql = injectReplacements(`SELECT $$ ? $$`, dialect, [1]); + + expectsql(sql, { + default: `SELECT $$ ? $$`, + }); + }); + + it('does consider the token to be a replacement if it is located after a $ quoted string', () => { const sql = injectReplacements(`SELECT $$ abc $$ AS string FROM users WHERE id = ?`, dialect, [1]); expectsql(sql, { From 27312bdc849c25c60cb88a677c2854e57c79b94e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Fri, 10 Mar 2023 13:24:46 +0100 Subject: [PATCH 09/18] fix: prevent BelongsTo's inverse association from itself creating a BelongsTo (#15756) --- packages/core/src/associations/belongs-to.ts | 4 +- packages/core/src/associations/has-many.ts | 22 ++++++---- packages/core/src/associations/has-one.ts | 24 +++++----- .../test/unit/associations/belongs-to.test.ts | 44 +++++++++++++++++-- 4 files changed, 68 insertions(+), 26 deletions(-) diff --git a/packages/core/src/associations/belongs-to.ts b/packages/core/src/associations/belongs-to.ts index 00804167af9c..4a99077115f0 100644 --- a/packages/core/src/associations/belongs-to.ts +++ b/packages/core/src/associations/belongs-to.ts @@ -221,11 +221,11 @@ export class BelongsTo< switch (options.inverse.type) { case 'hasMany': - HasMany.associate(secret, target, source, passDown, this); + HasMany.associate(secret, target, source, passDown, this, this); break; case 'hasOne': - HasOne.associate(secret, target, source, passDown, this); + HasOne.associate(secret, target, source, passDown, this, this); break; default: diff --git a/packages/core/src/associations/has-many.ts b/packages/core/src/associations/has-many.ts index 7c32b0ec502a..64169653fb63 100644 --- a/packages/core/src/associations/has-many.ts +++ b/packages/core/src/associations/has-many.ts @@ -87,6 +87,7 @@ export class HasMany< target: ModelStatic, options: NormalizedHasManyOptions, parent?: Association, + inverse?: BelongsTo, ) { if ( options.sourceKey @@ -105,7 +106,7 @@ export class HasMany< super(secret, source, target, options, parent); - this.inverse = BelongsTo.associate(secret, target, source, removeUndefined({ + this.inverse = inverse ?? BelongsTo.associate(secret, target, source, removeUndefined({ as: options.inverse?.as, scope: options.inverse?.scope, foreignKey: options.foreignKey, @@ -140,12 +141,13 @@ export class HasMany< T extends Model, SourceKey extends AttributeNames, TargetKey extends AttributeNames, - >( + >( secret: symbol, source: ModelStatic, target: ModelStatic, options: HasManyOptions = {}, parent?: Association, + inverse?: BelongsTo, ): HasMany { return defineAssociation< @@ -160,7 +162,7 @@ export class HasMany< throw new AssociationError('Both options "as" and "inverse.as" must be defined for hasMany self-associations, and their value must be different.'); } - return new HasMany(secret, source, target, normalizedOptions, parent); + return new HasMany(secret, source, target, normalizedOptions, parent, inverse); }); } @@ -696,13 +698,12 @@ export interface HasManyCreateAssociationMixinOptions * @see Model.hasMany */ export type HasManyCreateAssociationMixin< - TModel extends Model, - TForeignKey extends keyof CreationAttributes = never, - TScope extends keyof CreationAttributes = never, + Target extends Model, + ExcludedAttributes extends keyof CreationAttributes = never, > = ( - values?: Omit, TForeignKey | TScope>, - options?: HasManyCreateAssociationMixinOptions -) => Promise; + values?: Omit, ExcludedAttributes>, + options?: HasManyCreateAssociationMixinOptions +) => Promise; /** * The options for the removeAssociation mixin of the hasMany association. @@ -807,6 +808,9 @@ export interface HasManyHasAssociationsMixinOptions * * @see Model.hasMany */ +// TODO: this should be renamed to "HasManyHasAllAssociationsMixin", +// we should also add a "HasManyHasAnyAssociationsMixin" +// and "HasManyHasAssociationsMixin" should instead return a Map of id -> boolean or WeakMap of instance -> boolean export type HasManyHasAssociationsMixin = ( targets: Array, options?: HasManyHasAssociationsMixinOptions diff --git a/packages/core/src/associations/has-one.ts b/packages/core/src/associations/has-one.ts index c013e3e7b9ed..1551d483acca 100644 --- a/packages/core/src/associations/has-one.ts +++ b/packages/core/src/associations/has-one.ts @@ -88,6 +88,7 @@ export class HasOne< target: ModelStatic, options: NormalizedHasOneOptions, parent?: Association, + inverse?: BelongsTo, ) { if ( options?.sourceKey @@ -97,14 +98,12 @@ export class HasOne< } if ('keyType' in options) { - throw new TypeError('Option "keyType" has been removed from the BelongsTo\'s options. Set "foreignKey.type" instead.'); + throw new TypeError(`Option "keyType" has been removed from the BelongsTo's options. Set "foreignKey.type" instead.`); } - // TODO: throw is source model has a composite primary key. - super(secret, source, target, options, parent); - this.inverse = BelongsTo.associate(secret, target, source, removeUndefined({ + this.inverse = inverse ?? BelongsTo.associate(secret, target, source, removeUndefined({ as: options.inverse?.as, scope: options.inverse?.scope, foreignKey: options.foreignKey, @@ -134,12 +133,13 @@ export class HasOne< T extends Model, SourceKey extends AttributeNames, TargetKey extends AttributeNames, - >( + >( secret: symbol, source: ModelStatic, target: ModelStatic, options: HasOneOptions = {}, parent?: Association, + inverse?: BelongsTo, ): HasOne { return defineAssociation< HasOne, @@ -156,7 +156,7 @@ This is because hasOne associations automatically create the corresponding belon If having two associations does not make sense (for instance a "spouse" association from user to user), consider using belongsTo instead of hasOne.`); } - return new HasOne(secret, source, target, normalizedOptions, parent); + return new HasOne(secret, source, target, normalizedOptions, parent, inverse); }); } @@ -457,8 +457,10 @@ export interface HasOneCreateAssociationMixinOptions * * @see Model.hasOne */ -export type HasOneCreateAssociationMixin = ( - // TODO: omit the foreign key from CreationAttributes once we have a way to determine which key is the foreign key in typings - values?: CreationAttributes, - options?: HasOneCreateAssociationMixinOptions -) => Promise; +export type HasOneCreateAssociationMixin< + Target extends Model, + ExcludedAttributes extends keyof CreationAttributes = never, +> = ( + values?: Omit, ExcludedAttributes>, + options?: HasOneCreateAssociationMixinOptions +) => Promise; diff --git a/packages/core/test/unit/associations/belongs-to.test.ts b/packages/core/test/unit/associations/belongs-to.test.ts index 7c56a09fc0cc..e40dd4f3f5f8 100644 --- a/packages/core/test/unit/associations/belongs-to.test.ts +++ b/packages/core/test/unit/associations/belongs-to.test.ts @@ -1,8 +1,9 @@ import { expect } from 'chai'; import each from 'lodash/each'; import sinon from 'sinon'; -import type { ModelStatic } from '@sequelize/core'; -import { DataTypes, Deferrable } from '@sequelize/core'; +import type { CreationOptional, ModelStatic, NonAttribute } from '@sequelize/core'; +import { DataTypes, Deferrable, Model } from '@sequelize/core'; +import { BelongsTo } from '@sequelize/core/decorators-legacy'; import { sequelize, getTestDialectTeaser } from '../../support'; describe(getTestDialectTeaser('belongsTo'), () => { @@ -103,6 +104,39 @@ describe(getTestDialectTeaser('belongsTo'), () => { expect(A.getAttributes().BId.references?.deferrable).to.equal(Deferrable.INITIALLY_IMMEDIATE); }); + // See https://github.com/sequelize/sequelize/issues/15625 for more details + it('should be possible to define two belongsTo associations with the same target #15625', () => { + class Post extends Model { + declare id: CreationOptional; + + @BelongsTo(() => Author, { + foreignKey: 'authorId', + targetKey: 'id', + inverse: { as: 'myBooks', type: 'hasMany' }, + }) + declare author: NonAttribute; + + declare authorId: number; + + @BelongsTo(() => Author, { + foreignKey: 'coAuthorId', + targetKey: 'id', + inverse: { as: 'notMyBooks', type: 'hasMany' }, + }) + declare coAuthor: NonAttribute; + + declare coAuthorId: number; + } + + class Author extends Model { + declare id: number; + } + + // This would previously fail because the BelongsTo association would create an hasMany association which would + // then try to create a redundant belongsTo association + sequelize.addModels([Post, Author]); + }); + describe('association hooks', () => { let Projects: ModelStatic; let Tasks: ModelStatic; @@ -112,7 +146,7 @@ describe(getTestDialectTeaser('belongsTo'), () => { Tasks = sequelize.define('Task', { title: DataTypes.STRING }); }); - describe('beforeBelongsToAssociate', () => { + describe('beforeAssociate', () => { it('should trigger', () => { const beforeAssociate = sinon.spy(); Projects.beforeAssociate(beforeAssociate); @@ -138,7 +172,8 @@ describe(getTestDialectTeaser('belongsTo'), () => { expect(beforeAssociate).to.not.have.been.called; }); }); - describe('afterBelongsToAssociate', () => { + + describe('afterAssociate', () => { it('should trigger', () => { const afterAssociate = sinon.spy(); Projects.afterAssociate(afterAssociate); @@ -159,6 +194,7 @@ describe(getTestDialectTeaser('belongsTo'), () => { expect(afterAssociateArgs[1].sequelize.constructor.name).to.equal('Sequelize'); }); + it('should not trigger association hooks', () => { const afterAssociate = sinon.spy(); Projects.afterAssociate(afterAssociate); From 97e95b2d4f6ace8a1301aa63efedc3a339346baa Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Fri, 10 Mar 2023 13:51:27 +0100 Subject: [PATCH 10/18] meta: fix tag for action in authors workflow (#15760) This should fix the Renovate warning. --- .github/workflows/authors.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/authors.yml b/.github/workflows/authors.yml index f2e9edaab936..0051d3a6b8c1 100644 --- a/.github/workflows/authors.yml +++ b/.github/workflows/authors.yml @@ -22,7 +22,7 @@ jobs: fetch-depth: "0" # This is required to actually get all the authors persist-credentials: false - run: "dev/update-authors.js" # Run the AUTHORS tool - - uses: gr2m/create-or-update-pull-request-action@77596e3166f328b24613f7082ab30bf2d93079d5 # v1 # Create a PR or update the Action's existing PR + - uses: gr2m/create-or-update-pull-request-action@df20b2c073090271599a08c55ae26e0c3522b329 # v1.9.2 # Create a PR or update the Action's existing PR env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} with: From 0c7f0857d5e14646250dfa79b6b7b24f813d35ec Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Mar 2023 19:25:21 +0000 Subject: [PATCH 11/18] meta: update dependency @types/node to v18.15.0 (#15754) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- packages/core/package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/packages/core/package.json b/packages/core/package.json index 6b26d1a4785b..bbcef94c7978 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -71,7 +71,7 @@ "@types/ibm_db": "2.0.12", "@types/lodash": "4.14.191", "@types/mocha": "10.0.1", - "@types/node": "18.14.6", + "@types/node": "18.15.0", "@types/pg": "8.6.6", "@types/semver": "7.3.13", "@types/sinon": "10.0.13", diff --git a/yarn.lock b/yarn.lock index a508560d6209..1bdcd416df59 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1343,10 +1343,10 @@ resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.0.tgz#94c47b9217bbac49d4a67a967fdcdeed89ebb7d0" integrity sha512-5EWrvLmglK+imbCJY0+INViFWUHg1AHel1sq4ZVSfdcNqGy9Edv3UB9IIzzg+xPaUcAgZYcfVs2fBcwDeZzU0A== -"@types/node@18.14.6": - version "18.14.6" - resolved "https://registry.yarnpkg.com/@types/node/-/node-18.14.6.tgz#ae1973dd2b1eeb1825695bb11ebfb746d27e3e93" - integrity sha512-93+VvleD3mXwlLI/xASjw0FzKcwzl3OdTCzm1LaRfqgS21gfFtK3zDXM5Op9TeeMsJVOaJ2VRDpT9q4Y3d0AvA== +"@types/node@18.15.0": + version "18.15.0" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.0.tgz#286a65e3fdffd691e170541e6ecb0410b16a38be" + integrity sha512-z6nr0TTEOBGkzLGmbypWOGnpSpSIBorEhC4L+4HeQ2iezKCi4f77kyslRwvHeNitymGQ+oFyIWGP96l/DPSV9w== "@types/node@^17.0.10": version "17.0.45" From 29ff75c9452d9770d2c10417405f10b483454110 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Fri, 10 Mar 2023 21:15:52 +0100 Subject: [PATCH 12/18] meta: update dependency typedoc-plugin-mdn-links to v3 (#15755) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 8 ++++---- 2 files changed, 5 insertions(+), 5 deletions(-) diff --git a/package.json b/package.json index 9adf54ae366c..4ea0a48449d7 100644 --- a/package.json +++ b/package.json @@ -61,7 +61,7 @@ "ts-node": "10.9.1", "typedoc": "0.23.26", "typedoc-plugin-carbon-ads": "1.1.6", - "typedoc-plugin-mdn-links": "2.0.2", + "typedoc-plugin-mdn-links": "3.0.3", "typedoc-plugin-missing-exports": "1.0.0", "typescript": "4.9.5" }, diff --git a/yarn.lock b/yarn.lock index 1bdcd416df59..e20d77014dec 100644 --- a/yarn.lock +++ b/yarn.lock @@ -8963,10 +8963,10 @@ typedoc-plugin-carbon-ads@1.1.6: dependencies: typescript "^4.7.4" -typedoc-plugin-mdn-links@2.0.2: - version "2.0.2" - resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-2.0.2.tgz#5a6aa268950c41a1437b57e2c72ed77f8ba323d2" - integrity sha512-Fzjvfsj3rxvmZNqWRvq9JTGBkOkrPp0kBtvJCJ4U5Jm14OF1KoRErtmwgVQcPLA5Xs8h5I/W4uZBaL8SDHsgxQ== +typedoc-plugin-mdn-links@3.0.3: + version "3.0.3" + resolved "https://registry.yarnpkg.com/typedoc-plugin-mdn-links/-/typedoc-plugin-mdn-links-3.0.3.tgz#da8d1a9750d57333e6c21717b38bfc13d4058de2" + integrity sha512-NXhIpwQnsg7BcyMCHVqj3tUK+DL4g3Bt96JbFl4APzTGFkA+iM6GfZ/fn3TAqJ8O0CXG5R9BfWxolw1m1omNuQ== typedoc-plugin-missing-exports@1.0.0: version "1.0.0" From 84b0b9e1dd10ca7fd2a31a81258b91cf9fe76957 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 11 Mar 2023 05:00:06 +0000 Subject: [PATCH 13/18] meta: update dependency eslint to v8.36.0 (#15764) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 57 +++++++++++++++++++++++++++++++++++----------------- 2 files changed, 40 insertions(+), 19 deletions(-) diff --git a/package.json b/package.json index 4ea0a48449d7..39b2df953434 100644 --- a/package.json +++ b/package.json @@ -48,7 +48,7 @@ "@rushstack/eslint-patch": "1.2.0", "chai": "4.3.7", "esbuild": "0.17.11", - "eslint": "8.35.0", + "eslint": "8.36.0", "eslint-plugin-jsdoc": "40.0.1", "eslint-plugin-mocha": "10.1.0", "fast-glob": "3.2.12", diff --git a/yarn.lock b/yarn.lock index e20d77014dec..9983fdc31770 100644 --- a/yarn.lock +++ b/yarn.lock @@ -583,6 +583,18 @@ dependencies: eslint-visitor-keys "^3.3.0" +"@eslint-community/eslint-utils@^4.2.0": + version "4.2.0" + resolved "https://registry.yarnpkg.com/@eslint-community/eslint-utils/-/eslint-utils-4.2.0.tgz#a831e6e468b4b2b5ae42bf658bea015bf10bc518" + integrity sha512-gB8T4H4DEfX2IV9zGDJPOBgP1e/DbfCPDTtEqUMckpvzS1OYtva8JdFYBqMwYk7xAQ429WGF/UPqn8uQ//h2vQ== + dependencies: + eslint-visitor-keys "^3.3.0" + +"@eslint-community/regexpp@^4.4.0": + version "4.4.0" + resolved "https://registry.yarnpkg.com/@eslint-community/regexpp/-/regexpp-4.4.0.tgz#3e61c564fcd6b921cb789838631c5ee44df09403" + integrity sha512-A9983Q0LnDGdLPjxyXQ00sbV+K+O+ko2Dr+CZigbHWtX9pNfxlaBkMR8X1CztI73zuEyEBXTVjx7CE+/VSwDiQ== + "@eslint/eslintrc@^1.4.1": version "1.4.1" resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-1.4.1.tgz#af58772019a2d271b7e2d4c23ff4ddcba3ccfb3e" @@ -598,14 +610,14 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/eslintrc@^2.0.0": - version "2.0.0" - resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.0.tgz#943309d8697c52fc82c076e90c1c74fbbe69dbff" - integrity sha512-fluIaaV+GyV24CCu/ggiHdV+j4RNh85yQnAYS/G2mZODZgGmmlrgCydjUcV3YvxCm9x8nMAfThsqTni4KiXT4A== +"@eslint/eslintrc@^2.0.1": + version "2.0.1" + resolved "https://registry.yarnpkg.com/@eslint/eslintrc/-/eslintrc-2.0.1.tgz#7888fe7ec8f21bc26d646dbd2c11cd776e21192d" + integrity sha512-eFRmABvW2E5Ho6f5fHLqgena46rOj7r7OKHYfLElqcBfGFHHpjBhivyi5+jOEQuSpdc/1phIZJlbC2te+tZNIw== dependencies: ajv "^6.12.4" debug "^4.3.2" - espree "^9.4.0" + espree "^9.5.0" globals "^13.19.0" ignore "^5.2.0" import-fresh "^3.2.1" @@ -613,10 +625,10 @@ minimatch "^3.1.2" strip-json-comments "^3.1.1" -"@eslint/js@8.35.0": - version "8.35.0" - resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.35.0.tgz#b7569632b0b788a0ca0e438235154e45d42813a7" - integrity sha512-JXdzbRiWclLVoD8sNUjR443VVlYqiYmDVT6rGUEIEHU5YJW0gaVZwV2xgM7D4arkvASqD0IlLUVjHiFuxaftRw== +"@eslint/js@8.36.0": + version "8.36.0" + resolved "https://registry.yarnpkg.com/@eslint/js/-/js-8.36.0.tgz#9837f768c03a1e4a30bd304a64fb8844f0e72efe" + integrity sha512-lxJ9R5ygVm8ZWgYdUweoq5ownDlJ4upvoWmO4eLxBYHdMo+vZ/Rx0EN6MbKWDJOSUGrqJy2Gt+Dyv/VKml0fjg== "@gar/promisify@^1.0.1", "@gar/promisify@^1.1.3": version "1.1.3" @@ -3554,13 +3566,15 @@ eslint-visitor-keys@^3.3.0: resolved "https://registry.yarnpkg.com/eslint-visitor-keys/-/eslint-visitor-keys-3.3.0.tgz#f6480fa6b1f30efe2d1968aa8ac745b862469826" integrity sha512-mQ+suqKJVyeuwGYHAdjMFqjCyfl8+Ldnxuyp3ldiMBFKkvytrXUZWaiPCEav8qDHKty44bD+qV1IP4T+w+xXRA== -eslint@8.35.0: - version "8.35.0" - resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.35.0.tgz#fffad7c7e326bae606f0e8f436a6158566d42323" - integrity sha512-BxAf1fVL7w+JLRQhWl2pzGeSiGqbWumV4WNvc9Rhp6tiCtm4oHnyPBSEtMGZwrQgudFQ+otqzWoPB7x+hxoWsw== +eslint@8.36.0: + version "8.36.0" + resolved "https://registry.yarnpkg.com/eslint/-/eslint-8.36.0.tgz#1bd72202200a5492f91803b113fb8a83b11285cf" + integrity sha512-Y956lmS7vDqomxlaaQAHVmeb4tNMp2FWIvU/RnU5BD3IKMD/MJPr76xdyr68P8tV1iNMvN2mRK0yy3c+UjL+bw== dependencies: - "@eslint/eslintrc" "^2.0.0" - "@eslint/js" "8.35.0" + "@eslint-community/eslint-utils" "^4.2.0" + "@eslint-community/regexpp" "^4.4.0" + "@eslint/eslintrc" "^2.0.1" + "@eslint/js" "8.36.0" "@humanwhocodes/config-array" "^0.11.8" "@humanwhocodes/module-importer" "^1.0.1" "@nodelib/fs.walk" "^1.2.8" @@ -3571,9 +3585,8 @@ eslint@8.35.0: doctrine "^3.0.0" escape-string-regexp "^4.0.0" eslint-scope "^7.1.1" - eslint-utils "^3.0.0" eslint-visitor-keys "^3.3.0" - espree "^9.4.0" + espree "^9.5.0" esquery "^1.4.2" esutils "^2.0.2" fast-deep-equal "^3.1.3" @@ -3595,7 +3608,6 @@ eslint@8.35.0: minimatch "^3.1.2" natural-compare "^1.4.0" optionator "^0.9.1" - regexpp "^3.2.0" strip-ansi "^6.0.1" strip-json-comments "^3.1.0" text-table "^0.2.0" @@ -3706,6 +3718,15 @@ espree@^9.4.0: acorn-jsx "^5.3.2" eslint-visitor-keys "^3.3.0" +espree@^9.5.0: + version "9.5.0" + resolved "https://registry.yarnpkg.com/espree/-/espree-9.5.0.tgz#3646d4e3f58907464edba852fa047e6a27bdf113" + integrity sha512-JPbJGhKc47++oo4JkEoTe2wjy4fmMwvFpgJT9cQzmfXKp22Dr6Hf1tdCteLz1h0P3t+mGvWZ+4Uankvh8+c6zw== + dependencies: + acorn "^8.8.0" + acorn-jsx "^5.3.2" + eslint-visitor-keys "^3.3.0" + esprima@^4.0.0, esprima@^4.0.1: version "4.0.1" resolved "https://registry.yarnpkg.com/esprima/-/esprima-4.0.1.tgz#13b04cdb3e6c5d19df91ab6987a8695619b0aa71" From 1ac9af583f9a98c4102375294352924549bcf472 Mon Sep 17 00:00:00 2001 From: "renovate[bot]" <29139614+renovate[bot]@users.noreply.github.com> Date: Sat, 11 Mar 2023 08:23:38 +0000 Subject: [PATCH 14/18] meta: update dependency lint-staged to v13.2.0 (#15762) Co-authored-by: renovate[bot] <29139614+renovate[bot]@users.noreply.github.com> --- package.json | 2 +- yarn.lock | 67 ++++++++++++++++++++++++++++------------------------ 2 files changed, 37 insertions(+), 32 deletions(-) diff --git a/package.json b/package.json index 39b2df953434..2500e928ed44 100644 --- a/package.json +++ b/package.json @@ -53,7 +53,7 @@ "eslint-plugin-mocha": "10.1.0", "fast-glob": "3.2.12", "lerna": "6.5.1", - "lint-staged": "13.1.2", + "lint-staged": "13.2.0", "markdownlint-cli": "0.32.2", "mocha": "10.2.0", "node-hook": "1.0.0", diff --git a/yarn.lock b/yarn.lock index 9983fdc31770..9c27a476bf9e 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2315,6 +2315,11 @@ chalk@4.1.0: ansi-styles "^4.1.0" supports-color "^7.1.0" +chalk@5.2.0: + version "5.2.0" + resolved "https://registry.yarnpkg.com/chalk/-/chalk-5.2.0.tgz#249623b7d66869c673699fb66d65723e54dfcfb3" + integrity sha512-ree3Gqw/nazQAPuJJEy+avdl7QfZMcUvmHIKgEZkGL+xOBzRvup5Hxo6LHuMceSxOabuJLJm5Yp/92R9eMmMvA== + chalk@^2.0.0, chalk@^2.1.0: version "2.4.2" resolved "https://registry.yarnpkg.com/chalk/-/chalk-2.4.2.tgz#cd42541677a54333cf541a49108c1432b44c9424" @@ -2548,10 +2553,10 @@ combined-stream@^1.0.8: dependencies: delayed-stream "~1.0.0" -commander@^9.4.1: - version "9.5.0" - resolved "https://registry.yarnpkg.com/commander/-/commander-9.5.0.tgz#bc08d1eb5cedf7ccb797a96199d41c7bc3e60d30" - integrity sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ== +commander@^10.0.0: + version "10.0.0" + resolved "https://registry.yarnpkg.com/commander/-/commander-10.0.0.tgz#71797971162cd3cf65f0b9d24eb28f8d303acdf1" + integrity sha512-zS5PnTI22FIRM6ylNW8G4Ap0IEOyk62fhLSD0+uHRT9McRCLGpkVNvao4bjimpK/GShynyQkFFxHhwMcETmduA== commander@~9.4.0: version "9.4.1" @@ -3806,14 +3811,14 @@ execa@^5.0.0: signal-exit "^3.0.3" strip-final-newline "^2.0.0" -execa@^6.1.0: - version "6.1.0" - resolved "https://registry.yarnpkg.com/execa/-/execa-6.1.0.tgz#cea16dee211ff011246556388effa0818394fb20" - integrity sha512-QVWlX2e50heYJcCPG0iWtf8r0xjEYfz/OYLGDYH+IyjWezzPNxz63qNFOu0l4YftGWuizFVZHHs8PrLU5p2IDA== +execa@^7.0.0: + version "7.0.0" + resolved "https://registry.yarnpkg.com/execa/-/execa-7.0.0.tgz#2a44e20e73797f6c2df23889927972386157d7e4" + integrity sha512-tQbH0pH/8LHTnwTrsKWideqi6rFB/QNUawEwrn+WHyz7PX1Tuz2u7wfTvbaNBdP5JD5LVWxNo8/A8CHNZ3bV6g== dependencies: cross-spawn "^7.0.3" get-stream "^6.0.1" - human-signals "^3.0.1" + human-signals "^4.3.0" is-stream "^3.0.0" merge-stream "^2.0.0" npm-run-path "^5.1.0" @@ -4672,10 +4677,10 @@ human-signals@^2.1.0: resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-2.1.0.tgz#dc91fcba42e4d06e4abaed33b3e7a3c02f514ea0" integrity sha512-B4FFZ6q/T2jhhksgkbEW3HBvWIfDW85snkQgawt07S7J5QXTk6BkNV+0yAeZrM5QpMAdYlocGoljn0sJ/WQkFw== -human-signals@^3.0.1: - version "3.0.1" - resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-3.0.1.tgz#c740920859dafa50e5a3222da9d3bf4bb0e5eef5" - integrity sha512-rQLskxnM/5OCldHo+wNXbpVgDn5A17CUoKX+7Sokwaknlq7CdSnphy0W39GU8dw59XiCXmFXDg4fRuckQRKewQ== +human-signals@^4.3.0: + version "4.3.0" + resolved "https://registry.yarnpkg.com/human-signals/-/human-signals-4.3.0.tgz#2095c3cd5afae40049403d4b811235b03879db50" + integrity sha512-zyzVyMjpGBX2+6cDVZeFPCdtOtdsxOeseRhB9tkQ6xXmGUNrcnBzdEKPy3VPNYz+4gy1oukVOXcrJCunSyc6QQ== humanize-ms@^1.2.0, humanize-ms@^1.2.1: version "1.2.1" @@ -5633,10 +5638,10 @@ libnpmpublish@6.0.4: semver "^7.3.7" ssri "^9.0.0" -lilconfig@2.0.6: - version "2.0.6" - resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.0.6.tgz#32a384558bd58af3d4c6e077dd1ad1d397bc69d4" - integrity sha512-9JROoBW7pobfsx+Sq2JsASvCo6Pfo6WWoUW79HuB1BCoBXD4PLWJPqDF6fNj67pqBYTbAHkE57M1kS/+L1neOg== +lilconfig@2.1.0: + version "2.1.0" + resolved "https://registry.yarnpkg.com/lilconfig/-/lilconfig-2.1.0.tgz#78e23ac89ebb7e1bfbf25b18043de756548e7f52" + integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ== lines-and-columns@^1.1.6: version "1.2.4" @@ -5655,31 +5660,31 @@ linkify-it@^4.0.1: dependencies: uc.micro "^1.0.1" -lint-staged@13.1.2: - version "13.1.2" - resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.1.2.tgz#443636a0cfd834d5518d57d228130dc04c83d6fb" - integrity sha512-K9b4FPbWkpnupvK3WXZLbgu9pchUJ6N7TtVZjbaPsoizkqFUDkUReUL25xdrCljJs7uLUF3tZ7nVPeo/6lp+6w== +lint-staged@13.2.0: + version "13.2.0" + resolved "https://registry.yarnpkg.com/lint-staged/-/lint-staged-13.2.0.tgz#b7abaf79c91cd36d824f17b23a4ce5209206126a" + integrity sha512-GbyK5iWinax5Dfw5obm2g2ccUiZXNGtAS4mCbJ0Lv4rq6iEtfBSjOYdcbOtAIFtM114t0vdpViDDetjVTSd8Vw== dependencies: + chalk "5.2.0" cli-truncate "^3.1.0" - colorette "^2.0.19" - commander "^9.4.1" + commander "^10.0.0" debug "^4.3.4" - execa "^6.1.0" - lilconfig "2.0.6" - listr2 "^5.0.5" + execa "^7.0.0" + lilconfig "2.1.0" + listr2 "^5.0.7" micromatch "^4.0.5" normalize-path "^3.0.0" - object-inspect "^1.12.2" + object-inspect "^1.12.3" pidtree "^0.6.0" string-argv "^0.3.1" - yaml "^2.1.3" + yaml "^2.2.1" listenercount@~1.0.1: version "1.0.1" resolved "https://registry.yarnpkg.com/listenercount/-/listenercount-1.0.1.tgz#84c8a72ab59c4725321480c975e6508342e70937" integrity sha512-3mk/Zag0+IJxeDrxSgaDPy4zZ3w05PRZeJNnlWhzFz5OkX49J4krc+A8X2d2M69vGMBEX0uyl8M+W+8gH+kBqQ== -listr2@^5.0.5: +listr2@^5.0.7: version "5.0.7" resolved "https://registry.yarnpkg.com/listr2/-/listr2-5.0.7.tgz#de69ccc4caf6bea7da03c74f7a2ffecf3904bd53" integrity sha512-MD+qXHPmtivrHIDRwPYdfNkrzqDiuaKU/rfBcec3WMyMF3xylQj3jMq344OtvQxz7zaCFViRAeqlr2AFhPvXHw== @@ -6743,7 +6748,7 @@ object-assign@^4.0.1, object-assign@^4.1.1: resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== -object-inspect@^1.12.2, object-inspect@^1.9.0: +object-inspect@^1.12.2, object-inspect@^1.12.3, object-inspect@^1.9.0: version "1.12.3" resolved "https://registry.yarnpkg.com/object-inspect/-/object-inspect-1.12.3.tgz#ba62dffd67ee256c8c086dfae69e016cd1f198b9" integrity sha512-geUvdk7c+eizMNUDkRpW1wJwgfOiOeHbxBR/hLXK1aT6zmVSO0jsQcs7fj6MGw89jC/cjGfLcNOrtMYtGqm81g== @@ -9586,7 +9591,7 @@ yaml@^1.10.0: resolved "https://registry.yarnpkg.com/yaml/-/yaml-1.10.2.tgz#2301c5ffbf12b467de8da2333a459e29e7920e4b" integrity sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg== -yaml@^2.1.3: +yaml@^2.2.1: version "2.2.1" resolved "https://registry.yarnpkg.com/yaml/-/yaml-2.2.1.tgz#3014bf0482dcd15147aa8e56109ce8632cd60ce4" integrity sha512-e0WHiYql7+9wr4cWMx3TVQrNwejKaEe7/rHNmQmqRjazfOP5W8PB6Jpebb5o6fIapbz9o9+2ipcaTM2ZwDI6lw== From d0d2a6680f2a986e5ea547fe5e7661f3ce07b058 Mon Sep 17 00:00:00 2001 From: Rik Smale <13023439+WikiRik@users.noreply.github.com> Date: Sat, 11 Mar 2023 14:37:09 +0100 Subject: [PATCH 15/18] meta: small tweaks to associations and decorators (#15763) --- .../core/src/associations/belongs-to-many.ts | 28 ++++++++++--------- packages/core/src/associations/belongs-to.ts | 4 ++- packages/core/src/associations/has-many.ts | 3 +- .../src/decorators/legacy/associations.ts | 2 +- packages/core/src/decorators/shared/model.ts | 4 +-- packages/core/src/errors/base-error.ts | 4 +-- 6 files changed, 24 insertions(+), 21 deletions(-) diff --git a/packages/core/src/associations/belongs-to-many.ts b/packages/core/src/associations/belongs-to-many.ts index 33f4063c92af..b50f0427f2df 100644 --- a/packages/core/src/associations/belongs-to-many.ts +++ b/packages/core/src/associations/belongs-to-many.ts @@ -586,7 +586,7 @@ Add your own primary key to the through model, on different attributes than the const newInstances = newInstancesOrPrimaryKeys === null ? [] : this.toInstanceArray(newInstancesOrPrimaryKeys); - const where = { + const where: WhereOptions = { [foreignKey]: sourceInstance.get(sourceKey), ...this.through.scope, }; @@ -651,18 +651,18 @@ Add your own primary key to the through model, on different attributes than the const newInstances = this.toInstanceArray(newInstancesOrPrimaryKeys); + const where: WhereOptions = { + [this.foreignKey]: sourceInstance.get(this.sourceKey), + [this.otherKey]: newInstances.map(newInstance => newInstance.get(this.targetKey)), + ...this.through.scope, + }; + let currentRows: any[] = []; if (this.through?.unique ?? true) { currentRows = await this.through.model.findAll({ ...options, raw: true, - where: { - [this.foreignKey]: sourceInstance.get(this.sourceKey), - [this.otherKey]: newInstances.map(newInstance => newInstance.get(this.targetKey)), - ...this.through.scope, - }, - // force this option to be false, in case the user enabled - rejectOnEmpty: false, + where, }); } @@ -749,12 +749,14 @@ Add your own primary key to the through model, on different attributes than the throughAttributes = {}; } + const where: WhereOptions = { + [foreignKey]: sourceInstance.get(sourceKey), + [otherKey]: changedTarget.get(targetKey), + }; + promises.push(this.through.model.update(attributes, { ...options, - where: { - [foreignKey]: sourceInstance.get(sourceKey), - [otherKey]: changedTarget.get(targetKey), - }, + where, })); } @@ -775,7 +777,7 @@ Add your own primary key to the through model, on different attributes than the ): Promise { const targetInstance = this.toInstanceArray(targetInstanceOrPks); - const where = { + const where: WhereOptions = { [this.foreignKey]: sourceInstance.get(this.sourceKey), [this.otherKey]: targetInstance.map(newInstance => newInstance.get(this.targetKey)), ...this.through.scope, diff --git a/packages/core/src/associations/belongs-to.ts b/packages/core/src/associations/belongs-to.ts index 4a99077115f0..d78c75123a1e 100644 --- a/packages/core/src/associations/belongs-to.ts +++ b/packages/core/src/associations/belongs-to.ts @@ -316,7 +316,9 @@ export class BelongsTo< if (instances.length > 1) { where[this.targetKey] = { - [Op.in]: instances.map(_instance => _instance.get(this.foreignKey)), + [Op.in]: instances.map(instance => instance.get(this.foreignKey)) + // only fetch entities that actually have a foreign key set + .filter(foreignKey => foreignKey != null), }; } else { const foreignKeyValue = instances[0].get(this.foreignKey); diff --git a/packages/core/src/associations/has-many.ts b/packages/core/src/associations/has-many.ts index 64169653fb63..d1e799de04f9 100644 --- a/packages/core/src/associations/has-many.ts +++ b/packages/core/src/associations/has-many.ts @@ -10,6 +10,7 @@ import type { Transactionable, ModelStatic, AttributeNames, UpdateValues, Attributes, + WhereOptions, } from '../model'; import { Op } from '../operators'; import { col, fn } from '../sequelize'; @@ -466,7 +467,7 @@ export class HasMany< [this.foreignKey]: null, } as UpdateValues; - const where = { + const where: WhereOptions = { [this.foreignKey]: sourceInstance.get(this.sourceKey), // @ts-expect-error -- TODO: what if the target has no primary key? [this.target.primaryKeyAttribute]: targetInstances.map(targetInstance => { diff --git a/packages/core/src/decorators/legacy/associations.ts b/packages/core/src/decorators/legacy/associations.ts index 1096a062c657..a4f4704bbcc9 100644 --- a/packages/core/src/decorators/legacy/associations.ts +++ b/packages/core/src/decorators/legacy/associations.ts @@ -81,7 +81,7 @@ export function BelongsTo( return ( // This type is a hack to make sure the source model declares a property named [SourceKey]. // The error message is going to be horrendous, but at least it's enforced. - source: Model<{ [key in SourceKey]: unknown }>, + source: Model<{ [key in SourceKey]: any }>, associationName: string, ) => { const options = isString(optionsOrForeignKey) ? { foreignKey: optionsOrForeignKey } : optionsOrForeignKey; diff --git a/packages/core/src/decorators/shared/model.ts b/packages/core/src/decorators/shared/model.ts index 506873d07485..270181e776ad 100644 --- a/packages/core/src/decorators/shared/model.ts +++ b/packages/core/src/decorators/shared/model.ts @@ -1,3 +1,4 @@ +import { BaseError } from '../../errors/base-error.js'; import { mergeModelOptions } from '../../model-definition.js'; import { initModel } from '../../model-typescript.js'; import type { AttributeOptions, ModelAttributes, ModelOptions, ModelStatic } from '../../model.js'; @@ -35,8 +36,7 @@ export function registerModelOptions( try { mergeModelOptions(existingModelOptions, options, false); } catch (error) { - // TODO [TS 4.8]: remove this "as Error" cast once support for TS < 4.8 is dropped, as the typing of "cause" has been fixed in TS 4.8 - throw new Error(`Multiple decorators are trying to register conflicting options on model ${model.name}`, { cause: error as Error }); + throw new BaseError(`Multiple decorators are trying to register conflicting options on model ${model.name}`, { cause: error }); } } diff --git a/packages/core/src/errors/base-error.ts b/packages/core/src/errors/base-error.ts index 47c01e7116e4..cc6686d3cc78 100644 --- a/packages/core/src/errors/base-error.ts +++ b/packages/core/src/errors/base-error.ts @@ -66,13 +66,11 @@ export class BaseError extends Error { } } -const indentation = ' '; - function addCause(message: string = '', cause?: unknown) { let out = message; if (cause) { - out += `\n\n${indentation}Caused by:\n${indentation}${getErrorMessage(cause).replace(/\n/g, `\n${indentation}`)}`; + out += `\nCaused by: ${getErrorMessage(cause)}`; } return out; From 26beda5bf76bd65e30264ebf135e39efaa7d514d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Sat, 11 Mar 2023 18:45:16 +0100 Subject: [PATCH 16/18] fix: fix various type issues (#15765) --- .../core/src/associations/belongs-to-many.ts | 5 +- packages/core/src/associations/has-many.ts | 3 +- .../src/dialects/abstract/data-types-utils.ts | 6 +- .../dialects/abstract/query-generator.d.ts | 8 +- .../src/dialects/abstract/query-generator.js | 55 +++---- .../abstract/query-generator/operators.js | 2 +- .../dialects/abstract/query-interface.d.ts | 16 +- .../src/dialects/abstract/query-interface.js | 4 +- .../core/src/dialects/db2/query-generator.js | 4 +- .../core/src/dialects/ibmi/query-generator.js | 8 +- .../src/dialects/mssql/query-generator.js | 3 +- .../src/dialects/mysql/query-generator.js | 6 +- .../src/dialects/postgres/query-generator.js | 7 +- .../core/src/dialects/snowflake/data-types.ts | 2 +- .../src/dialects/snowflake/query-generator.js | 6 +- packages/core/src/dialects/sqlite/index.ts | 1 + .../src/dialects/sqlite/query-generator.js | 11 +- .../base-sql-expression.ts | 22 +++ packages/core/src/expression-builders/cast.ts | 27 ++++ packages/core/src/expression-builders/col.ts | 29 ++++ packages/core/src/expression-builders/fn.ts | 41 ++++++ packages/core/src/expression-builders/json.ts | 40 +++++ .../core/src/expression-builders/literal.ts | 25 ++++ .../core/src/expression-builders/where.ts | 94 ++++++++++++ packages/core/src/index.d.ts | 9 +- packages/core/src/instance-validator.js | 6 +- packages/core/src/model.d.ts | 22 ++- packages/core/src/model.js | 23 +-- packages/core/src/sequelize.d.ts | 118 +-------------- packages/core/src/sequelize.js | 108 +------------- packages/core/src/utils/check.ts | 21 ++- packages/core/src/utils/dialect.ts | 6 + packages/core/src/utils/format.ts | 48 +----- packages/core/src/utils/object.ts | 5 +- .../core/src/utils/query-builder-utils.ts | 2 +- packages/core/src/utils/sequelize-method.ts | 139 ------------------ packages/core/src/utils/string.ts | 4 +- packages/core/src/utils/types.ts | 2 + packages/core/src/utils/where.ts | 39 +++++ .../associations/belongs-to.test.js | 30 ++-- .../core/test/integration/include.test.js | 14 +- .../integration/instance.validations.test.js | 2 +- packages/core/test/integration/model.test.js | 24 +-- .../test/integration/model/create.test.js | 25 ++-- 44 files changed, 536 insertions(+), 536 deletions(-) create mode 100644 packages/core/src/expression-builders/base-sql-expression.ts create mode 100644 packages/core/src/expression-builders/cast.ts create mode 100644 packages/core/src/expression-builders/col.ts create mode 100644 packages/core/src/expression-builders/fn.ts create mode 100644 packages/core/src/expression-builders/json.ts create mode 100644 packages/core/src/expression-builders/literal.ts create mode 100644 packages/core/src/expression-builders/where.ts delete mode 100644 packages/core/src/utils/sequelize-method.ts create mode 100644 packages/core/src/utils/where.ts diff --git a/packages/core/src/associations/belongs-to-many.ts b/packages/core/src/associations/belongs-to-many.ts index b50f0427f2df..5d6bb21e9c05 100644 --- a/packages/core/src/associations/belongs-to-many.ts +++ b/packages/core/src/associations/belongs-to-many.ts @@ -3,6 +3,8 @@ import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import upperFirst from 'lodash/upperFirst'; import { AssociationError } from '../errors'; +import { col } from '../expression-builders/col.js'; +import { fn } from '../expression-builders/fn.js'; import type { AttributeNames, Attributes, @@ -25,7 +27,6 @@ import type { } from '../model'; import { Op } from '../operators'; import type { Sequelize } from '../sequelize'; -import { col, fn } from '../sequelize'; import { isModelStatic, isSameInitialModel } from '../utils/model-utils.js'; import { removeUndefined } from '../utils/object.js'; import { camelize } from '../utils/string.js'; @@ -663,6 +664,8 @@ Add your own primary key to the through model, on different attributes than the ...options, raw: true, where, + // force this option to be false, in case the user enabled + rejectOnEmpty: false, }); } diff --git a/packages/core/src/associations/has-many.ts b/packages/core/src/associations/has-many.ts index d1e799de04f9..ca3b0db7dbdf 100644 --- a/packages/core/src/associations/has-many.ts +++ b/packages/core/src/associations/has-many.ts @@ -1,5 +1,7 @@ import upperFirst from 'lodash/upperFirst'; import { AssociationError } from '../errors/index.js'; +import { col } from '../expression-builders/col.js'; +import { fn } from '../expression-builders/fn.js'; import type { Model, CreateOptions, @@ -13,7 +15,6 @@ import type { WhereOptions, } from '../model'; import { Op } from '../operators'; -import { col, fn } from '../sequelize'; import { isPlainObject } from '../utils/check.js'; import { isSameInitialModel } from '../utils/model-utils.js'; import { removeUndefined } from '../utils/object.js'; diff --git a/packages/core/src/dialects/abstract/data-types-utils.ts b/packages/core/src/dialects/abstract/data-types-utils.ts index 5f8c8d454289..0139edb17800 100644 --- a/packages/core/src/dialects/abstract/data-types-utils.ts +++ b/packages/core/src/dialects/abstract/data-types-utils.ts @@ -37,7 +37,11 @@ export function normalizeDataType( const type = dataTypeClassOrInstanceToInstance(Type); - return type.toDialectDataType(dialect); + if (!type.belongsToDialect(dialect)) { + return type.toDialectDataType(dialect); + } + + return type; } export function dataTypeClassOrInstanceToInstance(Type: DataTypeClassOrInstance): DataTypeInstance { diff --git a/packages/core/src/dialects/abstract/query-generator.d.ts b/packages/core/src/dialects/abstract/query-generator.d.ts index 4105f8550058..13bc2de3f4ed 100644 --- a/packages/core/src/dialects/abstract/query-generator.d.ts +++ b/packages/core/src/dialects/abstract/query-generator.d.ts @@ -1,5 +1,8 @@ // TODO: complete me - this file is a stub that will be completed when query-generator.ts is migrated to TS +import type { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import type { Col } from '../../expression-builders/col.js'; +import type { Literal } from '../../expression-builders/literal.js'; import type { NormalizedAttributeOptions, FindOptions, @@ -10,7 +13,6 @@ import type { WhereOptions, } from '../../model.js'; import type { QueryTypes } from '../../query-types.js'; -import type { Literal, SequelizeMethod, Col } from '../../utils/sequelize-method.js'; import type { DataType } from './data-types.js'; import type { QueryGeneratorOptions } from './query-generator-typescript.js'; import { AbstractQueryGeneratorTypeScript } from './query-generator-typescript.js'; @@ -129,7 +131,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { escape(value: unknown, field?: NormalizedAttributeOptions, options?: EscapeOptions): string; quoteIdentifiers(identifiers: string): string; handleSequelizeMethod( - smth: SequelizeMethod, + smth: BaseSqlExpression, tableName?: TableName, factory?: ModelStatic, options?: HandleSequelizeMethodOptions, @@ -148,7 +150,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { // TODO: see how we can make the typings protected/private while still allowing it to be typed in tests jsonPathExtractionQuery(column: string, path?: string | string[], isJson?: boolean): string; - selectQuery(tableName: string, options?: SelectOptions, model?: ModelStatic): string; + selectQuery(tableName: TableName, options?: SelectOptions, model?: ModelStatic): string; insertQuery( table: TableName, valueHash: object, diff --git a/packages/core/src/dialects/abstract/query-generator.js b/packages/core/src/dialects/abstract/query-generator.js index dde7bf1ed63a..33d60be72671 100644 --- a/packages/core/src/dialects/abstract/query-generator.js +++ b/packages/core/src/dialects/abstract/query-generator.js @@ -1,22 +1,25 @@ 'use strict'; import NodeUtil from 'node:util'; +import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import { Cast } from '../../expression-builders/cast.js'; +import { Col } from '../../expression-builders/col.js'; +import { Fn } from '../../expression-builders/fn.js'; +import { Literal } from '../../expression-builders/literal.js'; +import { Where } from '../../expression-builders/where.js'; import { conformIndex } from '../../model-internals'; import { getTextDataTypeForDialect } from '../../sql-string'; import { rejectInvalidOptions, isNullish, canTreatArrayAsAnd, isColString } from '../../utils/check'; import { TICK_CHAR } from '../../utils/dialect'; import { - getComplexKeys, - getComplexSize, - getOperators, mapFinderOptions, removeNullishValuesFromHash, } from '../../utils/format'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { isModelStatic } from '../../utils/model-utils'; -import { Cast, Col, Fn, Literal, SequelizeMethod, Where } from '../../utils/sequelize-method'; import { injectReplacements } from '../../utils/sql'; import { nameIndex, spliceStr } from '../../utils/string'; +import { getComplexKeys, getComplexSize, getOperators } from '../../utils/where.js'; import { AbstractDataType } from './data-types'; import { attributeTypeToSql, validateDataType } from './data-types-utils'; import { AbstractQueryGeneratorTypeScript } from './query-generator-typescript'; @@ -206,7 +209,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { identityWrapperRequired = true; } - if (value instanceof SequelizeMethod || options.bindParam === false) { + if (value instanceof BaseSqlExpression || options.bindParam === false) { values[key] = this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'INSERT', replacements: options.replacements }); } else { values[key] = this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'INSERT' }, bindParam); @@ -503,7 +506,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { const value = attrValueHash[key]; - if (value instanceof SequelizeMethod || options.bindParam === false) { + if (value instanceof BaseSqlExpression || options.bindParam === false) { values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); } else { values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE' }, bindParam)}`); @@ -621,7 +624,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { } const fieldsSql = options.fields.map(field => { - if (field instanceof SequelizeMethod) { + if (field instanceof BaseSqlExpression) { return this.handleSequelizeMethod(field); } @@ -758,7 +761,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return this.quoteIdentifier(field); } - if (field instanceof SequelizeMethod) { + if (field instanceof BaseSqlExpression) { return this.handleSequelizeMethod(field); } @@ -778,7 +781,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return field; } - if (field instanceof SequelizeMethod) { + if (field instanceof BaseSqlExpression) { throw new TypeError(`The constraint name must be provided explicitly if one of Sequelize's method (literal(), col(), etc…) is used in the constraint's fields`); } @@ -1006,7 +1009,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { for (i = 0; i < collectionLength - 1; i++) { item = collection[i]; - if (typeof item === 'string' || item._modelAttribute || item instanceof SequelizeMethod) { + if (typeof item === 'string' || item._modelAttribute || item instanceof BaseSqlExpression) { break; } else if (item instanceof Association) { const previousAssociation = collection[i - 1]; @@ -1043,7 +1046,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return `${this.quoteTable(collection.Model.name)}.${this.quoteIdentifier(collection.fieldName)}`; } - if (collection instanceof SequelizeMethod) { + if (collection instanceof BaseSqlExpression) { return this.handleSequelizeMethod(collection, undefined, undefined, options); } @@ -1092,7 +1095,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { * @private */ escape(value, attribute, options = {}) { - if (value instanceof SequelizeMethod) { + if (value instanceof BaseSqlExpression) { return this.handleSequelizeMethod(value, undefined, undefined, { replacements: options.replacements }); } @@ -1145,7 +1148,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { format(value, field, options, bindParam) { options = options || {}; - if (value instanceof SequelizeMethod) { + if (value instanceof BaseSqlExpression) { throw new TypeError('Cannot pass SequelizeMethod as a bind parameter - use escape instead'); } @@ -1544,7 +1547,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return attributes && attributes.map(attr => { let addTable = true; - if (attr instanceof SequelizeMethod) { + if (attr instanceof BaseSqlExpression) { return this.handleSequelizeMethod(attr, undefined, undefined, options); } @@ -1555,7 +1558,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { attr = [...attr]; - if (attr[0] instanceof SequelizeMethod) { + if (attr[0] instanceof BaseSqlExpression) { attr[0] = this.handleSequelizeMethod(attr[0], undefined, undefined, options); addTable = false; } else { @@ -1615,7 +1618,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { let verbatim = false; if (Array.isArray(attr) && attr.length === 2) { - if (attr[0] instanceof SequelizeMethod && ( + if (attr[0] instanceof BaseSqlExpression && ( attr[0] instanceof Literal || attr[0] instanceof Cast || attr[0] instanceof Fn @@ -1623,7 +1626,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { verbatim = true; } - attr = attr.map(attrPart => (attrPart instanceof SequelizeMethod ? this.handleSequelizeMethod(attrPart, undefined, undefined, options) : attrPart)); + attr = attr.map(attrPart => (attrPart instanceof BaseSqlExpression ? this.handleSequelizeMethod(attrPart, undefined, undefined, options) : attrPart)); attrAs = attr[1]; attr = attr[0]; @@ -2252,7 +2255,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { mainQueryOrder.push(this.quote(order, model, '->', options)); } - } else if (options.order instanceof SequelizeMethod) { + } else if (options.order instanceof BaseSqlExpression) { const sql = this.quote(options.order, model, '->', options); if (subQuery) { subQueryOrder.push(sql); @@ -2332,13 +2335,13 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { let value = smth.logic; let key; - if (smth.attribute instanceof SequelizeMethod) { + if (smth.attribute instanceof BaseSqlExpression) { key = this.getWhereConditions(smth.attribute, tableName, factory, options, prepend); } else { key = `${this.quoteTable(smth.attribute.Model.name)}.${this.quoteIdentifier(smth.attribute.field || smth.attribute.fieldName)}`; } - if (value && value instanceof SequelizeMethod) { + if (value && value instanceof BaseSqlExpression) { value = this.getWhereConditions(value, tableName, factory, options, prepend); if (value === 'NULL') { @@ -2397,7 +2400,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara } if (smth instanceof Cast) { - if (smth.val instanceof SequelizeMethod) { + if (smth.val instanceof BaseSqlExpression) { result = this.handleSequelizeMethod(smth.val, tableName, factory, options, prepend); } else if (_.isPlainObject(smth.val)) { result = this.whereItemsQuery(smth.val); @@ -2411,7 +2414,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara if (smth instanceof Fn) { return `${smth.fn}(${ smth.args.map(arg => { - if (arg instanceof SequelizeMethod) { + if (arg instanceof BaseSqlExpression) { return this.handleSequelizeMethod(arg, tableName, factory, options, prepend); } @@ -2532,7 +2535,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara return this._joinKeyValue(key, opValue, this.OperatorMap[Op.eq], options.prefix); } - if (value instanceof SequelizeMethod && !(key !== undefined && value instanceof Fn)) { + if (value instanceof BaseSqlExpression && !(key !== undefined && value instanceof Fn)) { return this.handleSequelizeMethod(value, undefined, undefined, options); } @@ -2766,7 +2769,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara } _getSafeKey(key, prefix) { - if (key instanceof SequelizeMethod) { + if (key instanceof BaseSqlExpression) { key = this.handleSequelizeMethod(key); return this._prefixKey(this.handleSequelizeMethod(key), prefix); @@ -2884,7 +2887,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara case Op.anyKeyExists: case Op.allKeysExist: { - if (value instanceof SequelizeMethod) { + if (value instanceof BaseSqlExpression) { return this._joinKeyValue(key, this.handleSequelizeMethod(value, undefined, undefined, options), comparator, options.prefix); } @@ -2977,7 +2980,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara prepend = true; } - if (smth && smth instanceof SequelizeMethod) { // Checking a property is cheaper than a lot of instanceof calls + if (smth && smth instanceof BaseSqlExpression) { // Checking a property is cheaper than a lot of instanceof calls return this.handleSequelizeMethod(smth, tableName, factory, options, prepend); } diff --git a/packages/core/src/dialects/abstract/query-generator/operators.js b/packages/core/src/dialects/abstract/query-generator/operators.js index 0bb2d7b66952..5403bbb8bc53 100644 --- a/packages/core/src/dialects/abstract/query-generator/operators.js +++ b/packages/core/src/dialects/abstract/query-generator/operators.js @@ -2,7 +2,7 @@ const _ = require('lodash'); const { Op } = require('../../../operators'); -const { getOperators } = require('../../../utils/format'); +const { getOperators } = require('../../../utils/where.js'); const OperatorHelpers = { OperatorMap: { diff --git a/packages/core/src/dialects/abstract/query-interface.d.ts b/packages/core/src/dialects/abstract/query-interface.d.ts index df4851b0c20e..32d41d6e2004 100644 --- a/packages/core/src/dialects/abstract/query-interface.d.ts +++ b/packages/core/src/dialects/abstract/query-interface.d.ts @@ -1,5 +1,8 @@ import type { SetRequired } from 'type-fest'; import type { Deferrable } from '../../deferrable'; +import type { Col } from '../../expression-builders/col.js'; +import type { Fn } from '../../expression-builders/fn.js'; +import type { Literal } from '../../expression-builders/literal.js'; import type { Logging, Model, @@ -13,7 +16,6 @@ import type { } from '../../model'; import type { Sequelize, QueryRawOptions, QueryRawOptionsWithModel } from '../../sequelize'; import type { Transaction } from '../../transaction'; -import type { Fn, Literal, Col } from '../../utils/sequelize-method.js'; import type { AllowLowercase } from '../../utils/types.js'; import type { DataType } from './data-types.js'; import type { RemoveIndexQueryOptions, TableNameOrModel } from './query-generator-typescript'; @@ -171,7 +173,7 @@ export interface IndexOptions { /** * Optional where parameter for index. Can be used to limit the index to certain rows. */ - where?: WhereOptions; + where?: WhereOptions; /** * Prefix to append to the index name. @@ -571,9 +573,9 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript { increment( model: ModelStatic, tableName: TableName, + where: WhereOptions>, incrementAmountsByField: object, - extraAttributesToBeUpdated?: object, - where?: WhereOptions>, + extraAttributesToBeUpdated: object, options?: QiArithmeticOptions, ): Promise; @@ -583,9 +585,9 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript { decrement( model: ModelStatic, tableName: TableName, - incrementAmountsByField: object, - extraAttributesToBeUpdated?: object, - where?: WhereOptions>, + where: WhereOptions>, + decrementAmountsByField: object, + extraAttributesToBeUpdated: object, options?: QiArithmeticOptions, ): Promise; diff --git a/packages/core/src/dialects/abstract/query-interface.js b/packages/core/src/dialects/abstract/query-interface.js index 1382eeaed154..0d36cc221e5b 100644 --- a/packages/core/src/dialects/abstract/query-interface.js +++ b/packages/core/src/dialects/abstract/query-interface.js @@ -1110,11 +1110,11 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript { return this.#arithmeticQuery('-', model, tableName, where, incrementAmountsByField, extraAttributesToBeUpdated, options); } - async #arithmeticQuery(operator, model, tableName, where, incrementAmountsByField, extraAttributesToBeUpdated, options) { + async #arithmeticQuery(operator, model, tableName, where, incrementAmountsByAttribute, extraAttributesToBeUpdated, options) { options = cloneDeep(options); options.model = model; - const sql = this.queryGenerator.arithmeticQuery(operator, tableName, where, incrementAmountsByField, extraAttributesToBeUpdated, options); + const sql = this.queryGenerator.arithmeticQuery(operator, tableName, where, incrementAmountsByAttribute, extraAttributesToBeUpdated, options); options.type = QueryTypes.UPDATE; diff --git a/packages/core/src/dialects/db2/query-generator.js b/packages/core/src/dialects/db2/query-generator.js index 3f6ada0d1ae6..037d1c83e807 100644 --- a/packages/core/src/dialects/db2/query-generator.js +++ b/packages/core/src/dialects/db2/query-generator.js @@ -1,8 +1,8 @@ 'use strict'; +import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; import { rejectInvalidOptions } from '../../utils/check'; import { removeNullishValuesFromHash } from '../../utils/format'; -import { SequelizeMethod } from '../../utils/sequelize-method'; import { removeTrailingSemicolon, underscore } from '../../utils/string'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; @@ -426,7 +426,7 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { for (const key in attrValueHash) { const value = attrValueHash[key]; - if (value instanceof SequelizeMethod || options.bindParam === false) { + if (value instanceof BaseSqlExpression || options.bindParam === false) { values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); } else { values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements }, bindParam)}`); diff --git a/packages/core/src/dialects/ibmi/query-generator.js b/packages/core/src/dialects/ibmi/query-generator.js index 8679264a6dc3..f9bfb2952681 100644 --- a/packages/core/src/dialects/ibmi/query-generator.js +++ b/packages/core/src/dialects/ibmi/query-generator.js @@ -1,10 +1,12 @@ 'use strict'; import { underscore } from 'inflection'; +import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import { Cast } from '../../expression-builders/cast.js'; +import { Json } from '../../expression-builders/json.js'; import { conformIndex } from '../../model-internals'; import { rejectInvalidOptions } from '../../utils/check'; import { addTicks } from '../../utils/dialect'; -import { Cast, Json, SequelizeMethod } from '../../utils/sequelize-method'; import { nameIndex, removeTrailingSemicolon } from '../../utils/string'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; @@ -308,7 +310,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { } escape(value, attribute, options) { - if (value instanceof SequelizeMethod) { + if (value instanceof BaseSqlExpression) { return this.handleSequelizeMethod(value, undefined, undefined, { replacements: options.replacements }); } @@ -384,7 +386,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { return this.quoteIdentifier(field); } - if (field instanceof SequelizeMethod) { + if (field instanceof BaseSqlExpression) { return this.handleSequelizeMethod(field); } diff --git a/packages/core/src/dialects/mssql/query-generator.js b/packages/core/src/dialects/mssql/query-generator.js index 4aca252eb895..7d80937cce5f 100644 --- a/packages/core/src/dialects/mssql/query-generator.js +++ b/packages/core/src/dialects/mssql/query-generator.js @@ -1,10 +1,11 @@ 'use strict'; +import { Col } from '../../expression-builders/col.js'; +import { Literal } from '../../expression-builders/literal.js'; import { rejectInvalidOptions } from '../../utils/check'; import { addTicks, removeTicks } from '../../utils/dialect'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; -import { Col, Literal } from '../../utils/sequelize-method'; import { generateIndexName, underscore } from '../../utils/string'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; import { diff --git a/packages/core/src/dialects/mysql/query-generator.js b/packages/core/src/dialects/mysql/query-generator.js index aabd5c959010..c6cbdf575d10 100644 --- a/packages/core/src/dialects/mysql/query-generator.js +++ b/packages/core/src/dialects/mysql/query-generator.js @@ -1,10 +1,12 @@ 'use strict'; +import { Cast } from '../../expression-builders/cast.js'; +import { Json } from '../../expression-builders/json.js'; import { rejectInvalidOptions } from '../../utils/check'; import { addTicks } from '../../utils/dialect'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; +import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; -import { Cast, Json } from '../../utils/sequelize-method'; import { underscore } from '../../utils/string'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; import { @@ -340,7 +342,7 @@ export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { return `TRUNCATE ${this.quoteTable(tableName)}`; } - deleteQuery(tableName, where, options = {}, model) { + deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { let query = `DELETE FROM ${this.quoteTable(tableName)}`; where = this.getWhereConditions(where, null, model, options); diff --git a/packages/core/src/dialects/postgres/query-generator.js b/packages/core/src/dialects/postgres/query-generator.js index 3f756f3000b2..88ed87a68e03 100644 --- a/packages/core/src/dialects/postgres/query-generator.js +++ b/packages/core/src/dialects/postgres/query-generator.js @@ -1,7 +1,8 @@ 'use strict'; +import { Json } from '../../expression-builders/json.js'; +import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; -import { Json } from '../../utils/sequelize-method'; import { generateIndexName } from '../../utils/string'; import { ENUM } from './data-types'; import { quoteIdentifier, removeTicks } from '../../utils/dialect'; @@ -387,7 +388,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { ].join(''); } - deleteQuery(tableName, where, options = {}, model) { + deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { const table = this.quoteTable(tableName); let whereClause = this.getWhereConditions(where, null, model, options); const limit = options.limit ? ` LIMIT ${this.escape(options.limit, undefined, _.pick(options, ['replacements', 'bind']))}` : ''; @@ -953,7 +954,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { if ( optForceQuote === true - // TODO: drop this.options.quoteIdentifiers. Always quote identifiers. + // TODO: drop this.options.quoteIdentifiers. Always quote identifiers based on these rules || optQuoteIdentifiers !== false || identifier.includes('.') || identifier.includes('->') diff --git a/packages/core/src/dialects/snowflake/data-types.ts b/packages/core/src/dialects/snowflake/data-types.ts index 79fa275e88d1..48d7f1b46043 100644 --- a/packages/core/src/dialects/snowflake/data-types.ts +++ b/packages/core/src/dialects/snowflake/data-types.ts @@ -38,7 +38,7 @@ export class TEXT extends BaseTypes.TEXT { } export class JSON extends BaseTypes.JSON { - escape(value: any, options: StringifyOptions) { + escape(value: unknown, options: StringifyOptions) { return options.operation === 'where' && typeof value === 'string' ? value : globalThis.JSON.stringify(value); } } diff --git a/packages/core/src/dialects/snowflake/query-generator.js b/packages/core/src/dialects/snowflake/query-generator.js index a62158bf1d70..e56242ae09ae 100644 --- a/packages/core/src/dialects/snowflake/query-generator.js +++ b/packages/core/src/dialects/snowflake/query-generator.js @@ -1,10 +1,12 @@ 'use strict'; +import { Cast } from '../../expression-builders/cast.js'; +import { Json } from '../../expression-builders/json.js'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; +import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { addTicks, quoteIdentifier } from '../../utils/dialect.js'; import { rejectInvalidOptions } from '../../utils/check'; -import { Cast, Json } from '../../utils/sequelize-method'; import { underscore } from '../../utils/string'; import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, @@ -382,7 +384,7 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { ]); } - deleteQuery(tableName, where, options = {}, model) { + deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { const table = this.quoteTable(tableName); let whereClause = this.getWhereConditions(where, null, model, options); const limit = options.limit && ` LIMIT ${this.escape(options.limit, undefined, options)}`; diff --git a/packages/core/src/dialects/sqlite/index.ts b/packages/core/src/dialects/sqlite/index.ts index aff10c916a68..e9917243ca78 100644 --- a/packages/core/src/dialects/sqlite/index.ts +++ b/packages/core/src/dialects/sqlite/index.ts @@ -40,6 +40,7 @@ export class SqliteDialect extends AbstractDialect { JSON: true, }, // TODO: add support for JSON operations https://www.sqlite.org/json1.html (bundled in sqlite3) + // be careful: json_extract, ->, and ->> don't have the exact same meanings as mysql & mariadb jsonOperations: false, }); diff --git a/packages/core/src/dialects/sqlite/query-generator.js b/packages/core/src/dialects/sqlite/query-generator.js index 8340004df1bd..769485745d8a 100644 --- a/packages/core/src/dialects/sqlite/query-generator.js +++ b/packages/core/src/dialects/sqlite/query-generator.js @@ -1,10 +1,13 @@ 'use strict'; +import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import { Cast } from '../../expression-builders/cast.js'; +import { Json } from '../../expression-builders/json.js'; import { addTicks, removeTicks } from '../../utils/dialect'; import { removeNullishValuesFromHash } from '../../utils/format'; +import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { rejectInvalidOptions } from '../../utils/check'; -import { Cast, Json, SequelizeMethod } from '../../utils/sequelize-method'; import { underscore } from '../../utils/string'; import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, REMOVE_COLUMN_QUERY_SUPPORTABLE_OPTIONS } from '../abstract/query-generator'; @@ -240,7 +243,7 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { attrValueHash = removeNullishValuesFromHash(attrValueHash, options.omitNull, options); - const modelAttributeMap = {}; + const modelAttributeMap = Object.create(null); const values = []; const bind = Object.create(null); const bindParam = options.bindParam === undefined ? this.bindParam(bind) : options.bindParam; @@ -257,7 +260,7 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { for (const key in attrValueHash) { const value = attrValueHash[key]; - if (value instanceof SequelizeMethod || options.bindParam === false) { + if (value instanceof BaseSqlExpression || options.bindParam === false) { values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); } else { values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements }, bindParam)}`); @@ -288,7 +291,7 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { ].join(''); } - deleteQuery(tableName, where, options = {}, model) { + deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { _.defaults(options, this.options); let whereClause = this.getWhereConditions(where, null, model, options); diff --git a/packages/core/src/expression-builders/base-sql-expression.ts b/packages/core/src/expression-builders/base-sql-expression.ts new file mode 100644 index 000000000000..53a9283e95c8 --- /dev/null +++ b/packages/core/src/expression-builders/base-sql-expression.ts @@ -0,0 +1,22 @@ +import type { Cast } from './cast.js'; +import type { Col } from './col.js'; +import type { Fn } from './fn.js'; +import type { Json } from './json.js'; +import type { Literal } from './literal.js'; +import type { Where } from './where.js'; + +/** + * Utility functions for representing SQL functions, and columns that should be escaped. + * Please do not use these functions directly, use Sequelize.fn and Sequelize.col instead. + * + * @private + */ +export class BaseSqlExpression {} + +export type DynamicSqlExpression = + | Fn + | Col + | Cast + | Literal + | Where + | Json; diff --git a/packages/core/src/expression-builders/cast.ts b/packages/core/src/expression-builders/cast.ts new file mode 100644 index 000000000000..374d1f0bd163 --- /dev/null +++ b/packages/core/src/expression-builders/cast.ts @@ -0,0 +1,27 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link cast} + */ +export class Cast extends BaseSqlExpression { + private readonly val: any; + private readonly type: string; + private readonly json: boolean; + + constructor(val: unknown, type: string = '', json: boolean = false) { + super(); + this.val = val; + this.type = type.trim(); + this.json = json; + } +} + +/** + * Creates a object representing a call to the cast function. + * + * @param val The value to cast + * @param type The type to cast it to + */ +export function cast(val: unknown, type: string): Cast { + return new Cast(val, type); +} diff --git a/packages/core/src/expression-builders/col.ts b/packages/core/src/expression-builders/col.ts new file mode 100644 index 000000000000..68e0c8cca307 --- /dev/null +++ b/packages/core/src/expression-builders/col.ts @@ -0,0 +1,29 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link col} + */ +export class Col extends BaseSqlExpression { + private readonly col: string[] | string; + + constructor(identifiers: string[] | string, ...args: string[]) { + super(); + // TODO(ephys): this does not look right. First parameter is ignored if a second parameter is provided. + // should we change the signature to `constructor(...cols: string[])` + if (args.length > 0) { + identifiers = args; + } + + this.col = identifiers; + } +} + +/** + * Creates an object which represents a column in the DB, this allows referencing another column in your query. + * This is often useful in conjunction with `sequelize.fn`, since raw string arguments to fn will be escaped. + * + * @param identifiers The name of the column + */ +export function col(identifiers: string[] | string): Col { + return new Col(identifiers); +} diff --git a/packages/core/src/expression-builders/fn.ts b/packages/core/src/expression-builders/fn.ts new file mode 100644 index 000000000000..3e5fdf8bc719 --- /dev/null +++ b/packages/core/src/expression-builders/fn.ts @@ -0,0 +1,41 @@ +import type { WhereAttributeHash } from '../model.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link fn} + */ +export class Fn extends BaseSqlExpression { + private readonly fn: string; + + // unknown already covers the other two types, but they've been added explicitly to document + // passing WhereAttributeHash generates a condition inside the function. + private readonly args: Array; + + constructor(fnName: string, args: Fn['args']) { + super(); + this.fn = fnName; + this.args = args; + } + + clone(): Fn { + return new Fn(this.fn, this.args); + } +} + +/** + * Creates an object representing a database function. This can be used in search queries, both in where and order parts, and as default values in column definitions. + * If you want to refer to columns in your function, you should use {@link col}, so that the columns are properly interpreted as columns and not a strings. + * + * @param fnName The SQL function you want to call + * @param args All further arguments will be passed as arguments to the function + * + * @example Convert a user's username to upper case + * ```ts + * instance.update({ + * username: fn('upper', col('username')) + * }); + * ``` + */ +export function fn(fnName: string, ...args: Fn['args']): Fn { + return new Fn(fnName, args); +} diff --git a/packages/core/src/expression-builders/json.ts b/packages/core/src/expression-builders/json.ts new file mode 100644 index 000000000000..674c5c476dbf --- /dev/null +++ b/packages/core/src/expression-builders/json.ts @@ -0,0 +1,40 @@ +import isObject from 'lodash/isObject.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link json} + */ +export class Json extends BaseSqlExpression { + private readonly conditions?: { [key: string]: any }; + private readonly path?: string; + private readonly value?: string | number | boolean | null; + + constructor( + conditionsOrPath: { [key: string]: any } | string, + value?: string | number | boolean | null, + ) { + super(); + + if (typeof conditionsOrPath === 'string') { + this.path = conditionsOrPath; + + if (value) { + this.value = value; + } + } else if (isObject(conditionsOrPath)) { + this.conditions = conditionsOrPath; + } + } +} + +/** + * Creates an object representing nested where conditions for postgres's json data-type. + * + * @param conditionsOrPath A hash containing strings/numbers or other nested hash, a string using dot + * notation or a string using postgres json syntax. + * @param value An optional value to compare against. + * Produces a string of the form "<json path> = '<value>'". + */ +export function json(conditionsOrPath: string | object, value?: string | number | boolean): Json { + return new Json(conditionsOrPath, value); +} diff --git a/packages/core/src/expression-builders/literal.ts b/packages/core/src/expression-builders/literal.ts new file mode 100644 index 000000000000..b25c577812b2 --- /dev/null +++ b/packages/core/src/expression-builders/literal.ts @@ -0,0 +1,25 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link literal} + */ +export class Literal extends BaseSqlExpression { + /** this (type-only) brand prevents TypeScript from thinking Cast is assignable to Literal because they share the same shape */ + declare private readonly brand: 'literal'; + + private readonly val: unknown; + + constructor(val: unknown) { + super(); + this.val = val; + } +} + +/** + * Creates an object representing a literal, i.e. something that will not be escaped. + * + * @param val literal value + */ +export function literal(val: string) { + return new Literal(val); +} diff --git a/packages/core/src/expression-builders/where.ts b/packages/core/src/expression-builders/where.ts new file mode 100644 index 000000000000..c502d91cfa51 --- /dev/null +++ b/packages/core/src/expression-builders/where.ts @@ -0,0 +1,94 @@ +import type { WhereAttributeHashValue, WhereOperators, AttributeOptions, ColumnReference } from '../model.js'; +import type { Op } from '../operators.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; +import type { Cast } from './cast.js'; +import type { Fn } from './fn.js'; +import type { Literal } from './literal.js'; + +export type WhereLeftOperand = Fn | ColumnReference | Literal | Cast | AttributeOptions; + +/** + * Do not use me directly. Use {@link where} + */ +export class Where extends BaseSqlExpression { + // TODO [=7]: rename to leftOperand after typescript migration + private readonly attribute: WhereLeftOperand; + // TODO [=7]: rename to operator after typescript migration + private readonly comparator: string | Operator; + // TODO [=7]: rename to rightOperand after typescript migration + private readonly logic: WhereOperators[Operator] | WhereAttributeHashValue | any; + + constructor(leftOperand: WhereLeftOperand, operator: Operator, rightOperand: WhereOperators[Operator]); + constructor(leftOperand: WhereLeftOperand, operator: string, rightOperand: any); + constructor(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue); + constructor( + leftOperand: WhereLeftOperand, + operatorOrRightOperand: string | Operator | WhereAttributeHashValue, + rightOperand?: WhereOperators[Operator] | any, + ) { + super(); + + this.attribute = leftOperand; + + if (rightOperand !== undefined) { + this.logic = rightOperand; + this.comparator = operatorOrRightOperand; + } else { + this.logic = operatorOrRightOperand; + this.comparator = '='; + } + } +} + +/** + * A way of specifying "attr = condition". + * Can be used as a replacement for the POJO syntax (e.g. `where: { name: 'Lily' }`) when you need to compare a column that the POJO syntax cannot represent. + * + * @param leftOperand The left side of the comparison. + * - A value taken from YourModel.rawAttributes, to reference an attribute. + * The attribute must be defined in your model definition. + * - A Literal (using {@link literal}) + * - A SQL Function (using {@link fn}) + * - A Column name (using {@link col}) + * Note that simple strings to reference an attribute are not supported. You can use the POJO syntax instead. + * @param operator The comparison operator to use. If unspecified, defaults to {@link OpTypes.eq}. + * @param rightOperand The right side of the comparison. Its value depends on the used operator. + * See {@link WhereOperators} for information about what value is valid for each operator. + * + * @example + * // Using an attribute as the left operand. + * // Equal to: WHERE first_name = 'Lily' + * where(User.rawAttributes.firstName, Op.eq, 'Lily'); + * + * @example + * // Using a column name as the left operand. + * // Equal to: WHERE first_name = 'Lily' + * where(col('first_name'), Op.eq, 'Lily'); + * + * @example + * // Using a SQL function on the left operand. + * // Equal to: WHERE LOWER(first_name) = 'lily' + * where(fn('LOWER', col('first_name')), Op.eq, 'lily'); + * + * @example + * // Using raw SQL as the left operand. + * // Equal to: WHERE 'Lily' = 'Lily' + * where(literal(`'Lily'`), Op.eq, 'Lily'); + */ +export function where( + leftOperand: WhereLeftOperand | Where, + operator: OpSymbol, + rightOperand: WhereOperators[OpSymbol] +): Where; +export function where(leftOperand: any, operator: string, rightOperand: any): Where; +export function where(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue): Where; + +export function where( + ...args: + | [leftOperand: WhereLeftOperand | Where, operator: OpSymbol, rightOperand: WhereOperators[OpSymbol]] + | [leftOperand: any, operator: string, rightOperand: any] + | [leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue] +): Where { + // @ts-expect-error -- they are the same type but this overload is internal + return new Where(...args); +} diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index 9c7b07011ef7..9521998cb21d 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -61,7 +61,14 @@ export { useInflection } from './utils/string'; export { isModelStatic, isSameInitialModel } from './utils/model-utils'; export type { Validator } from './utils/validator-extras'; export { Deferrable } from './deferrable'; -export { Col, Cast, Fn, Json, Where, Literal, SequelizeMethod } from './utils/sequelize-method.js'; export { AbstractQueryGenerator } from './dialects/abstract/query-generator.js'; export { importModels } from './import-models.js'; export { ModelDefinition } from './model-definition.js'; +export { BaseSqlExpression } from './expression-builders/base-sql-expression.js'; + +export { literal, Literal } from './expression-builders/literal.js'; +export { fn, Fn } from './expression-builders/fn.js'; +export { col, Col } from './expression-builders/col.js'; +export { cast, Cast } from './expression-builders/cast.js'; +export { json, Json } from './expression-builders/json.js'; +export { where, Where } from './expression-builders/where.js'; diff --git a/packages/core/src/instance-validator.js b/packages/core/src/instance-validator.js index 128fca9d9c0f..bf09385b0d94 100644 --- a/packages/core/src/instance-validator.js +++ b/packages/core/src/instance-validator.js @@ -2,8 +2,8 @@ import { AbstractDataType } from './dialects/abstract/data-types'; import { validateDataType } from './dialects/abstract/data-types-utils'; +import { BaseSqlExpression } from './expression-builders/base-sql-expression.js'; import { getAllOwnKeys } from './utils/object'; -import { SequelizeMethod } from './utils/sequelize-method'; import { BelongsTo } from './associations/belongs-to'; const _ = require('lodash'); @@ -144,7 +144,7 @@ export class InstanceValidator { const value = this.modelInstance.dataValues[attrName]; - if (value instanceof SequelizeMethod) { + if (value instanceof BaseSqlExpression) { continue; } @@ -386,7 +386,7 @@ export class InstanceValidator { } const type = attribute.type; - if (value != null && !(value instanceof SequelizeMethod) && type instanceof AbstractDataType) { + if (value != null && !(value instanceof BaseSqlExpression) && type instanceof AbstractDataType) { const error = validateDataType(type, attributeName, this.modelInstance, value); if (error) { this.errors.push(error); diff --git a/packages/core/src/model.d.ts b/packages/core/src/model.d.ts index 4f4d8c34a084..76fa6b3df95b 100644 --- a/packages/core/src/model.d.ts +++ b/packages/core/src/model.d.ts @@ -18,19 +18,17 @@ import type { TableNameWithSchema, IndexField, } from './dialects/abstract/query-interface'; +import type { Cast } from './expression-builders/cast.js'; +import type { Col } from './expression-builders/col.js'; +import type { Fn } from './expression-builders/fn.js'; +import type { Json } from './expression-builders/json.js'; +import type { Literal } from './expression-builders/literal.js'; +import type { Where } from './expression-builders/where.js'; import type { IndexHints } from './index-hints'; import type { ValidationOptions } from './instance-validator'; import type { ModelHooks } from './model-hooks.js'; import { ModelTypeScript } from './model-typescript.js'; import type { Sequelize, SyncOptions, QueryOptions } from './sequelize'; -import type { - Cast, - Col, - Fn, - Json, - Literal, - Where, -} from './utils/sequelize-method.js'; import type { AllowArray, AllowReadonlyArray, @@ -2423,22 +2421,22 @@ export abstract class Model>( this: ModelStatic, - identifier: Identifier, + identifier: unknown, options: FindByPkOptions & { raw: true, rejectOnEmpty?: false } ): Promise; static findByPk>( this: ModelStatic, - identifier: Identifier, + identifier: unknown, options: NonNullFindByPkOptions & { raw: true } ): Promise; static findByPk( this: ModelStatic, - identifier: Identifier, + identifier: unknown, options: NonNullFindByPkOptions ): Promise; static findByPk( this: ModelStatic, - identifier?: Identifier, + identifier?: unknown, options?: FindByPkOptions ): Promise; diff --git a/packages/core/src/model.js b/packages/core/src/model.js index 6ec68d611a6c..26c97e663871 100644 --- a/packages/core/src/model.js +++ b/packages/core/src/model.js @@ -2,6 +2,7 @@ import omit from 'lodash/omit'; import { AbstractDataType } from './dialects/abstract/data-types'; +import { BaseSqlExpression } from './expression-builders/base-sql-expression.js'; import { intersects } from './utils/array'; import { noDoubleNestedGroup, @@ -12,23 +13,22 @@ import { } from './utils/deprecations'; import { toDefaultValue } from './utils/dialect'; import { - getComplexKeys, mapFinderOptions, mapOptionFieldNames, mapValueFieldNames, mapWhereFieldNames, } from './utils/format'; import { every, find } from './utils/iterators'; -import { cloneDeep, mergeDefaults, defaults, flattenObjectDeep, getObjectFromMap } from './utils/object'; +import { cloneDeep, mergeDefaults, defaults, flattenObjectDeep, getObjectFromMap, EMPTY_OBJECT } from './utils/object'; import { isWhereEmpty } from './utils/query-builder-utils'; import { ModelTypeScript } from './model-typescript'; import { isModelStatic, isSameInitialModel } from './utils/model-utils'; -import { SequelizeMethod } from './utils/sequelize-method'; import { Association, BelongsTo, BelongsToMany, HasMany, HasOne } from './associations'; import { AssociationSecret } from './associations/helpers'; import { Op } from './operators'; import { _validateIncludedElements, combineIncludes, setTransactionFromCls, throwInvalidInclude } from './model-internals'; import { QueryTypes } from './query-types'; +import { getComplexKeys } from './utils/where.js'; const assert = require('node:assert'); const NodeUtil = require('node:util'); @@ -147,7 +147,7 @@ export class Model extends ModelTypeScript { ? _.mapValues(getObjectFromMap(modelDefinition.defaultValues), getDefaultValue => { const value = getDefaultValue(); - return value && value instanceof SequelizeMethod ? value : _.cloneDeep(value); + return value && value instanceof BaseSqlExpression ? value : _.cloneDeep(value); }) : Object.create(null); @@ -1395,7 +1395,7 @@ ${associationOwner._getAssociationDebugList()}`); */ static async findByPk(param, options) { // return Promise resolved with null if no arguments are passed - if ([null, undefined].includes(param)) { + if (param == null) { return null; } @@ -1403,6 +1403,7 @@ ${associationOwner._getAssociationDebugList()}`); if (typeof param === 'number' || typeof param === 'bigint' || typeof param === 'string' || Buffer.isBuffer(param)) { options.where = { + // TODO: support composite primary keys [this.primaryKeyAttribute]: param, }; } else { @@ -2447,7 +2448,7 @@ ${associationOwner._getAssociationDebugList()}`); throw new Error('Missing where or truncate attribute in the options parameter of model.destroy.'); } - if (!options.truncate && !_.isPlainObject(options.where) && !Array.isArray(options.where) && !(options.where instanceof SequelizeMethod)) { + if (!options.truncate && !_.isPlainObject(options.where) && !Array.isArray(options.where) && !(options.where instanceof BaseSqlExpression)) { throw new Error('Expected plain object, array or sequelize method in the options.where parameter of model.destroy.'); } @@ -3050,7 +3051,7 @@ Instead of specifying a Model, either: static _optionsMustContainWhere(options) { assert(options && options.where, 'Missing where attribute in the options parameter'); - assert(_.isPlainObject(options.where) || Array.isArray(options.where) || options.where instanceof SequelizeMethod, + assert(_.isPlainObject(options.where) || Array.isArray(options.where) || options.where instanceof BaseSqlExpression, 'Expected plain object, array or sequelize method in the options.where parameter'); } @@ -3081,7 +3082,7 @@ Instead of specifying a Model, either: ); } - const where = {}; + const where = Object.create(null); for (const attributeName of modelDefinition.primaryKeysAttributeNames) { const attrVal = this.get(attributeName, { raw: true }); @@ -3156,7 +3157,7 @@ Instead of specifying a Model, either: attributeName = undefined; } - options = options || {}; + options = options ?? EMPTY_OBJECT; const { attributes, attributesWithGetters } = this.constructor.modelDefinition; @@ -3371,7 +3372,7 @@ Instead of specifying a Model, either: if ( !options.comesFromDatabase && value != null - && !(value instanceof SequelizeMethod) + && !(value instanceof BaseSqlExpression) && attributeType // "type" can be a string && attributeType instanceof AbstractDataType @@ -3384,7 +3385,7 @@ Instead of specifying a Model, either: !options.raw && ( // True when sequelize method - value instanceof SequelizeMethod + value instanceof BaseSqlExpression // Otherwise, check for data type type comparators || ((value != null && attributeType && attributeType instanceof AbstractDataType) && !attributeType.areValuesEqual(value, originalValue, options)) || ((value == null || !attributeType || !(attributeType instanceof AbstractDataType)) && !_.isEqual(value, originalValue)) diff --git a/packages/core/src/sequelize.d.ts b/packages/core/src/sequelize.d.ts index 9b8acfa54734..f428cd66fbb7 100644 --- a/packages/core/src/sequelize.d.ts +++ b/packages/core/src/sequelize.d.ts @@ -4,6 +4,12 @@ import type { AbstractConnectionManager } from './dialects/abstract/connection-m import type { AbstractDataType, DataType, DataTypeClassOrInstance } from './dialects/abstract/data-types.js'; import type { AbstractQueryInterface, ColumnsDescription } from './dialects/abstract/query-interface'; import type { CreateSchemaOptions } from './dialects/abstract/query-interface.types'; +import type { cast } from './expression-builders/cast.js'; +import type { col } from './expression-builders/col.js'; +import type { fn, Fn } from './expression-builders/fn.js'; +import type { json } from './expression-builders/json.js'; +import type { literal } from './expression-builders/literal.js'; +import type { where } from './expression-builders/where.js'; import type { DestroyOptions, DropOptions, @@ -12,19 +18,15 @@ import type { AttributeOptions, ModelAttributes, ModelOptions, - WhereOperators, Hookable, ModelStatic, Attributes, - ColumnReference, Transactionable, Poolable, - WhereAttributeHashValue, } from './model'; import type { ModelManager } from './model-manager'; import { SequelizeTypeScript } from './sequelize-typescript.js'; import type { SequelizeHooks } from './sequelize-typescript.js'; -import type { Cast, Col, Fn, Json, Literal, Where } from './utils/sequelize-method.js'; import type { RequiredBy } from './utils/types.js'; import type { QueryTypes, TRANSACTION_TYPES, ISOLATION_LEVELS, Op, DataTypes, AbstractQueryGenerator } from '.'; @@ -775,7 +777,7 @@ export class Sequelize extends SequelizeTypeScript { * Dictionary of all models linked with this instance. */ models: { - [key: string]: ModelStatic, + [key: string]: ModelStatic, }; /** @@ -1056,7 +1058,7 @@ export class Sequelize extends SequelizeTypeScript { * * @param [options] The options passed to Model.destroy in addition to truncate */ - truncate(options?: DestroyOptions): Promise; + truncate(options?: DestroyOptions): Promise; /** * Drop all tables defined through this sequelize instance. This is done by calling Model.drop on each model @@ -1106,50 +1108,6 @@ export class Sequelize extends SequelizeTypeScript { // Utilities -/** - * Creates a object representing a database function. This can be used in search queries, both in where and - * order parts, and as default values in column definitions. If you want to refer to columns in your - * function, you should use `sequelize.col`, so that the columns are properly interpreted as columns and - * not a strings. - * - * Convert a user's username to upper case - * ```ts - * instance.update({ - * username: fn('upper', col('username')) - * }) - * ``` - * - * @param sqlFunction The function you want to call - * @param args All further arguments will be passed as arguments to the function - */ -export function fn( - sqlFunction: string, - ...args: Fn['args'] -): Fn; - -/** - * Creates a object representing a column in the DB. This is often useful in conjunction with - * `sequelize.fn`, since raw string arguments to fn will be escaped. - * - * @param columnName The name of the column - */ -export function col(columnName: string): Col; - -/** - * Creates a object representing a call to the cast function. - * - * @param val The value to cast - * @param type The type to cast it to - */ -export function cast(val: unknown, type: string): Cast; - -/** - * Creates a object representing a literal, i.e. something that will not be escaped. - * - * @param val - */ -export function literal(val: string): Literal; - /** * An AND query * @@ -1163,63 +1121,3 @@ export function and(...args: T): { [Op.and]: T }; * @param args Each argument will be joined by OR */ export function or(...args: T): { [Op.or]: T }; - -/** - * Creates an object representing nested where conditions for postgres's json data-type. - * - * @param conditionsOrPath A hash containing strings/numbers or other nested hash, a string using dot - * notation or a string using postgres json syntax. - * @param value An optional value to compare against. - * Produces a string of the form "<json path> = '<value>'". - */ -export function json(conditionsOrPath: string | object, value?: string | number | boolean): Json; - -export type WhereLeftOperand = Fn | ColumnReference | Literal | Cast | AttributeOptions; - -/** - * A way of specifying "attr = condition". - * Can be used as a replacement for the POJO syntax (e.g. `where: { name: 'Lily' }`) when you need to compare a column that the POJO syntax cannot represent. - * - * @param leftOperand The left side of the comparison. - * - A value taken from YourModel.rawAttributes, to reference an attribute. - * The attribute must be defined in your model definition. - * - A Literal (using {@link literal}) - * - A SQL Function (using {@link fn}) - * - A Column name (using {@link col}) - * Note that simple strings to reference an attribute are not supported. You can use the POJO syntax instead. - * @param operator The comparison operator to use. If unspecified, defaults to {@link OpTypes.eq}. - * @param rightOperand The right side of the comparison. Its value depends on the used operator. - * See {@link WhereOperators} for information about what value is valid for each operator. - * - * @example - * // Using an attribute as the left operand. - * // Equal to: WHERE first_name = 'Lily' - * where(User.rawAttributes.firstName, Op.eq, 'Lily'); - * - * @example - * // Using a column name as the left operand. - * // Equal to: WHERE first_name = 'Lily' - * where(col('first_name'), Op.eq, 'Lily'); - * - * @example - * // Using a SQL function on the left operand. - * // Equal to: WHERE LOWER(first_name) = 'lily' - * where(fn('LOWER', col('first_name')), Op.eq, 'lily'); - * - * @example - * // Using raw SQL as the left operand. - * // Equal to: WHERE 'Lily' = 'Lily' - * where(literal(`'Lily'`), Op.eq, 'Lily'); - */ -export function where( - leftOperand: WhereLeftOperand | Where, - operator: OpSymbol, - rightOperand: WhereOperators[OpSymbol] -): Where; -export function where(leftOperand: any, operator: string, rightOperand: any): Where; -export function where(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue): Where; - -type ContinuationLocalStorageNamespace = { - get(key: string): unknown, - set(key: string, value: unknown): void, -}; diff --git a/packages/core/src/sequelize.js b/packages/core/src/sequelize.js index 25fe0d580e75..cf7e3ee32258 100644 --- a/packages/core/src/sequelize.js +++ b/packages/core/src/sequelize.js @@ -3,12 +3,17 @@ import isPlainObject from 'lodash/isPlainObject'; import retry from 'retry-as-promised'; import { normalizeDataType } from './dialects/abstract/data-types-utils'; +import { Cast, cast } from './expression-builders/cast.js'; +import { Col, col } from './expression-builders/col.js'; +import { Fn, fn } from './expression-builders/fn.js'; +import { Json, json } from './expression-builders/json.js'; +import { Literal, literal } from './expression-builders/literal.js'; +import { Where, where } from './expression-builders/where.js'; import { SequelizeTypeScript } from './sequelize-typescript'; import { withSqliteForeignKeysOff } from './dialects/sqlite/sqlite-utils'; import { isString } from './utils/check.js'; import { noSequelizeDataType } from './utils/deprecations'; import { isModelStatic, isSameInitialModel } from './utils/model-utils'; -import { Cast, Col, Fn, Json, Literal, Where } from './utils/sequelize-method'; import { injectReplacements, mapBindParameters } from './utils/sql'; import { useInflection } from './utils/string'; import { parseConnectionString } from './utils/url'; @@ -1337,74 +1342,6 @@ for (const error of Object.keys(sequelizeErrors)) { Sequelize[error] = sequelizeErrors[error]; } -/** - * Creates an object representing a database function. This can be used in search queries, both in where and order parts, and as default values in column definitions. - * If you want to refer to columns in your function, you should use `sequelize.col`, so that the columns are properly interpreted as columns and not a strings. - * - * @see Model.findAll - * @see Sequelize.define - * @see Sequelize.col - * - * @param {string} fn The function you want to call - * @param {any} args All further arguments will be passed as arguments to the function - * - * @since v2.0.0-dev3 - * @memberof Sequelize - * @returns {Sequelize.fn} - * - * @example Convert a user's username to upper case - * ```ts - * instance.update({ - * username: fn('upper', col('username')) - * }); - * ``` - */ -export function fn(fn, ...args) { - return new Fn(fn, args); -} - -/** - * Creates an object which represents a column in the DB, this allows referencing another column in your query. This is often useful in conjunction with `sequelize.fn`, since raw string arguments to fn will be escaped. - * - * @see Sequelize#fn - * - * @param {string} col The name of the column - * @since v2.0.0-dev3 - * @memberof Sequelize - * - * @returns {Sequelize.col} - */ -export function col(col) { - return new Col(col); -} - -/** - * Creates an object representing a call to the cast function. - * - * @param {any} val The value to cast - * @param {string} type The type to cast it to - * @since v2.0.0-dev3 - * @memberof Sequelize - * - * @returns {Sequelize.cast} - */ -export function cast(val, type) { - return new Cast(val, type); -} - -/** - * Creates an object representing a literal, i.e. something that will not be escaped. - * - * @param {any} val literal value - * @since v2.0.0-dev3 - * @memberof Sequelize - * - * @returns {Sequelize.literal} - */ -export function literal(val) { - return new Literal(val); -} - /** * An AND query * @@ -1436,36 +1373,3 @@ export function or(...args) { return { [Op.or]: args }; } -/** - * Creates an object representing nested where conditions for postgres/sqlite/mysql json data-type. - * - * @see Model.findAll - * - * @param {string|object} conditionsOrPath A hash containing strings/numbers or other nested hash, a string using dot notation or a string using postgres/sqlite/mysql json syntax. - * @param {string|number|boolean} [value] An optional value to compare against. Produces a string of the form " = ''". - * @memberof Sequelize - * - * @returns {Sequelize.json} - */ -export function json(conditionsOrPath, value) { - return new Json(conditionsOrPath, value); -} - -/** - * A way of specifying attr = condition. - * - * The attr can either be an object taken from `Model.rawAttributes` (for example `Model.rawAttributes.id` or `Model.rawAttributes.name`). The - * attribute should be defined in your model definition. The attribute can also be an object from one of the sequelize utility functions (`sequelize.fn`, `sequelize.col` etc.) - * - * For string attributes, use the regular `{ where: { attr: something }}` syntax. If you don't want your string to be escaped, use `sequelize.literal`. - * - * @see Model.findAll - * - * @param {object} attr The attribute, which can be either an attribute object from `Model.rawAttributes` or a sequelize object, for example an instance of `sequelize.fn`. For simple string attributes, use the POJO syntax - * @param {symbol} [comparator='Op.eq'] operator - * @param {string|object} logic The condition. Can be both a simply type, or a further condition (`or`, `and`, `.literal` etc.) - * @since v2.0.0-dev3 - */ -export function where(attr, comparator, logic) { - return new Where(attr, comparator, logic); -} diff --git a/packages/core/src/utils/check.ts b/packages/core/src/utils/check.ts index 0dd91dad389a..3474a99e36f0 100644 --- a/packages/core/src/utils/check.ts +++ b/packages/core/src/utils/check.ts @@ -1,6 +1,6 @@ import pickBy from 'lodash/pickBy'; import { BaseError } from '../errors/index.js'; -import { Where } from './sequelize-method'; +import { Where } from '../expression-builders/where.js'; export function isNullish(val: unknown): val is null | undefined { return val == null; @@ -42,6 +42,14 @@ export function isString(val: unknown): val is string { return typeof val === 'string'; } +export function isBigInt(val: unknown): val is bigint { + return typeof val === 'bigint'; +} + +export function isNumber(val: unknown): val is number { + return typeof val === 'number'; +} + /** * Works like lodash's isPlainObject, but has better typings * @@ -57,6 +65,17 @@ export function isPlainObject(value: unknown): value is object { return prototype === null || prototype === Object.prototype; } +/** + * This function is the same as {@link isPlainObject}, but types the result as a Record / Dictionary. + * This function won't be necessary starting with TypeScript 4.9, thanks to improvements to the TS object type, + * but we have to keep it until we drop support for TS < 4.9. + * + * @param value + */ +export function isDictionary(value: unknown): value is Record { + return isPlainObject(value); +} + /** * Returns whether `value` is using the nested syntax for attributes. * diff --git a/packages/core/src/utils/dialect.ts b/packages/core/src/utils/dialect.ts index a80a7e40f41f..cf12e3f69ac7 100644 --- a/packages/core/src/utils/dialect.ts +++ b/packages/core/src/utils/dialect.ts @@ -1,8 +1,10 @@ import { randomUUID } from 'node:crypto'; +import NodeUtil from 'node:util'; import isPlainObject from 'lodash/isPlainObject'; import { v1 as uuidv1 } from 'uuid'; import type { AbstractDialect } from '../dialects/abstract'; import * as DataTypes from '../dialects/abstract/data-types.js'; +import { isString } from './check.js'; export function toDefaultValue(value: unknown, dialect: AbstractDialect): unknown { if (typeof value === 'function') { @@ -69,6 +71,10 @@ export function removeTicks(s: string, tickChar: string = TICK_CHAR): string { } export function quoteIdentifier(identifier: string, leftTick: string, rightTick: string): string { + if (!isString(identifier)) { + throw new Error(`quoteIdentifier received a non-string identifier: ${NodeUtil.inspect(identifier)}`); + } + // TODO [engine:node@>14]: drop regexp, use replaceAll with a string instead. const leftTickRegExp = new RegExp(`\\${leftTick}`, 'g'); diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index 6520b6c96852..58ce5532816b 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -1,18 +1,10 @@ import assert from 'node:assert'; import forIn from 'lodash/forIn'; import isPlainObject from 'lodash/isPlainObject'; -import type { - Attributes, - NormalizedAttributeOptions, - Model, - ModelStatic, - WhereOptions, -} from '..'; +import type { Attributes, Model, ModelStatic, NormalizedAttributeOptions, WhereOptions } from '..'; import * as DataTypes from '../data-types'; -import { Op as operators } from '../operators'; import { isString } from './check.js'; - -const operatorsSet = new Set(Object.values(operators)); +import { getComplexKeys } from './where.js'; export type FinderOptions = { attributes?: string[], @@ -148,42 +140,6 @@ export function mapWhereFieldNames(where: Record, Model: Model return newWhere; } -/** - * getComplexKeys - * - * @param obj - * @returns All keys including operators - * @private - */ -export function getComplexKeys(obj: object): Array { - return [ - ...getOperators(obj), - ...Object.keys(obj), - ]; -} - -/** - * getComplexSize - * - * @param obj - * @returns Length of object properties including operators if obj is array returns its length - * @private - */ -export function getComplexSize(obj: object | any[]): number { - return Array.isArray(obj) ? obj.length : getComplexKeys(obj).length; -} - -/** - * getOperators - * - * @param obj - * @returns All operators properties of obj - * @private - */ -export function getOperators(obj: object): symbol[] { - return Object.getOwnPropertySymbols(obj).filter(s => operatorsSet.has(s)); -} - export function combineTableNames(tableName1: string, tableName2: string): string { return tableName1.toLowerCase() < tableName2.toLowerCase() ? tableName1 + tableName2 diff --git a/packages/core/src/utils/object.ts b/packages/core/src/utils/object.ts index 19874967da94..ea828cd09d75 100644 --- a/packages/core/src/utils/object.ts +++ b/packages/core/src/utils/object.ts @@ -9,10 +9,13 @@ import isPlainObject from 'lodash/isPlainObject'; import isUndefined from 'lodash/isUndefined.js'; import mergeWith from 'lodash/mergeWith'; import omitBy from 'lodash/omitBy.js'; -import { getComplexKeys } from './format'; import type { MapView } from './immutability.js'; import { combinedIterator, map } from './iterators.js'; import { camelize } from './string'; +import { getComplexKeys } from './where.js'; + +export const EMPTY_OBJECT = Object.freeze(Object.create(null)); +export const EMPTY_ARRAY = Object.freeze([]); /** * Deeply merges object `b` into `a`. diff --git a/packages/core/src/utils/query-builder-utils.ts b/packages/core/src/utils/query-builder-utils.ts index 58ce34db2b78..a73f268c4e9d 100644 --- a/packages/core/src/utils/query-builder-utils.ts +++ b/packages/core/src/utils/query-builder-utils.ts @@ -1,7 +1,7 @@ import isEmpty from 'lodash/isEmpty.js'; import * as DataTypes from '../data-types'; import type { DataType } from '../dialects/abstract/data-types.js'; -import { getOperators } from './format.js'; +import { getOperators } from './where.js'; /** * Determine if the default value provided exists and can be described diff --git a/packages/core/src/utils/sequelize-method.ts b/packages/core/src/utils/sequelize-method.ts deleted file mode 100644 index d38b5d6c8094..000000000000 --- a/packages/core/src/utils/sequelize-method.ts +++ /dev/null @@ -1,139 +0,0 @@ -import isObject from 'lodash/isObject'; -import type { Op, WhereOperators, WhereLeftOperand, WhereAttributeHash, WhereAttributeHashValue } from '..'; - -/** - * Utility functions for representing SQL functions, and columns that should be escaped. - * Please do not use these functions directly, use Sequelize.fn and Sequelize.col instead. - * - * @private - */ -export class SequelizeMethod {} - -/** - * Do not use me directly. Use {@link fn} - */ -export class Fn extends SequelizeMethod { - private readonly fn: string; - - // unknown already covers the other two types, but they've been added explicitly to document - // passing WhereAttributeHash generates a condition inside the function. - private readonly args: Array; - - constructor(fn: string, args: Fn['args']) { - super(); - this.fn = fn; - this.args = args; - } - - clone(): Fn { - return new Fn(this.fn, this.args); - } -} - -/** - * Do not use me directly. Use {@link col} - */ -export class Col extends SequelizeMethod { - private readonly col: string[] | string; - - constructor(col: string[] | string, ...args: string[]) { - super(); - // TODO(ephys): this does not look right. First parameter is ignored if a second parameter is provided. - // should we change the signature to `constructor(...cols: string[])` - if (args.length > 0) { - col = args; - } - - this.col = col; - } -} - -/** - * Do not use me directly. Use {@link cast} - */ -export class Cast extends SequelizeMethod { - private readonly val: any; - private readonly type: string; - private readonly json: boolean; - - constructor(val: unknown, type: string = '', json: boolean = false) { - super(); - this.val = val; - this.type = type.trim(); - this.json = json; - } -} - -/** - * Do not use me directly. Use {@link literal} - */ -export class Literal extends SequelizeMethod { - /** this (type-only) brand prevents TypeScript from thinking Cast is assignable to Literal because they share the same shape */ - declare private readonly brand: 'literal'; - - private readonly val: unknown; - - constructor(val: unknown) { - super(); - this.val = val; - } -} - -/** - * Do not use me directly. Use {@link json} - */ -export class Json extends SequelizeMethod { - private readonly conditions?: { [key: string]: any }; - private readonly path?: string; - private readonly value?: string | number | boolean | null; - - constructor( - conditionsOrPath: { [key: string]: any } | string, - value?: string | number | boolean | null, - ) { - super(); - - if (typeof conditionsOrPath === 'string') { - this.path = conditionsOrPath; - - if (value) { - this.value = value; - } - } else if (isObject(conditionsOrPath)) { - this.conditions = conditionsOrPath; - } - } -} - -/** - * Do not use me directly. Use {@link where} - */ -export class Where extends SequelizeMethod { - // TODO [=7]: rename to leftOperand after typescript migration - private readonly attribute: WhereLeftOperand; - // TODO [=7]: rename to operator after typescript migration - private readonly comparator: string | Operator; - // TODO [=7]: rename to rightOperand after typescript migration - private readonly logic: WhereOperators[Operator] | WhereAttributeHashValue | any; - - constructor(leftOperand: WhereLeftOperand, operator: Operator, rightOperand: WhereOperators[Operator]); - constructor(leftOperand: WhereLeftOperand, operator: string, rightOperand: any); - constructor(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue); - constructor( - leftOperand: WhereLeftOperand, - operatorOrRightOperand: string | Operator | WhereAttributeHashValue, - rightOperand?: WhereOperators[Operator] | any, - ) { - super(); - - this.attribute = leftOperand; - - if (rightOperand !== undefined) { - this.logic = rightOperand; - this.comparator = operatorOrRightOperand; - } else { - this.logic = operatorOrRightOperand; - this.comparator = '='; - } - } -} diff --git a/packages/core/src/utils/string.ts b/packages/core/src/utils/string.ts index 32a4d5e5f15c..a34d92374552 100644 --- a/packages/core/src/utils/string.ts +++ b/packages/core/src/utils/string.ts @@ -1,7 +1,7 @@ import NodeUtil from 'node:util'; import * as _inflection from 'inflection'; import type { IndexOptions, TableName } from '../dialects/abstract/query-interface.js'; -import { SequelizeMethod } from './sequelize-method.js'; +import { BaseSqlExpression } from '../expression-builders/base-sql-expression.js'; /* Inflection */ type Inflection = typeof _inflection; @@ -101,7 +101,7 @@ ${NodeUtil.inspect(index)}`); return field; } - if (field instanceof SequelizeMethod) { + if (field instanceof BaseSqlExpression) { // eslint-disable-next-line unicorn/prefer-type-error -- not a type error. throw new Error(`Index on table ${tableName} uses Sequelize's ${field.constructor.name} as one of its fields. You need to name this index manually.`); } diff --git a/packages/core/src/utils/types.ts b/packages/core/src/utils/types.ts index 16ed233416c2..eb72f74d0958 100644 --- a/packages/core/src/utils/types.ts +++ b/packages/core/src/utils/types.ts @@ -83,3 +83,5 @@ export type OmitConstructors = Pick>; export type PartialBy = Omit & Partial>; export type RequiredBy = Omit & Required>; + +export type ReadOnlyRecord = Readonly>; diff --git a/packages/core/src/utils/where.ts b/packages/core/src/utils/where.ts new file mode 100644 index 000000000000..771480edb4e9 --- /dev/null +++ b/packages/core/src/utils/where.ts @@ -0,0 +1,39 @@ +import { Op as operators } from '../operators.js'; + +/** + * getComplexKeys + * + * @param obj + * @returns All keys including operators + * @private + */ +export function getComplexKeys(obj: object): Array { + return [ + ...getOperators(obj), + ...Object.keys(obj), + ]; +} + +/** + * getComplexSize + * + * @param obj + * @returns Length of object properties including operators if obj is array returns its length + * @private + */ +export function getComplexSize(obj: object | any[]): number { + return Array.isArray(obj) ? obj.length : getComplexKeys(obj).length; +} + +const operatorsSet = new Set(Object.values(operators)); + +/** + * getOperators + * + * @param obj + * @returns All operators properties of obj + * @private + */ +export function getOperators(obj: object): symbol[] { + return Object.getOwnPropertySymbols(obj).filter(s => operatorsSet.has(s)); +} diff --git a/packages/core/test/integration/associations/belongs-to.test.js b/packages/core/test/integration/associations/belongs-to.test.js index c4894f7f3392..f1d217a1ac4e 100644 --- a/packages/core/test/integration/associations/belongs-to.test.js +++ b/packages/core/test/integration/associations/belongs-to.test.js @@ -37,19 +37,23 @@ describe(Support.getTestDialectTeaser('BelongsTo'), () => { await this.sequelize.sync({ force: true }); - const tasks = await Promise.all([Task.create({ - id: 1, - user: { id: 1 }, - }, { - include: [Task.User], - }), Task.create({ - id: 2, - user: { id: 2 }, - }, { - include: [Task.User], - }), Task.create({ - id: 3, - })]); + const tasks = await Promise.all([ + Task.create({ + id: 1, + user: { id: 1 }, + }, { + include: [Task.User], + }), + Task.create({ + id: 2, + user: { id: 2 }, + }, { + include: [Task.User], + }), + Task.create({ + id: 3, + }), + ]); const result = await Task.User.get(tasks); expect(result.get(tasks[0].id).id).to.equal(tasks[0].user.id); diff --git a/packages/core/test/integration/include.test.js b/packages/core/test/integration/include.test.js index a15f450ad4f8..a301e67ae0cf 100644 --- a/packages/core/test/integration/include.test.js +++ b/packages/core/test/integration/include.test.js @@ -4,7 +4,7 @@ const chai = require('chai'); const expect = chai.expect; const Support = require('./support'); -const { DataTypes, Sequelize } = require('@sequelize/core'); +const { DataTypes, Sequelize, or, and } = require('@sequelize/core'); const _ = require('lodash'); const dialect = Support.getTestDialect(); @@ -909,10 +909,10 @@ Instead of specifying a Model, either: await createUsersAndItems.bind(this)(); }); - it('should support Sequelize.and()', async function () { + it('should support and()', async function () { const result = await this.User.findAll({ include: [ - { model: this.Item, where: Sequelize.and({ test: 'def' }) }, + { model: this.Item, where: and({ test: 'def' }) }, ], }); @@ -920,11 +920,12 @@ Instead of specifying a Model, either: expect(result[0].Item.test).to.eql('def'); }); - it('should support Sequelize.or()', async function () { + it('should support or()', async function () { await expect(this.User.findAll({ include: [ { - model: this.Item, where: Sequelize.or({ + model: this.Item, + where: or({ test: 'def', }, { test: 'abc', @@ -942,7 +943,8 @@ Instead of specifying a Model, either: const result = await this.User.findAndCountAll({ include: [ { - model: this.Item, where: { + model: this.Item, + where: { test: 'def', }, }, diff --git a/packages/core/test/integration/instance.validations.test.js b/packages/core/test/integration/instance.validations.test.js index 8eb2cb03d6d5..009ed533d757 100644 --- a/packages/core/test/integration/instance.validations.test.js +++ b/packages/core/test/integration/instance.validations.test.js @@ -531,7 +531,7 @@ describe(Support.getTestDialectTeaser('InstanceValidator'), () => { await expect(failingBar.validate({ skip: ['field'] })).not.to.be.rejected; }); - it('skips validations for fields with value that is SequelizeMethod', async function () { + it('skips validations for fields with value that is BaseExpression', async function () { const values = ['value1', 'value2']; const Bar = this.sequelize.define(`Bar${Support.rand()}`, { diff --git a/packages/core/test/integration/model.test.js b/packages/core/test/integration/model.test.js index 118b08645c2e..f248cdf16681 100644 --- a/packages/core/test/integration/model.test.js +++ b/packages/core/test/integration/model.test.js @@ -80,7 +80,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { it('throws an error if a custom model-wide validation is not a function', function () { expect(() => { this.sequelize.define('Foo', { - field: DataTypes.INTEGER, + columnName: DataTypes.INTEGER, }, { validate: { notFunction: 33, @@ -227,7 +227,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { it('allows unique on column with field aliases', async function () { const User = this.sequelize.define('UserWithUniqueFieldAlias', { - userName: { type: DataTypes.STRING, unique: 'user_name_unique', field: 'user_name' }, + userName: { type: DataTypes.STRING, unique: 'user_name_unique', columnName: 'user_name' }, }); await User.sync({ force: true }); @@ -837,17 +837,17 @@ describe(Support.getTestDialectTeaser('Model'), () => { it('should map the correct fields when saving instance (#10589)', async function () { const User = this.sequelize.define('User', { id3: { - field: 'id', + columnName: 'id', type: DataTypes.INTEGER, primaryKey: true, }, id: { - field: 'id2', + columnName: 'id2', type: DataTypes.INTEGER, allowNull: false, }, id2: { - field: 'id3', + columnName: 'id3', type: DataTypes.INTEGER, allowNull: false, }, @@ -879,17 +879,17 @@ describe(Support.getTestDialectTeaser('Model'), () => { it('should map the correct fields when updating instance (#10589)', async function () { const User = this.sequelize.define('User', { id3: { - field: 'id', + columnName: 'id', type: DataTypes.INTEGER, primaryKey: true, }, id: { - field: 'id2', + columnName: 'id2', type: DataTypes.INTEGER, allowNull: false, }, id2: { - field: 'id3', + columnName: 'id3', type: DataTypes.INTEGER, allowNull: false, }, @@ -1422,7 +1422,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { const UserProject = this.sequelize.define('UserProject', { userId: { type: DataTypes.INTEGER, - field: 'user_id', + columnName: 'user_id', }, }); @@ -1958,12 +1958,12 @@ describe(Support.getTestDialectTeaser('Model'), () => { this.UserWithFields = this.sequelize.define('UserWithFields', { age: { type: DataTypes.INTEGER, - field: 'user_age', + columnName: 'user_age', }, order: DataTypes.INTEGER, gender: { type: DataTypes.ENUM('male', 'female'), - field: 'male_female', + columnName: 'male_female', }, }); @@ -2739,7 +2739,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { username: { type: DataTypes.STRING, allowNull: false, - field: 'data', + columnName: 'data', get() { const val = this.getDataValue('username'); diff --git a/packages/core/test/integration/model/create.test.js b/packages/core/test/integration/model/create.test.js index f458aff71958..48b7cf46c751 100644 --- a/packages/core/test/integration/model/create.test.js +++ b/packages/core/test/integration/model/create.test.js @@ -530,39 +530,34 @@ describe(Support.getTestDialectTeaser('Model'), () => { username: 'gottlieb', }); - return Promise.all([(async () => { - try { - await User.findOrCreate({ + return Promise.all([ + (async () => { + const error = await expect(User.findOrCreate({ where: { objectId: 'asdasdasd', }, defaults: { username: 'gottlieb', }, - }); + })).to.be.rejectedWith(Sequelize.UniqueConstraintError); - throw new Error('I should have ben rejected'); - } catch (error) { expect(error instanceof Sequelize.UniqueConstraintError).to.be.ok; expect(error.fields).to.be.ok; - } - })(), (async () => { - try { - await User.findOrCreate({ + })(), + (async () => { + const error = await expect(User.findOrCreate({ where: { objectId: 'asdasdasd', }, defaults: { username: 'gottlieb', }, - }); + })).to.be.rejectedWith(Sequelize.UniqueConstraintError); - throw new Error('I should have ben rejected'); - } catch (error) { expect(error instanceof Sequelize.UniqueConstraintError).to.be.ok; expect(error.fields).to.be.ok; - } - })()]); + })(), + ]); }); it('works without a transaction', async function () { From 5c428218df05a6354cc039d73eb58a49434172ee Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Sat, 11 Mar 2023 21:20:33 +0100 Subject: [PATCH 17/18] feat: remove escape options from data types (#15766) --- .../src/dialects/abstract/data-types-utils.ts | 12 +- .../core/src/dialects/abstract/data-types.ts | 153 ++++++++++-------- .../src/dialects/abstract/query-generator.js | 44 +++-- .../dialects/abstract/query-interface.d.ts | 2 +- packages/core/src/dialects/db2/data-types.ts | 6 +- .../core/src/dialects/ibmi/query-generator.js | 2 +- .../src/dialects/mssql/query-generator.js | 1 + .../core/src/dialects/mysql/data-types.ts | 16 +- .../core/src/dialects/postgres/data-types.ts | 110 ++++++------- .../core/src/dialects/snowflake/data-types.ts | 10 +- packages/core/src/instance-validator.js | 2 +- packages/core/src/model-definition.ts | 4 +- packages/core/src/model.d.ts | 6 +- packages/core/src/model.js | 12 +- packages/core/src/sql-string.ts | 14 +- packages/core/src/utils/dialect.ts | 5 +- packages/core/src/utils/sql.ts | 4 +- packages/core/test/integration/model.test.js | 2 +- .../model/attributes/types.test.js | 2 +- .../test/integration/model/create.test.js | 2 - .../test/integration/model/searchPath.test.js | 36 ++--- packages/core/test/support.ts | 14 +- packages/core/test/types/models/user.ts | 2 +- packages/core/test/unit/data-types/_utils.ts | 2 +- .../unit/data-types/misc-data-types.test.ts | 3 +- .../unit/data-types/temporal-types.test.ts | 8 +- .../dialects/postgres/range-data-type.test.ts | 88 ++++------ .../query-generator/add-column-query.test.ts | 4 +- .../query-generator/arithmetic-query.test.ts | 2 +- .../query-generator/bulk-insert-query.test.ts | 2 +- .../unit/query-generator/delete-query.test.ts | 2 +- .../unit/query-generator/insert-query.test.ts | 14 +- .../remove-column-query.test.ts | 4 +- .../unit/query-generator/select-query.test.ts | 24 +-- .../unit/query-generator/update-query.test.ts | 14 +- .../unit/query-interface/bulk-delete.test.ts | 2 +- .../unit/query-interface/bulk-insert.test.ts | 6 +- .../unit/query-interface/bulk-update.test.ts | 8 +- .../unit/query-interface/decrement.test.ts | 10 +- .../test/unit/query-interface/delete.test.ts | 2 +- .../unit/query-interface/increment.test.ts | 10 +- .../test/unit/query-interface/insert.test.ts | 8 +- .../unit/query-interface/raw-select.test.ts | 2 +- .../test/unit/query-interface/select.test.ts | 2 +- .../test/unit/query-interface/update.test.ts | 20 +-- .../test/unit/query-interface/upsert.test.ts | 12 +- packages/core/test/unit/sql/insert.test.js | 20 +-- packages/core/test/unit/sql/update.test.js | 6 +- packages/core/test/unit/utils/sql.test.ts | 6 +- packages/core/test/unit/utils/utils.test.ts | 14 +- 50 files changed, 358 insertions(+), 398 deletions(-) diff --git a/packages/core/src/dialects/abstract/data-types-utils.ts b/packages/core/src/dialects/abstract/data-types-utils.ts index 0139edb17800..42c066dea6fd 100644 --- a/packages/core/src/dialects/abstract/data-types-utils.ts +++ b/packages/core/src/dialects/abstract/data-types-utils.ts @@ -1,7 +1,7 @@ import NodeUtils from 'node:util'; import { BaseError, ValidationErrorItem } from '../../errors/index.js'; import type { Model } from '../../model.js'; -import type { DataType, DataTypeClass, DataTypeClassOrInstance, DataTypeInstance, ToSqlOptions } from './data-types.js'; +import type { DataType, DataTypeClass, DataTypeClassOrInstance, DataTypeInstance } from './data-types.js'; import { AbstractDataType } from './data-types.js'; import type { AbstractDialect } from './index.js'; @@ -51,10 +51,10 @@ export function dataTypeClassOrInstanceToInstance(Type: DataTypeClassOrInstance) } export function validateDataType( - type: AbstractDataType, - attributeName: string, - modelInstance: Model | null, value: unknown, + type: AbstractDataType, + attributeName: string = '[unnamed]', + modelInstance: Model | null = null, ): ValidationErrorItem | null { try { type.validate(value); @@ -77,13 +77,13 @@ export function validateDataType( } } -export function attributeTypeToSql(type: AbstractDataType | string, options: ToSqlOptions): string { +export function attributeTypeToSql(type: AbstractDataType | string): string { if (typeof type === 'string') { return type; } if (type instanceof AbstractDataType) { - return type.toSql(options); + return type.toSql(); } throw new Error('attributeTypeToSql received a type that is neither a string or an instance of AbstractDataType'); diff --git a/packages/core/src/dialects/abstract/data-types.ts b/packages/core/src/dialects/abstract/data-types.ts index 8cafb836060f..123e5fbb7b2c 100644 --- a/packages/core/src/dialects/abstract/data-types.ts +++ b/packages/core/src/dialects/abstract/data-types.ts @@ -9,7 +9,7 @@ import { ValidationErrorItem } from '../../errors'; import type { Falsy } from '../../generic/falsy'; import type { GeoJson, GeoJsonType } from '../../geo-json.js'; import { assertIsGeoJson } from '../../geo-json.js'; -import type { NormalizedAttributeOptions, ModelStatic, Rangable, RangePart } from '../../model.js'; +import type { ModelStatic, Rangable, RangePart } from '../../model.js'; import type { Sequelize } from '../../sequelize.js'; import { makeBufferFromTypedArray } from '../../utils/buffer.js'; import { isPlainObject, isString } from '../../utils/check.js'; @@ -21,6 +21,7 @@ import { validator as Validator } from '../../utils/validator-extras'; import type { HstoreRecord } from '../postgres/hstore.js'; import { buildRangeParser } from '../postgres/range.js'; import { + attributeTypeToSql, dataTypeClassOrInstanceToInstance, isDataType, isDataTypeClass, @@ -62,18 +63,9 @@ export type DataType = | string | DataTypeClassOrInstance; -export interface ToSqlOptions { - dialect: AbstractDialect; -} - -export interface StringifyOptions { - dialect: AbstractDialect; - operation?: string; - timezone?: string | undefined; - field?: NormalizedAttributeOptions; -} +export type NormalizedDataType = string | DataTypeInstance; -export interface BindParamOptions extends StringifyOptions { +export interface BindParamOptions { bindParam(value: unknown): string; } @@ -218,16 +210,15 @@ export abstract class AbstractDataType< * The resulting value will be inlined as-is with no further escaping. * * @param value The value to escape. - * @param options Options. */ - escape(value: AcceptedType, options: StringifyOptions): string { - const asBindValue = this.toBindableValue(value, options); + escape(value: AcceptedType): string { + const asBindValue = this.toBindableValue(value); if (!isString(asBindValue)) { throw new Error(`${this.constructor.name}#stringify has been overridden to return a non-string value, so ${this.constructor.name}#escape must be implemented to handle that value correctly.`); } - return options.dialect.escapeString(asBindValue); + return this._getDialect().escapeString(asBindValue); } /** @@ -246,7 +237,7 @@ export abstract class AbstractDataType< */ getBindParamSql(value: AcceptedType, options: BindParamOptions): string { // TODO: rename "options.bindParam" to "options.collectBindParam" - return options.bindParam(this.toBindableValue(value, options)); + return options.bindParam(this.toBindableValue(value)); } /** @@ -255,15 +246,14 @@ export abstract class AbstractDataType< * will handle escaping. * * @param value The value to convert. - * @param _options Options. */ - toBindableValue(value: AcceptedType, _options: StringifyOptions): unknown { + toBindableValue(value: AcceptedType): unknown { return String(value); } toString(): string { try { - return this.toSql({ dialect: this.usageContext?.sequelize.dialect! }); + return this.toSql(); } catch { // best effort introspection (dialect may not be available) return this.constructor.toString(); @@ -278,7 +268,7 @@ export abstract class AbstractDataType< * Returns a SQL declaration of this data type. * e.g. 'VARCHAR(255)', 'TEXT', etc… */ - abstract toSql(options: ToSqlOptions): string; + abstract toSql(): string; /** * Override this method to emit an error or a warning if the Data Type, as it is configured, is not compatible @@ -336,6 +326,16 @@ export abstract class AbstractDataType< return this._construct(this.options); } + withUsageContext(usageContext: DataTypeUseContext): this { + const out = this.clone().attachUsageContext(usageContext); + + if (this.#dialect) { + out.#dialect = this.#dialect; + } + + return out; + } + /** * @param usageContext * @private @@ -426,7 +426,7 @@ export class STRING extends AbstractDataType { } } - toSql(_options: ToSqlOptions): string { + toSql(): string { // TODO: STRING should use an unlimited length type by default - https://github.com/sequelize/sequelize/issues/14259 return joinSQLFragments([ `VARCHAR(${this.options.length ?? 255})`, @@ -471,12 +471,12 @@ export class STRING extends AbstractDataType { return new this({ binary: true }); } - escape(value: string | Buffer, options: StringifyOptions): string { + escape(value: string | Buffer): string { if (Buffer.isBuffer(value)) { - return options.dialect.escapeBuffer(value); + return this._getDialect().escapeBuffer(value); } - return options.dialect.escapeString(value); + return this._getDialect().escapeString(value); } toBindableValue(value: string | Buffer): unknown { @@ -683,10 +683,10 @@ export class BaseNumberDataType e throw new Error(`getNumberSqlTypeName has not been implemented in ${this.constructor.name}`); } - toSql(_options: ToSqlOptions): string { + toSql(): string { let result: string = this.getNumberSqlTypeName(); - if (this.options.unsigned && this._supportsNativeUnsigned(_options.dialect)) { + if (this.options.unsigned && this._supportsNativeUnsigned(this._getDialect())) { result += ' UNSIGNED'; } @@ -715,11 +715,11 @@ export class BaseNumberDataType e } } - escape(value: AcceptedNumber, options: StringifyOptions): string { - return this.toBindableValue(value, options); + escape(value: AcceptedNumber): string { + return String(this.toBindableValue(value)); } - toBindableValue(num: AcceptedNumber, _options: StringifyOptions): string { + toBindableValue(num: AcceptedNumber): string | number { // This should be unnecessary but since this directly returns the passed string its worth the added validation. this.validate(num); @@ -733,7 +733,7 @@ export class BaseNumberDataType e return `${sign}Infinity`; } - return String(num); + return num; } getBindParamSql(value: AcceptedNumber, options: BindParamOptions): string { @@ -809,13 +809,13 @@ export class BaseIntegerDataType extends BaseNumberDataType { return _dialect.supports.dataTypes.INTS.unsigned; } - toSql(options: ToSqlOptions): string { + toSql(): string { let result: string = this.getNumberSqlTypeName(); if (this.options.length != null) { result += `(${this.options.length})`; } - if (this.options.unsigned && this._supportsNativeUnsigned(options.dialect)) { + if (this.options.unsigned && this._supportsNativeUnsigned(this._getDialect())) { result += ' UNSIGNED'; } @@ -1072,13 +1072,13 @@ export class BaseDecimalNumberDataType extends BaseNumberDataType { } toBindableValue(value: boolean | Falsy): unknown { - return value ? 'true' : 'false'; + return Boolean(value); } } @@ -1464,24 +1464,23 @@ export class DATE extends AbstractDataType { return false; } - protected _applyTimezone(date: AcceptedDate, options: { timezone?: string | undefined }) { - if (options.timezone) { - if (isValidTimeZone(options.timezone)) { - return dayjs(date).tz(options.timezone); + protected _applyTimezone(date: AcceptedDate) { + const timezone = this._getDialect().sequelize.options.timezone; + + if (timezone) { + if (isValidTimeZone(timezone)) { + return dayjs(date).tz(timezone); } - return dayjs(date).utcOffset(options.timezone); + return dayjs(date).utcOffset(timezone); } return dayjs(date); } - toBindableValue( - date: AcceptedDate, - options: StringifyOptions, - ) { + toBindableValue(date: AcceptedDate) { // Z here means current timezone, _not_ UTC - return this._applyTimezone(date, options).format('YYYY-MM-DD HH:mm:ss.SSS Z'); + return this._applyTimezone(date).format('YYYY-MM-DD HH:mm:ss.SSS Z'); } } @@ -1507,7 +1506,7 @@ export class DATEONLY extends AbstractDataType { return 'DATE'; } - toBindableValue(date: AcceptedDate, _options: StringifyOptions) { + toBindableValue(date: AcceptedDate) { return dayjs.utc(date).format('YYYY-MM-DD'); } @@ -1751,10 +1750,10 @@ export class BLOB extends AbstractDataType { return value; } - escape(value: string | Buffer, options: StringifyOptions) { + escape(value: string | Buffer) { const buf = typeof value === 'string' ? Buffer.from(value, 'binary') : value; - return options.dialect.escapeBuffer(buf); + return this._getDialect().escapeBuffer(buf); } getBindParamSql(value: AcceptedBlob, options: BindParamOptions) { @@ -2210,8 +2209,8 @@ sequelize.define('MyModel', { } } - toSql(options: ToSqlOptions): string { - throw new Error(`ENUM has not been implemented in the ${options.dialect.name} dialect.`); + toSql(): string { + throw new Error(`ENUM has not been implemented in the ${this._getDialect().name} dialect.`); } } @@ -2220,7 +2219,7 @@ export interface ArrayOptions { } interface NormalizedArrayOptions { - type: AbstractDataType; + type: NormalizedDataType; } /** @@ -2244,7 +2243,7 @@ export class ARRAY> extends AbstractDataType> extends AbstractDataType> extends AbstractDataType = this.options.type; + for (const item of value) { - this.options.type.validate(item); + subType.validate(item); } } @@ -2279,7 +2284,13 @@ export class ARRAY> extends AbstractDataType this.options.type.sanitize(item)); + if (isString(this.options.type)) { + return; + } + + const subType: AbstractDataType = this.options.type; + + return value.map(item => subType.sanitize(item)); } parseDatabaseValue(value: unknown[]): unknown { @@ -2288,11 +2299,23 @@ export class ARRAY> extends AbstractDataType this.options.type.parseDatabaseValue(item)); + if (isString(this.options.type)) { + return value; + } + + const subType: AbstractDataType = this.options.type; + + return value.map(item => subType.parseDatabaseValue(item)); } - toBindableValue(value: Array>, _options: StringifyOptions): unknown { - return value.map(val => this.options.type.toBindableValue(val, _options)); + toBindableValue(value: Array>): unknown { + if (isString(this.options.type)) { + return value; + } + + const subType: AbstractDataType = this.options.type; + + return value.map(val => subType.toBindableValue(val)); } protected _checkOptionSupport(dialect: AbstractDialect) { @@ -2310,13 +2333,17 @@ export class ARRAY> extends AbstractDataType 0 && orderIndex !== -1) { - item = this.sequelize.literal(` ${validOrderOptions[orderIndex]}`); + item = new Literal(` ${validOrderOptions[orderIndex]}`); } else if (isModelStatic(previousModel)) { const { modelDefinition: previousModelDefinition } = previousModel; @@ -992,7 +990,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { item = this.jsonPathExtractionQuery(identifier, path); // literal because we don't want to append the model name when string - item = this.sequelize.literal(item); + item = new Literal(item); } } } @@ -1101,7 +1099,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (value == null || attribute?.type == null || typeof attribute.type === 'string') { // use default escape mechanism instead of the DataType's. - return SqlString.escape(value, this.options.timezone, this.dialect); + return SqlString.escape(value, this.dialect); } if (!attribute.type.belongsToDialect(this.dialect)) { @@ -1177,7 +1175,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { } const error = field.type instanceof AbstractDataType - ? validateDataType(field.type, field.fieldName, null, value) + ? validateDataType(value, field.type, field.fieldName, null) : null; if (error) { throw error; @@ -1372,7 +1370,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { options.attributes.push([order, alias]); // We don't want to prepend model name when we alias the attributes, so quote them here - alias = this.sequelize.literal(this.quote(alias, undefined, undefined, options)); + alias = new Literal(this.quote(alias, undefined, undefined, options)); if (Array.isArray(options.order[i])) { options.order[i][0] = alias; @@ -1858,7 +1856,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (include.on) { joinOn = this.whereItemsQuery(include.on, { - prefix: this.sequelize.literal(this.quoteIdentifier(asRight)), + prefix: new Literal(this.quoteIdentifier(asRight)), model: include.model, replacements: options?.replacements, }); @@ -1866,7 +1864,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (include.where) { joinWhere = this.whereItemsQuery(include.where, { - prefix: this.sequelize.literal(this.quoteIdentifier(asRight)), + prefix: new Literal(this.quoteIdentifier(asRight)), model: include.model, replacements: options?.replacements, }); @@ -2050,7 +2048,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { targetJoinOn += `${this.quoteIdentifier(throughAs)}.${this.quoteIdentifier(identTarget)}`; if (through.where) { - throughWhere = this.getWhereConditions(through.where, this.sequelize.literal(this.quoteIdentifier(throughAs)), through.model, topLevelInfo.options); + throughWhere = this.getWhereConditions(through.where, new Literal(this.quoteIdentifier(throughAs)), through.model, topLevelInfo.options); } // Generate a wrapped join so that the through table join can be dependent on the target join @@ -2063,7 +2061,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { joinCondition = sourceJoinOn; if ((include.where || include.through.where) && include.where) { - targetWhere = this.getWhereConditions(include.where, this.sequelize.literal(this.quoteIdentifier(includeAs.internalAs)), include.model, topLevelInfo.options); + targetWhere = this.getWhereConditions(include.where, new Literal(this.quoteIdentifier(includeAs.internalAs)), include.model, topLevelInfo.options); if (targetWhere) { joinCondition += ` AND ${targetWhere}`; } @@ -2134,7 +2132,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { model: topInclude.through.model, where: { [Op.and]: [ - this.sequelize.literal([ + new Literal([ `${this.quoteTable(topParent.model.name)}.${this.quoteIdentifier(topParent.model.primaryKeyField)}`, `${this.quoteIdentifier(topInclude.through.model.name)}.${this.quoteIdentifier(topAssociation.identifierField)}`, ].join(' = ')), @@ -2161,7 +2159,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { where: { [Op.and]: [ topInclude.where, - { [Op.join]: this.sequelize.literal(join) }, + { [Op.join]: new Literal(join) }, ], }, limit: 1, @@ -2174,7 +2172,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { topLevelInfo.options.where[Op.and] = []; } - topLevelInfo.options.where[`__${includeAs.internalAs}`] = this.sequelize.literal([ + topLevelInfo.options.where[`__${includeAs.internalAs}`] = new Literal([ '(', query.replace(/;$/, ''), ')', diff --git a/packages/core/src/dialects/abstract/query-interface.d.ts b/packages/core/src/dialects/abstract/query-interface.d.ts index 32d41d6e2004..f872a62b28c9 100644 --- a/packages/core/src/dialects/abstract/query-interface.d.ts +++ b/packages/core/src/dialects/abstract/query-interface.d.ts @@ -497,7 +497,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript { /** * Inserts a new record */ - insert(instance: Model | null, tableName: string, values: object, options?: QiInsertOptions): Promise; + insert(instance: Model | null, tableName: TableName, values: object, options?: QiInsertOptions): Promise; /** * Inserts or Updates a record in the database diff --git a/packages/core/src/dialects/db2/data-types.ts b/packages/core/src/dialects/db2/data-types.ts index 7b7e0f151c96..0cc167ee4f07 100644 --- a/packages/core/src/dialects/db2/data-types.ts +++ b/packages/core/src/dialects/db2/data-types.ts @@ -1,7 +1,7 @@ import dayjs from 'dayjs'; import maxBy from 'lodash/maxBy.js'; import * as BaseTypes from '../abstract/data-types.js'; -import type { AcceptedDate, ToSqlOptions } from '../abstract/data-types.js'; +import type { AcceptedDate } from '../abstract/data-types.js'; import type { AbstractDialect } from '../abstract/index.js'; function removeUnsupportedIntegerOptions(dataType: BaseTypes.BaseIntegerDataType, dialect: AbstractDialect) { @@ -36,7 +36,7 @@ export class BLOB extends BaseTypes.BLOB { } export class STRING extends BaseTypes.STRING { - toSql(options: ToSqlOptions) { + toSql() { const length = this.options.length ?? 255; if (this.options.binary) { @@ -44,7 +44,7 @@ export class STRING extends BaseTypes.STRING { return `VARCHAR(${length}) FOR BIT DATA`; } - throw new Error(`${options.dialect.name} does not support the BINARY option for data types with a length greater than 4000.`); + throw new Error(`${this._getDialect().name} does not support the BINARY option for data types with a length greater than 4000.`); } if (length <= 4000) { diff --git a/packages/core/src/dialects/ibmi/query-generator.js b/packages/core/src/dialects/ibmi/query-generator.js index f9bfb2952681..11eb7a1eab2f 100644 --- a/packages/core/src/dialects/ibmi/query-generator.js +++ b/packages/core/src/dialects/ibmi/query-generator.js @@ -318,7 +318,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { const format = (value === null && options.where); // use default escape mechanism instead of the DataType's. - return SqlString.escape(value, this.options.timezone, this.dialect, format); + return SqlString.escape(value, this.dialect, format); } if (!attribute.type.belongsToDialect(this.dialect)) { diff --git a/packages/core/src/dialects/mssql/query-generator.js b/packages/core/src/dialects/mssql/query-generator.js index 7d80937cce5f..eb2072071b7d 100644 --- a/packages/core/src/dialects/mssql/query-generator.js +++ b/packages/core/src/dialects/mssql/query-generator.js @@ -457,6 +457,7 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { } upsertQuery(tableName, insertValues, updateValues, where, model, options) { + // TODO: support TableNameWithSchema objects const targetTableAlias = this.quoteTable(`${tableName}_target`); const sourceTableAlias = this.quoteTable(`${tableName}_source`); const primaryKeysColumns = []; diff --git a/packages/core/src/dialects/mysql/data-types.ts b/packages/core/src/dialects/mysql/data-types.ts index fe79f44d1f05..0d3aed849d51 100644 --- a/packages/core/src/dialects/mysql/data-types.ts +++ b/packages/core/src/dialects/mysql/data-types.ts @@ -7,8 +7,6 @@ import { isValidTimeZone } from '../../utils/dayjs'; import * as BaseTypes from '../abstract/data-types.js'; import type { AcceptedDate, - StringifyOptions, - ToSqlOptions, BindParamOptions, } from '../abstract/data-types.js'; @@ -92,8 +90,8 @@ export class BOOLEAN extends BaseTypes.BOOLEAN { } export class DATE extends BaseTypes.DATE { - toBindableValue(date: AcceptedDate, options: StringifyOptions) { - date = this._applyTimezone(date, options); + toBindableValue(date: AcceptedDate) { + date = this._applyTimezone(date); return date.format('YYYY-MM-DD HH:mm:ss.SSS'); } @@ -119,8 +117,8 @@ export class UUID extends BaseTypes.UUID { } export class GEOMETRY extends BaseTypes.GEOMETRY { - toBindableValue(value: GeoJson, options: StringifyOptions) { - return `ST_GeomFromText(${options.dialect.escapeString( + toBindableValue(value: GeoJson) { + return `ST_GeomFromText(${this._getDialect().escapeString( wkx.Geometry.parseGeoJSON(value).toWkt(), )})`; } @@ -137,7 +135,9 @@ export class GEOMETRY extends BaseTypes.GEOMETRY { } export class ENUM extends BaseTypes.ENUM { - toSql(options: ToSqlOptions) { - return `ENUM(${this.options.values.map(value => options.dialect.escapeString(value)).join(', ')})`; + toSql() { + const dialect = this._getDialect(); + + return `ENUM(${this.options.values.map(value => dialect.escapeString(value)).join(', ')})`; } } diff --git a/packages/core/src/dialects/postgres/data-types.ts b/packages/core/src/dialects/postgres/data-types.ts index 1aecb8741722..598848703708 100644 --- a/packages/core/src/dialects/postgres/data-types.ts +++ b/packages/core/src/dialects/postgres/data-types.ts @@ -1,14 +1,15 @@ import assert from 'node:assert'; import wkx from 'wkx'; import type { Rangable } from '../../model.js'; -import { isString } from '../../utils/check.js'; +import { isBigInt, isNumber, isString } from '../../utils/check.js'; +import * as BaseTypes from '../abstract/data-types'; import type { AcceptableTypeOf, - StringifyOptions, BindParamOptions, AcceptedDate, + AbstractDataType, } from '../abstract/data-types'; -import * as BaseTypes from '../abstract/data-types'; +import { attributeTypeToSql } from '../abstract/data-types-utils.js'; import type { AbstractDialect } from '../abstract/index.js'; import * as Hstore from './hstore'; import { PostgresQueryGenerator } from './query-generator'; @@ -24,7 +25,7 @@ function removeUnsupportedIntegerOptions(dataType: BaseTypes.BaseIntegerDataType } export class DATEONLY extends BaseTypes.DATEONLY { - toBindableValue(value: AcceptableTypeOf, options: StringifyOptions) { + toBindableValue(value: AcceptableTypeOf) { if (value === Number.POSITIVE_INFINITY) { return 'infinity'; } @@ -33,12 +34,12 @@ export class DATEONLY extends BaseTypes.DATEONLY { return '-infinity'; } - return super.toBindableValue(value, options); + return super.toBindableValue(value); } sanitize(value: unknown): unknown { if (value === Number.POSITIVE_INFINITY - || value === Number.NEGATIVE_INFINITY) { + || value === Number.NEGATIVE_INFINITY) { return value; } @@ -87,7 +88,7 @@ export class DATE extends BaseTypes.DATE { validate(value: any) { if (value === Number.POSITIVE_INFINITY - || value === Number.NEGATIVE_INFINITY) { + || value === Number.NEGATIVE_INFINITY) { // valid return; } @@ -95,10 +96,7 @@ export class DATE extends BaseTypes.DATE { super.validate(value); } - toBindableValue( - value: AcceptedDate, - options: StringifyOptions, - ): string { + toBindableValue(value: AcceptedDate): string { if (value === Number.POSITIVE_INFINITY) { return 'infinity'; } @@ -107,7 +105,7 @@ export class DATE extends BaseTypes.DATE { return '-infinity'; } - return super.toBindableValue(value, options); + return super.toBindableValue(value); } sanitize(value: unknown) { @@ -258,8 +256,8 @@ export class GEOMETRY extends BaseTypes.GEOMETRY { return wkx.Geometry.parse(b).toGeoJSON({ shortCrs: true }); } - toBindableValue(value: AcceptableTypeOf, options: StringifyOptions): string { - return `ST_GeomFromGeoJSON(${options.dialect.escapeString(JSON.stringify(value))})`; + toBindableValue(value: AcceptableTypeOf): string { + return `ST_GeomFromGeoJSON(${this._getDialect().escapeString(JSON.stringify(value))})`; } getBindParamSql(value: AcceptableTypeOf, options: BindParamOptions) { @@ -282,11 +280,8 @@ export class GEOGRAPHY extends BaseTypes.GEOGRAPHY { return result; } - toBindableValue( - value: AcceptableTypeOf, - options: StringifyOptions, - ) { - return `ST_GeomFromGeoJSON(${options.dialect.escapeString(JSON.stringify(value))})`; + toBindableValue(value: AcceptableTypeOf) { + return `ST_GeomFromGeoJSON(${this._getDialect().escapeString(JSON.stringify(value))})`; } getBindParamSql(value: AcceptableTypeOf, options: BindParamOptions) { @@ -305,91 +300,78 @@ export class HSTORE extends BaseTypes.HSTORE { } export class RANGE extends BaseTypes.RANGE { - toBindableValue(values: Rangable>, options: StringifyOptions) { + toBindableValue(values: Rangable>): string { if (!Array.isArray(values)) { - return this.options.subtype.toBindableValue(values, options); + throw new TypeError('Range values must be an array'); } return RangeParser.stringify(values, rangePart => { - const out = this.options.subtype.toBindableValue(rangePart, options); + let out = this.options.subtype.toBindableValue(rangePart); + + if (isNumber(out) || isBigInt(out)) { + out = String(out); + } if (!isString(out)) { - throw new Error('DataTypes.RANGE only accepts types that can be stringified.'); + throw new Error('DataTypes.RANGE only accepts types that are represented by either strings, numbers or bigints.'); } return out; }); } - escape(values: Rangable>, options: StringifyOptions): string { - const value = this.toBindableValue(values, options); - if (!Array.isArray(values)) { - return `'${value}'::${this.#toCastType()}`; - } + escape(values: Rangable>): string { + const value = this.toBindableValue(values); + const dialect = this._getDialect(); - return `'${value}'`; + return dialect.escapeString(value); } getBindParamSql( values: Rangable>, options: BindParamOptions, ): string { - const value = this.toBindableValue(values, options); - if (!Array.isArray(values)) { - return `${options.bindParam(value ?? '')}::${this.#toCastType()}`; - } + const value = this.toBindableValue(values); - return options.bindParam(value); + return `${options.bindParam(value)}::${this.toSql()}`; } toSql() { const subTypeClass = this.options.subtype.constructor as typeof BaseTypes.AbstractDataType; - return RANGE.typeMap.subTypes[subTypeClass.getDataTypeId().toLowerCase()]; + return RANGE.typeMap[subTypeClass.getDataTypeId().toLowerCase()]; } - #toCastType(): string { - const subTypeClass = this.options.subtype.constructor as typeof BaseTypes.AbstractDataType; - - return RANGE.typeMap.castTypes[subTypeClass.getDataTypeId().toLowerCase()]; - } - - static typeMap: { subTypes: Record, castTypes: Record } = { - subTypes: { - integer: 'int4range', - decimal: 'numrange', - date: 'tstzrange', - dateonly: 'daterange', - bigint: 'int8range', - }, - castTypes: { - integer: 'int4', - decimal: 'numeric', - date: 'timestamptz', - dateonly: 'date', - bigint: 'int8', - }, + static typeMap: Record = { + integer: 'int4range', + decimal: 'numrange', + date: 'tstzrange', + dateonly: 'daterange', + bigint: 'int8range', }; } export class ARRAY> extends BaseTypes.ARRAY { - escape( - values: Array>, - options: StringifyOptions, - ) { + escape(values: Array>) { const type = this.options.type; - return `ARRAY[${values.map((value: any) => { - return type.escape(value, options); - }).join(',')}]::${type.toSql(options)}[]`; + const mappedValues = isString(type) ? values : values.map(value => type.escape(value)); + + return `ARRAY[${mappedValues.join(',')}]::${attributeTypeToSql(type)}[]`; } getBindParamSql( values: Array>, options: BindParamOptions, ) { + if (isString(this.options.type)) { + return options.bindParam(values); + } + + const subType: AbstractDataType = this.options.type; + return options.bindParam(values.map((value: any) => { - return this.options.type.toBindableValue(value, options); + return subType.toBindableValue(value); })); } } diff --git a/packages/core/src/dialects/snowflake/data-types.ts b/packages/core/src/dialects/snowflake/data-types.ts index 48d7f1b46043..97bf5db8fc35 100644 --- a/packages/core/src/dialects/snowflake/data-types.ts +++ b/packages/core/src/dialects/snowflake/data-types.ts @@ -1,6 +1,6 @@ import maxBy from 'lodash/maxBy.js'; import * as BaseTypes from '../abstract/data-types.js'; -import type { AcceptedDate, StringifyOptions } from '../abstract/data-types.js'; +import type { AcceptedDate } from '../abstract/data-types.js'; import type { AbstractDialect } from '../abstract/index.js'; export class DATE extends BaseTypes.DATE { @@ -8,8 +8,8 @@ export class DATE extends BaseTypes.DATE { return `TIMESTAMP${this.options.precision != null ? `(${this.options.precision})` : ''}`; } - toBindableValue(date: AcceptedDate, options: StringifyOptions) { - date = this._applyTimezone(date, options); + toBindableValue(date: AcceptedDate) { + date = this._applyTimezone(date); return date.format('YYYY-MM-DD HH:mm:ss.SSS'); } @@ -38,8 +38,8 @@ export class TEXT extends BaseTypes.TEXT { } export class JSON extends BaseTypes.JSON { - escape(value: unknown, options: StringifyOptions) { - return options.operation === 'where' && typeof value === 'string' ? value : globalThis.JSON.stringify(value); + escape(value: unknown) { + return globalThis.JSON.stringify(value); } } diff --git a/packages/core/src/instance-validator.js b/packages/core/src/instance-validator.js index bf09385b0d94..77b46acfc8ec 100644 --- a/packages/core/src/instance-validator.js +++ b/packages/core/src/instance-validator.js @@ -387,7 +387,7 @@ export class InstanceValidator { const type = attribute.type; if (value != null && !(value instanceof BaseSqlExpression) && type instanceof AbstractDataType) { - const error = validateDataType(type, attributeName, this.modelInstance, value); + const error = validateDataType(value, type, attributeName, this.modelInstance); if (error) { this.errors.push(error); } diff --git a/packages/core/src/model-definition.ts b/packages/core/src/model-definition.ts index 40457e6d47ff..71a89daa702a 100644 --- a/packages/core/src/model-definition.ts +++ b/packages/core/src/model-definition.ts @@ -505,7 +505,7 @@ Timestamp attributes are managed automatically by Sequelize, and their nullabili if (builtAttribute.type instanceof AbstractDataType) { // @ts-expect-error -- defaultValue is not readOnly yet! builtAttribute.type - = builtAttribute.type.clone().attachUsageContext({ + = builtAttribute.type.withUsageContext({ // TODO: Repository Pattern - replace with ModelDefinition model: this.#model, attributeName, @@ -520,7 +520,7 @@ Timestamp attributes are managed automatically by Sequelize, and their nullabili = new builtAttribute.defaultValue(); } - this.#defaultValues.set(attributeName, () => toDefaultValue(builtAttribute.defaultValue, this.sequelize.dialect)); + this.#defaultValues.set(attributeName, () => toDefaultValue(builtAttribute.defaultValue)); } // TODO: remove "notNull" & "isNull" validators diff --git a/packages/core/src/model.d.ts b/packages/core/src/model.d.ts index 76fa6b3df95b..76bd455fe0ba 100644 --- a/packages/core/src/model.d.ts +++ b/packages/core/src/model.d.ts @@ -11,7 +11,7 @@ import type { HasOneOptions, } from './associations/index'; import type { Deferrable } from './deferrable'; -import type { AbstractDataType, DataType } from './dialects/abstract/data-types.js'; +import type { DataType, NormalizedDataType } from './dialects/abstract/data-types.js'; import type { IndexOptions, TableName, @@ -1876,7 +1876,7 @@ export interface NormalizedAttributeOptions extends Rea /** * Like {@link AttributeOptions.type}, but normalized. */ - readonly type: string | AbstractDataType; + readonly type: NormalizedDataType; readonly references?: NormalizedAttributeReferencesOptions; } @@ -2436,7 +2436,7 @@ export abstract class Model; static findByPk( this: ModelStatic, - identifier?: unknown, + identifier: unknown, options?: FindByPkOptions ): Promise; diff --git a/packages/core/src/model.js b/packages/core/src/model.js index 26c97e663871..3e809ad84a54 100644 --- a/packages/core/src/model.js +++ b/packages/core/src/model.js @@ -165,23 +165,23 @@ export class Model extends ModelTypeScript { const { createdAt: createdAtAttrName, updatedAt: updatedAtAttrName, deletedAt: deletedAtAttrName } = modelDefinition.timestampAttributeNames; if (createdAtAttrName && defaults[createdAtAttrName]) { - this.dataValues[createdAtAttrName] = toDefaultValue(defaults[createdAtAttrName], this.sequelize.dialect); + this.dataValues[createdAtAttrName] = toDefaultValue(defaults[createdAtAttrName]); delete defaults[createdAtAttrName]; } if (updatedAtAttrName && defaults[updatedAtAttrName]) { - this.dataValues[updatedAtAttrName] = toDefaultValue(defaults[updatedAtAttrName], this.sequelize.dialect); + this.dataValues[updatedAtAttrName] = toDefaultValue(defaults[updatedAtAttrName]); delete defaults[updatedAtAttrName]; } if (deletedAtAttrName && defaults[deletedAtAttrName]) { - this.dataValues[deletedAtAttrName] = toDefaultValue(defaults[deletedAtAttrName], this.sequelize.dialect); + this.dataValues[deletedAtAttrName] = toDefaultValue(defaults[deletedAtAttrName]); delete defaults[deletedAtAttrName]; } for (const key in defaults) { if (values[key] === undefined) { - this.set(key, toDefaultValue(defaults[key], this.sequelize.dialect), { raw: true }); + this.set(key, toDefaultValue(defaults[key]), { raw: true }); delete values[key]; } } @@ -2808,7 +2808,7 @@ ${associationOwner._getAssociationDebugList()}`); const attribute = attributes.get(attributeName); if (attribute?.defaultValue) { - return toDefaultValue(attribute.defaultValue, this.sequelize.dialect); + return toDefaultValue(attribute.defaultValue); } } @@ -3888,7 +3888,7 @@ Instead of specifying a Model, either: throw new Error('You attempted to update an instance that is not persisted.'); } - options = options || {}; + options = options || EMPTY_OBJECT; if (Array.isArray(options)) { options = { fields: options }; } diff --git a/packages/core/src/sql-string.ts b/packages/core/src/sql-string.ts index e95da98c4ff5..01fed8bf545a 100644 --- a/packages/core/src/sql-string.ts +++ b/packages/core/src/sql-string.ts @@ -3,7 +3,7 @@ import type { AbstractDataType } from './dialects/abstract/data-types.js'; import type { AbstractDialect } from './dialects/abstract/index.js'; import { logger } from './utils/logger'; -function arrayToList(array: unknown[], timeZone: string | undefined, dialect: AbstractDialect, format: boolean) { +function arrayToList(array: unknown[], dialect: AbstractDialect, format: boolean) { // TODO: rewrite // eslint-disable-next-line unicorn/no-array-reduce return array.reduce((sql: string, val, i) => { @@ -12,9 +12,9 @@ function arrayToList(array: unknown[], timeZone: string | undefined, dialect: Ab } if (Array.isArray(val)) { - sql += `(${arrayToList(val, timeZone, dialect, format)})`; + sql += `(${arrayToList(val, dialect, format)})`; } else { - sql += escape(val, timeZone, dialect, format); + sql += escape(val, dialect, format); } return sql; @@ -84,7 +84,6 @@ function bestGuessDataTypeOfVal(val: unknown, dialect: AbstractDialect): Abstrac export function escape( val: unknown, - timeZone: string | undefined, dialect: AbstractDialect, format: boolean = false, ): string { @@ -101,13 +100,10 @@ export function escape( } if (Array.isArray(val) && (dialectName !== 'postgres' || format)) { - return arrayToList(val, timeZone, dialect, format); + return arrayToList(val, dialect, format); } const dataType = bestGuessDataTypeOfVal(val, dialect); - return dataType.escape(val, { - dialect, - timezone: timeZone, - }); + return dataType.escape(val); } diff --git a/packages/core/src/utils/dialect.ts b/packages/core/src/utils/dialect.ts index cf12e3f69ac7..e96d46cf07b5 100644 --- a/packages/core/src/utils/dialect.ts +++ b/packages/core/src/utils/dialect.ts @@ -2,15 +2,14 @@ import { randomUUID } from 'node:crypto'; import NodeUtil from 'node:util'; import isPlainObject from 'lodash/isPlainObject'; import { v1 as uuidv1 } from 'uuid'; -import type { AbstractDialect } from '../dialects/abstract'; import * as DataTypes from '../dialects/abstract/data-types.js'; import { isString } from './check.js'; -export function toDefaultValue(value: unknown, dialect: AbstractDialect): unknown { +export function toDefaultValue(value: unknown): unknown { if (typeof value === 'function') { const tmp = value(); if (tmp instanceof DataTypes.AbstractDataType) { - return tmp.toSql({ dialect }); + return tmp.toSql(); } return tmp; diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index e2f13d361c58..64f55686c774 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -203,7 +203,7 @@ function mapBindParametersAndReplacements( throw new Error(`Named replacement ":${replacementName}" has no entry in the replacement map.`); } - const escapedReplacement = escapeSqlValue(replacementValue, undefined, dialect, true); + const escapedReplacement = escapeSqlValue(replacementValue, dialect, true); // add everything before the bind parameter name output += sqlString.slice(previousSliceEnd, i); @@ -244,7 +244,7 @@ function mapBindParametersAndReplacements( throw new Error(`Positional replacement (?) ${replacementIndex} has no entry in the replacement map (replacements[${replacementIndex}] is undefined).`); } - const escapedReplacement = escapeSqlValue(replacementValue, undefined, dialect, true); + const escapedReplacement = escapeSqlValue(replacementValue, dialect, true); // add everything before the bind parameter name output += sqlString.slice(previousSliceEnd, i); diff --git a/packages/core/test/integration/model.test.js b/packages/core/test/integration/model.test.js index f248cdf16681..7d965ef0370e 100644 --- a/packages/core/test/integration/model.test.js +++ b/packages/core/test/integration/model.test.js @@ -451,7 +451,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { await this.sequelize.sync(); await this.sequelize.sync(); // The second call should not try to create the indices again - const args = await this.sequelize.queryInterface.showIndex(Model.tableName); + const args = await this.sequelize.queryInterface.showIndex(Model.table); let primary; let idx1; let idx2; diff --git a/packages/core/test/integration/model/attributes/types.test.js b/packages/core/test/integration/model/attributes/types.test.js index 736fb08fbf01..c7bebd833760 100644 --- a/packages/core/test/integration/model/attributes/types.test.js +++ b/packages/core/test/integration/model/attributes/types.test.js @@ -63,7 +63,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { }); it('should be ignored in table creation', async function () { - const fields = await this.sequelize.getQueryInterface().describeTable(this.User.tableName); + const fields = await this.sequelize.getQueryInterface().describeTable(this.User.table); expect(Object.keys(fields).length).to.equal(2); }); diff --git a/packages/core/test/integration/model/create.test.js b/packages/core/test/integration/model/create.test.js index 48b7cf46c751..e456042902a7 100644 --- a/packages/core/test/integration/model/create.test.js +++ b/packages/core/test/integration/model/create.test.js @@ -541,7 +541,6 @@ describe(Support.getTestDialectTeaser('Model'), () => { }, })).to.be.rejectedWith(Sequelize.UniqueConstraintError); - expect(error instanceof Sequelize.UniqueConstraintError).to.be.ok; expect(error.fields).to.be.ok; })(), (async () => { @@ -554,7 +553,6 @@ describe(Support.getTestDialectTeaser('Model'), () => { }, })).to.be.rejectedWith(Sequelize.UniqueConstraintError); - expect(error instanceof Sequelize.UniqueConstraintError).to.be.ok; expect(error.fields).to.be.ok; })(), ]); diff --git a/packages/core/test/integration/model/searchPath.test.js b/packages/core/test/integration/model/searchPath.test.js index 7602ffc7438b..03bb3d1f3be0 100644 --- a/packages/core/test/integration/model/searchPath.test.js +++ b/packages/core/test/integration/model/searchPath.test.js @@ -53,14 +53,10 @@ describe(Support.getTestDialectTeaser('Model'), () => { beforeEach('build restaurant tables', async function () { const Restaurant = this.Restaurant; - try { - await current.createSchema('schema_one'); - await current.createSchema('schema_two'); - await Restaurant.sync({ force: true, searchPath: SEARCH_PATH_ONE }); - await Restaurant.sync({ force: true, searchPath: SEARCH_PATH_TWO }); - } catch (error) { - expect(error).to.be.null; - } + await current.createSchema('schema_one'); + await current.createSchema('schema_two'); + await Restaurant.sync({ force: true, searchPath: SEARCH_PATH_ONE }); + await Restaurant.sync({ force: true, searchPath: SEARCH_PATH_TWO }); }); afterEach('drop schemas', async () => { @@ -241,16 +237,12 @@ describe(Support.getTestDialectTeaser('Model'), () => { beforeEach(async function () { const Location = this.Location; - try { - await Location.sync({ force: true }); - await Location.create({ name: 'HQ' }); - const obj = await Location.findOne({ where: { name: 'HQ' } }); - expect(obj).to.not.be.null; - expect(obj.name).to.equal('HQ'); - locationId = obj.id; - } catch (error) { - expect(error).to.be.null; - } + await Location.sync({ force: true }); + await Location.create({ name: 'HQ' }); + const obj = await Location.findOne({ where: { name: 'HQ' } }); + expect(obj).to.not.be.null; + expect(obj.name).to.equal('HQ'); + locationId = obj.id; }); it('should be able to insert and retrieve associated data into the table in schema_one', async function () { @@ -300,12 +292,8 @@ describe(Support.getTestDialectTeaser('Model'), () => { beforeEach(async function () { const Employee = this.Employee; - try { - await Employee.sync({ force: true, searchPath: SEARCH_PATH_ONE }); - await Employee.sync({ force: true, searchPath: SEARCH_PATH_TWO }); - } catch (error) { - expect(error).to.be.null; - } + await Employee.sync({ force: true, searchPath: SEARCH_PATH_ONE }); + await Employee.sync({ force: true, searchPath: SEARCH_PATH_TWO }); }); it('should be able to insert and retrieve associated data into the table in schema_one', async function () { diff --git a/packages/core/test/support.ts b/packages/core/test/support.ts index ebab8c7f0c30..296e8f86212e 100644 --- a/packages/core/test/support.ts +++ b/packages/core/test/support.ts @@ -47,7 +47,11 @@ function withInlineCause(cb: (() => any)): () => void { }; } -function inlineErrorCause(error: Error) { +function inlineErrorCause(error: unknown): string { + if (!(error instanceof Error)) { + return String(error); + } + let message = error.message; // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error @@ -332,9 +336,9 @@ export function expectPerDialect( if (expectation instanceof Error) { assert(result instanceof Error, `Expected method to error with "${expectation.message}", but it returned ${inspect(result)}.`); - expect(result.message).to.equal(expectation.message); + expect(inlineErrorCause(result)).to.include(expectation.message); } else { - assert(!(result instanceof Error), `Did not expect query to error, but it errored with ${result instanceof Error ? result.message : ''}`); + assert(!(result instanceof Error), `Did not expect query to error, but it errored with ${inlineErrorCause(result)}`); assertMatchesExpectation(result, expectation); } @@ -483,9 +487,9 @@ export function expectsql( if (expectation instanceof Error) { assert(query instanceof Error, `Expected query to error with "${expectation.message}", but it is equal to ${JSON.stringify(query)}.`); - expect(query.message).to.equal(expectation.message); + expect(inlineErrorCause(query)).to.include(expectation.message); } else { - assert(!(query instanceof Error), `Expected query to equal ${minifySql(expectation)}, but it errored with ${query instanceof Error ? query.message : ''}`); + assert(!(query instanceof Error), `Expected query to equal:\n${minifySql(expectation)}\n\nBut it errored with:\n${inlineErrorCause(query)}`); expect(minifySql(isObject(query) ? query.query : query)).to.equal(minifySql(expectation)); } diff --git a/packages/core/test/types/models/user.ts b/packages/core/test/types/models/user.ts index f2cb38c81535..4bd01ec02093 100644 --- a/packages/core/test/types/models/user.ts +++ b/packages/core/test/types/models/user.ts @@ -85,7 +85,7 @@ User.init( ); User.afterSync(() => { - sequelize.getQueryInterface().addIndex(User.tableName, { + sequelize.getQueryInterface().addIndex(User.table, { fields: ['lastName'], using: 'BTREE', name: 'lastNameIdx', diff --git a/packages/core/test/unit/data-types/_utils.ts b/packages/core/test/unit/data-types/_utils.ts index 26ba6a2e4ad7..419d55a4c63c 100644 --- a/packages/core/test/unit/data-types/_utils.ts +++ b/packages/core/test/unit/data-types/_utils.ts @@ -7,7 +7,7 @@ export const testDataTypeSql = createTester((it, description: string, dataType: let result: Error | string; try { - result = typeof dataType === 'string' ? dataType : sequelize.normalizeDataType(dataType).toSql({ dialect: sequelize.dialect }); + result = typeof dataType === 'string' ? dataType : sequelize.normalizeDataType(dataType).toSql(); } catch (error) { assert(error instanceof Error); result = error; diff --git a/packages/core/test/unit/data-types/misc-data-types.test.ts b/packages/core/test/unit/data-types/misc-data-types.test.ts index e5665c537652..a2f42d3086ae 100644 --- a/packages/core/test/unit/data-types/misc-data-types.test.ts +++ b/packages/core/test/unit/data-types/misc-data-types.test.ts @@ -6,7 +6,6 @@ import { expectsql, sequelize, getTestDialect } from '../../support'; import { testDataTypeSql } from './_utils'; const dialectName = getTestDialect(); -const dialect = sequelize.dialect; describe('DataTypes.BOOLEAN', () => { testDataTypeSql('BOOLEAN', DataTypes.BOOLEAN, { @@ -49,7 +48,7 @@ describe('DataTypes.ENUM', () => { const enumType = User.getAttributes().anEnum.type; assert(typeof enumType !== 'string'); - expectsql(enumType.toSql({ dialect }), { + expectsql(enumType.toSql(), { postgres: '"public"."enum_Users_anEnum"', 'mysql mariadb': `ENUM('value 1', 'value 2')`, // SQL Server does not support enums, we use text + a check constraint instead diff --git a/packages/core/test/unit/data-types/temporal-types.test.ts b/packages/core/test/unit/data-types/temporal-types.test.ts index 2ed97eb15e25..d0eb4a126cb1 100644 --- a/packages/core/test/unit/data-types/temporal-types.test.ts +++ b/packages/core/test/unit/data-types/temporal-types.test.ts @@ -80,8 +80,8 @@ describe('DataTypes.DATE', () => { describe('toBindableValue', () => { if (dialect.supports.dataTypes.DATETIME.infinity) { it('stringifies numeric Infinity/-Infinity', () => { - expect(type.toBindableValue(Number.POSITIVE_INFINITY, { dialect })).to.equal('infinity'); - expect(type.toBindableValue(Number.NEGATIVE_INFINITY, { dialect })).to.equal('-infinity'); + expect(type.toBindableValue(Number.POSITIVE_INFINITY)).to.equal('infinity'); + expect(type.toBindableValue(Number.NEGATIVE_INFINITY)).to.equal('-infinity'); }); } }); @@ -99,8 +99,8 @@ describe('DataTypes.DATEONLY', () => { describe('validate', () => { if (dialect.supports.dataTypes.DATEONLY.infinity) { it('DATEONLY should stringify Infinity/-Infinity to infinity/-infinity', () => { - expect(type.toBindableValue(Number.POSITIVE_INFINITY, { dialect })).to.equal('infinity'); - expect(type.toBindableValue(Number.NEGATIVE_INFINITY, { dialect })).to.equal('-infinity'); + expect(type.toBindableValue(Number.POSITIVE_INFINITY)).to.equal('infinity'); + expect(type.toBindableValue(Number.NEGATIVE_INFINITY)).to.equal('-infinity'); }); } }); diff --git a/packages/core/test/unit/dialects/postgres/range-data-type.test.ts b/packages/core/test/unit/dialects/postgres/range-data-type.test.ts index 9f6943053802..ec786fc12d7c 100644 --- a/packages/core/test/unit/dialects/postgres/range-data-type.test.ts +++ b/packages/core/test/unit/dialects/postgres/range-data-type.test.ts @@ -1,20 +1,17 @@ import { expect } from 'chai'; import type { Rangable } from '@sequelize/core'; import { DataTypes } from '@sequelize/core'; -import type { StringifyOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/data-types.js'; -import { sequelize } from '../../../support'; - -const dialect = sequelize.dialect; -const stringifyOptions: StringifyOptions = { - dialect, - timezone: '+02:00', -}; +import { createSequelizeInstance, getTestDialect } from '../../../support'; describe('[POSTGRES Specific] RANGE DataType', () => { - if (!dialect.name.startsWith('postgres')) { + if (getTestDialect() !== 'postgres') { return; } + const { dialect } = createSequelizeInstance({ + timezone: '+02:00', + }); + const integerRangeType = DataTypes.RANGE(DataTypes.INTEGER).toDialectDataType(dialect); const bigintRangeType = DataTypes.RANGE(DataTypes.BIGINT).toDialectDataType(dialect); const decimalRangeType = DataTypes.RANGE(DataTypes.DECIMAL).toDialectDataType(dialect); @@ -23,21 +20,21 @@ describe('[POSTGRES Specific] RANGE DataType', () => { describe('escape', () => { it('should handle empty objects correctly', () => { - expect(integerRangeType.escape([], stringifyOptions)).to.equal(`'empty'`); + expect(integerRangeType.escape([])).to.equal(`'empty'`); }); it('should handle null as empty bound', () => { - expect(integerRangeType.escape([null, 1], stringifyOptions)).to.equal(`'[,1)'`); - expect(integerRangeType.escape([1, null], stringifyOptions)).to.equal(`'[1,)'`); - expect(integerRangeType.escape([null, null], stringifyOptions)).to.equal(`'[,)'`); + expect(integerRangeType.escape([null, 1])).to.equal(`'[,1)'`); + expect(integerRangeType.escape([1, null])).to.equal(`'[1,)'`); + expect(integerRangeType.escape([null, null])).to.equal(`'[,)'`); }); it('should handle Infinity/-Infinity as infinity/-infinity bounds', () => { - expect(integerRangeType.escape([Number.POSITIVE_INFINITY, 1], stringifyOptions)).to.equal(`'[infinity,1)'`); - expect(integerRangeType.escape([1, Number.POSITIVE_INFINITY], stringifyOptions)).to.equal(`'[1,infinity)'`); - expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, 1], stringifyOptions)).to.equal(`'[-infinity,1)'`); - expect(integerRangeType.escape([1, Number.NEGATIVE_INFINITY], stringifyOptions)).to.equal(`'[1,-infinity)'`); - expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY], stringifyOptions)).to.equal(`'[-infinity,infinity)'`); + expect(integerRangeType.escape([Number.POSITIVE_INFINITY, 1])).to.equal(`'[infinity,1)'`); + expect(integerRangeType.escape([1, Number.POSITIVE_INFINITY])).to.equal(`'[1,infinity)'`); + expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, 1])).to.equal(`'[-infinity,1)'`); + expect(integerRangeType.escape([1, Number.NEGATIVE_INFINITY])).to.equal(`'[1,-infinity)'`); + expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY])).to.equal(`'[-infinity,infinity)'`); }); it('should throw error when array length is not 0 or 2', () => { @@ -57,7 +54,7 @@ describe('[POSTGRES Specific] RANGE DataType', () => { integerRangeType.escape({}, stringifyOptions); }).to.throw(); expect(() => { - integerRangeType.escape('test', stringifyOptions); + integerRangeType.escape('test'); }).to.throw(); expect(() => { // @ts-expect-error -- testing that invalid input throws @@ -66,10 +63,10 @@ describe('[POSTGRES Specific] RANGE DataType', () => { }); it('should handle array of objects with `inclusive` and `value` properties', () => { - expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { value: 1 }], stringifyOptions)).to.equal(`'[0,1)'`); - expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { inclusive: true, value: 1 }], stringifyOptions)).to.equal(`'[0,1]'`); - expect(integerRangeType.escape([{ inclusive: false, value: 0 }, 1], stringifyOptions)).to.equal(`'(0,1)'`); - expect(integerRangeType.escape([0, { inclusive: true, value: 1 }], stringifyOptions)).to.equal(`'[0,1]'`); + expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { value: 1 }])).to.equal(`'[0,1)'`); + expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'`); + expect(integerRangeType.escape([{ inclusive: false, value: 0 }, 1])).to.equal(`'(0,1)'`); + expect(integerRangeType.escape([0, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'`); }); it('should handle date values', () => { @@ -77,56 +74,33 @@ describe('[POSTGRES Specific] RANGE DataType', () => { expect(dateRangeType.escape([ new Date(Date.UTC(2000, 1, 1)), new Date(Date.UTC(2000, 1, 2)), - ], stringifyOptions)).to.equal(`'[2000-02-01 02:00:00.000 +02:00,2000-02-02 02:00:00.000 +02:00)'`); + ])).to.equal(`'[2000-02-01 02:00:00.000 +02:00,2000-02-02 02:00:00.000 +02:00)'`); }); }); describe('stringify value', () => { - it('should stringify integer values with appropriate casting', () => { - expect(integerRangeType.escape(1, stringifyOptions)).to.equal(`'1'::int4`); - }); - - it('should stringify bigint values with appropriate casting', () => { - expect(bigintRangeType.escape(1, stringifyOptions)).to.equal(`'1'::int8`); - expect(bigintRangeType.escape(1n, stringifyOptions)).to.equal(`'1'::int8`); - expect(bigintRangeType.escape('1', stringifyOptions)).to.equal(`'1'::int8`); - }); - - it('should stringify numeric values with appropriate casting', () => { - expect(decimalRangeType.escape(1.1, stringifyOptions)).to.equal(`'1.1'::numeric`); - expect(decimalRangeType.escape('1.1', stringifyOptions)).to.equal(`'1.1'::numeric`); - }); - - it('should stringify dateonly values with appropriate casting', () => { - expect(dateOnlyRangeType.escape(new Date(Date.UTC(2000, 1, 1)), stringifyOptions)).to.include('::date'); - }); - - it('should stringify date values with appropriate casting', () => { - expect(dateRangeType.escape(new Date(Date.UTC(2000, 1, 1)), stringifyOptions)).to.equal(`'2000-02-01 02:00:00.000 +02:00'::timestamptz`); - }); - describe('with null range bounds', () => { const infiniteRange: Rangable = [null, null]; const infiniteRangeSQL = `'[,)'`; it('should stringify integer range to infinite range', () => { - expect(integerRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(integerRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify bigint range to infinite range', () => { - expect(bigintRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(bigintRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify numeric range to infinite range', () => { - expect(decimalRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(decimalRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify dateonly ranges to infinite range', () => { - expect(dateOnlyRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify date ranges to infinite range', () => { - expect(dateRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(dateRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); }); @@ -135,23 +109,23 @@ describe('[POSTGRES Specific] RANGE DataType', () => { const infiniteRangeSQL = '\'[-infinity,infinity)\''; it('should stringify integer range to infinite range', () => { - expect(integerRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(integerRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify bigint range to infinite range', () => { - expect(bigintRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(bigintRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify numeric range to infinite range', () => { - expect(decimalRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(decimalRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify dateonly ranges to infinite range', () => { - expect(dateOnlyRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); it('should stringify date ranges to infinite range', () => { - expect(dateRangeType.escape(infiniteRange, stringifyOptions)).to.equal(infiniteRangeSQL); + expect(dateRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); }); }); }); diff --git a/packages/core/test/unit/query-generator/add-column-query.test.ts b/packages/core/test/unit/query-generator/add-column-query.test.ts index c24599a8b79f..05c5f2636880 100644 --- a/packages/core/test/unit/query-generator/add-column-query.test.ts +++ b/packages/core/test/unit/query-generator/add-column-query.test.ts @@ -12,7 +12,7 @@ describe('QueryGenerator#addColumnQuery', () => { }, { timestamps: false }); it('generates a ADD COLUMN query in supported dialects', () => { - expectsql(() => queryGenerator.addColumnQuery(User.tableName, 'age', { + expectsql(() => queryGenerator.addColumnQuery(User.table, 'age', { type: DataTypes.INTEGER, }), { default: `ALTER TABLE [Users] ADD [age] INTEGER;`, @@ -22,7 +22,7 @@ describe('QueryGenerator#addColumnQuery', () => { }); it('generates a ADD COLUMN IF NOT EXISTS query in supported dialects', () => { - expectsql(() => queryGenerator.addColumnQuery(User.tableName, 'age', { + expectsql(() => queryGenerator.addColumnQuery(User.table, 'age', { type: DataTypes.INTEGER, }, { ifNotExists: true }), { default: buildInvalidOptionReceivedError('addColumnQuery', dialectName, ['ifNotExists']), diff --git a/packages/core/test/unit/query-generator/arithmetic-query.test.ts b/packages/core/test/unit/query-generator/arithmetic-query.test.ts index f72c0bb98889..b41716a44b19 100644 --- a/packages/core/test/unit/query-generator/arithmetic-query.test.ts +++ b/packages/core/test/unit/query-generator/arithmetic-query.test.ts @@ -80,7 +80,7 @@ describe('QueryGenerator#arithmeticQuery', () => { it('parses named replacements in literals', async () => { const sql = queryGenerator.arithmeticQuery( '+', - User.tableName, + User.table, // where literal('id = :id'), // increment by field diff --git a/packages/core/test/unit/query-generator/bulk-insert-query.test.ts b/packages/core/test/unit/query-generator/bulk-insert-query.test.ts index 91f61beddc79..964f3d1fe07e 100644 --- a/packages/core/test/unit/query-generator/bulk-insert-query.test.ts +++ b/packages/core/test/unit/query-generator/bulk-insert-query.test.ts @@ -9,7 +9,7 @@ describe('QueryGenerator#bulkInsertQuery', () => { }, { timestamps: false }); it('parses named replacements in literals', async () => { - const sql = queryGenerator.bulkInsertQuery(User.tableName, [{ + const sql = queryGenerator.bulkInsertQuery(User.table, [{ firstName: literal(':injection'), }], { replacements: { diff --git a/packages/core/test/unit/query-generator/delete-query.test.ts b/packages/core/test/unit/query-generator/delete-query.test.ts index ab3dcc4cb6c8..0179d586ddef 100644 --- a/packages/core/test/unit/query-generator/delete-query.test.ts +++ b/packages/core/test/unit/query-generator/delete-query.test.ts @@ -11,7 +11,7 @@ describe('QueryGenerator#deleteQuery', () => { // you'll find more replacement tests in query-generator tests it('parses named replacements in literals', async () => { const query = queryGenerator.deleteQuery( - User.tableName, + User.table, literal('name = :name'), { limit: literal(':limit'), diff --git a/packages/core/test/unit/query-generator/insert-query.test.ts b/packages/core/test/unit/query-generator/insert-query.test.ts index 117067344567..24b580e52f3a 100644 --- a/packages/core/test/unit/query-generator/insert-query.test.ts +++ b/packages/core/test/unit/query-generator/insert-query.test.ts @@ -11,7 +11,7 @@ describe('QueryGenerator#insertQuery', () => { // you'll find more replacement tests in query-generator tests it('parses named replacements in literals', () => { - const { query, bind } = queryGenerator.insertQuery(User.tableName, { + const { query, bind } = queryGenerator.insertQuery(User.table, { firstName: literal(':name'), }, {}, { replacements: { @@ -29,7 +29,7 @@ describe('QueryGenerator#insertQuery', () => { }); it('supports named bind parameters in literals', () => { - const { query, bind } = queryGenerator.insertQuery(User.tableName, { + const { query, bind } = queryGenerator.insertQuery(User.table, { firstName: 'John', lastName: literal('$lastName'), username: 'jd', @@ -48,7 +48,7 @@ describe('QueryGenerator#insertQuery', () => { }); it('parses positional bind parameters in literals', () => { - const { query, bind } = queryGenerator.insertQuery(User.tableName, { + const { query, bind } = queryGenerator.insertQuery(User.table, { firstName: 'John', lastName: literal('$1'), username: 'jd', @@ -67,7 +67,7 @@ describe('QueryGenerator#insertQuery', () => { }); it('parses bind parameters in literals even with bindParams: false', () => { - const { query, bind } = queryGenerator.insertQuery(User.tableName, { + const { query, bind } = queryGenerator.insertQuery(User.table, { firstName: 'John', lastName: literal('$1'), username: 'jd', @@ -86,7 +86,7 @@ describe('QueryGenerator#insertQuery', () => { describe('returning', () => { it('supports returning: true', () => { - const { query } = queryGenerator.insertQuery(User.tableName, { + const { query } = queryGenerator.insertQuery(User.table, { firstName: 'John', }, User.getAttributes(), { returning: true, @@ -105,7 +105,7 @@ describe('QueryGenerator#insertQuery', () => { }); it('supports array of strings (column names)', () => { - const { query } = queryGenerator.insertQuery(User.tableName, { + const { query } = queryGenerator.insertQuery(User.table, { firstName: 'John', }, User.getAttributes(), { returning: ['*', 'myColumn'], @@ -127,7 +127,7 @@ describe('QueryGenerator#insertQuery', () => { it('supports array of literals', () => { expectsql(() => { - return queryGenerator.insertQuery(User.tableName, { + return queryGenerator.insertQuery(User.table, { firstName: 'John', }, User.getAttributes(), { returning: [literal('*')], diff --git a/packages/core/test/unit/query-generator/remove-column-query.test.ts b/packages/core/test/unit/query-generator/remove-column-query.test.ts index 3cece26c3820..1a1e6d50fd5a 100644 --- a/packages/core/test/unit/query-generator/remove-column-query.test.ts +++ b/packages/core/test/unit/query-generator/remove-column-query.test.ts @@ -13,7 +13,7 @@ describe('QueryGenerator#removeColumnQuery', () => { }, { timestamps: false }); it('generates a DROP COLUMN query in supported dialects', () => { - expectsql(() => queryGenerator.removeColumnQuery(User.tableName, 'age'), { + expectsql(() => queryGenerator.removeColumnQuery(User.table, 'age'), { default: `ALTER TABLE [Users] DROP COLUMN [age];`, postgres: `ALTER TABLE "Users" DROP COLUMN "age";`, snowflake: `ALTER TABLE "Users" DROP "age";`, @@ -23,7 +23,7 @@ describe('QueryGenerator#removeColumnQuery', () => { }); it('generates a DROP COLUMN IF EXISTS query in supported dialects', () => { - expectsql(() => queryGenerator.removeColumnQuery(User.tableName, 'age', { ifExists: true }), { + expectsql(() => queryGenerator.removeColumnQuery(User.table, 'age', { ifExists: true }), { default: buildInvalidOptionReceivedError('removeColumnQuery', dialectName, ['ifExists']), mariadb: 'ALTER TABLE `Users` DROP IF EXISTS `age`;', mssql: 'ALTER TABLE [Users] DROP COLUMN IF EXISTS [age];', diff --git a/packages/core/test/unit/query-generator/select-query.test.ts b/packages/core/test/unit/query-generator/select-query.test.ts index 2bcbf8bf21cf..48e391739c43 100644 --- a/packages/core/test/unit/query-generator/select-query.test.ts +++ b/packages/core/test/unit/query-generator/select-query.test.ts @@ -36,7 +36,7 @@ describe('QueryGenerator#selectQuery', () => { }); it('supports offset without limit', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: ['id'], offset: 1, @@ -55,7 +55,7 @@ describe('QueryGenerator#selectQuery', () => { }); it('supports querying for bigint values', () => { - const sql = queryGenerator.selectQuery(Project.tableName, { + const sql = queryGenerator.selectQuery(Project.table, { model: Project, attributes: ['id'], where: { @@ -76,7 +76,7 @@ describe('QueryGenerator#selectQuery', () => { }); it('supports cast in attributes', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: [ 'id', @@ -93,7 +93,7 @@ describe('QueryGenerator#selectQuery', () => { it('parses named replacements in literals', () => { // The goal of this test is to test that :replacements are parsed in literals in as many places as possible - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: [[fn('uppercase', literal(':attr')), 'id'], literal(':attr2')], where: { @@ -170,7 +170,7 @@ describe('QueryGenerator#selectQuery', () => { it('does not parse replacements in strings in literals', () => { // The goal of this test is to test that :replacements are parsed in literals in as many places as possible - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: [literal('id')], where: literal(`id = ':id'`), @@ -185,7 +185,7 @@ describe('QueryGenerator#selectQuery', () => { }); it('parses named replacements in literals in includes', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: ['id'], include: _validateIncludedElements({ @@ -256,7 +256,7 @@ describe('QueryGenerator#selectQuery', () => { }); it(`parses named replacements in belongsToMany includes' through tables`, () => { - const sql = queryGenerator.selectQuery(Project.tableName, { + const sql = queryGenerator.selectQuery(Project.table, { model: Project, attributes: ['id'], include: _validateIncludedElements({ @@ -309,7 +309,7 @@ describe('QueryGenerator#selectQuery', () => { }); it('parses named replacements in literals in includes (subQuery)', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: ['id'], include: _validateIncludedElements({ @@ -428,7 +428,7 @@ describe('QueryGenerator#selectQuery', () => { it('rejects positional replacements, because their execution order is hard to determine', () => { expect( - () => queryGenerator.selectQuery(User.tableName, { + () => queryGenerator.selectQuery(User.table, { model: User, where: { username: { @@ -443,7 +443,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara }); it(`always escapes the attribute if it's provided as a string`, () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: [ // these used to have special escaping logic, now they're always escaped like any other strings. col, fn, and literal can be used for advanced logic. @@ -491,7 +491,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara }); it('supports a "having" option', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { model: User, attributes: [ literal('*'), @@ -509,7 +509,7 @@ Only named replacements (:name) are allowed in literal() because we cannot guara describe('minifyAliases', () => { it('minifies custom attributes', () => { - const sql = queryGenerator.selectQuery(User.tableName, { + const sql = queryGenerator.selectQuery(User.table, { minifyAliases: true, model: User, attributes: [ diff --git a/packages/core/test/unit/query-generator/update-query.test.ts b/packages/core/test/unit/query-generator/update-query.test.ts index bc5af41374d7..79c43c06b8a3 100644 --- a/packages/core/test/unit/query-generator/update-query.test.ts +++ b/packages/core/test/unit/query-generator/update-query.test.ts @@ -11,11 +11,9 @@ describe('QueryGenerator#updateQuery', () => { // you'll find more replacement tests in query-generator tests it('parses named replacements in literals', async () => { - const { query, bind } = queryGenerator.updateQuery(User.tableName, { + const { query, bind } = queryGenerator.updateQuery(User.table, { firstName: literal(':name'), - }, { - where: literal('name = :name'), - }, { + }, literal('name = :name'), { replacements: { name: 'Zoe', }, @@ -30,7 +28,7 @@ describe('QueryGenerator#updateQuery', () => { }); it('generates extra bind params', async () => { - const { query, bind } = queryGenerator.updateQuery(User.tableName, { + const { query, bind } = queryGenerator.updateQuery(User.table, { firstName: 'John', lastName: literal('$1'), username: 'jd', @@ -48,13 +46,11 @@ describe('QueryGenerator#updateQuery', () => { }); it('does not generate extra bind params with bindParams: false', async () => { - const { query, bind } = queryGenerator.updateQuery(User.tableName, { + const { query, bind } = queryGenerator.updateQuery(User.table, { firstName: 'John', lastName: literal('$1'), username: 'jd', - }, { - where: literal('first_name = $2'), - }, { + }, literal('first_name = $2'), { bindParam: false, }); diff --git a/packages/core/test/unit/query-interface/bulk-delete.test.ts b/packages/core/test/unit/query-interface/bulk-delete.test.ts index 11174d18a92d..799ad7fc9258 100644 --- a/packages/core/test/unit/query-interface/bulk-delete.test.ts +++ b/packages/core/test/unit/query-interface/bulk-delete.test.ts @@ -17,7 +17,7 @@ describe('QueryInterface#bulkDelete', () => { const stub = sinon.stub(sequelize, 'queryRaw'); await sequelize.getQueryInterface().bulkDelete( - User.tableName, + User.table, { firstName: ':id' }, { replacements: { diff --git a/packages/core/test/unit/query-interface/bulk-insert.test.ts b/packages/core/test/unit/query-interface/bulk-insert.test.ts index 6165439200af..0c4dfb4e411b 100644 --- a/packages/core/test/unit/query-interface/bulk-insert.test.ts +++ b/packages/core/test/unit/query-interface/bulk-insert.test.ts @@ -17,7 +17,7 @@ describe('QueryInterface#bulkInsert', () => { const stub = sinon.stub(sequelize, 'queryRaw').resolves([[], 0]); const users = range(1000).map(i => ({ firstName: `user${i}` })); - await sequelize.getQueryInterface().bulkInsert(User.tableName, users); + await sequelize.getQueryInterface().bulkInsert(User.table, users); expect(stub.callCount).to.eq(1); const firstCall = stub.getCall(0).args[0]; @@ -34,7 +34,7 @@ describe('QueryInterface#bulkInsert', () => { const transaction = new Transaction(sequelize, {}); const users = range(2000).map(i => ({ firstName: `user${i}` })); - await sequelize.getQueryInterface().bulkInsert(User.tableName, users, { transaction }); + await sequelize.getQueryInterface().bulkInsert(User.table, users, { transaction }); expect(stub.callCount).to.eq(1); const firstCall = stub.getCall(0).args[0]; @@ -50,7 +50,7 @@ describe('QueryInterface#bulkInsert', () => { it('does not parse replacements outside of raw sql', async () => { const stub = sinon.stub(sequelize, 'queryRaw').resolves([[], 0]); - await sequelize.getQueryInterface().bulkInsert(User.tableName, [{ + await sequelize.getQueryInterface().bulkInsert(User.table, [{ firstName: ':injection', }], { replacements: { diff --git a/packages/core/test/unit/query-interface/bulk-update.test.ts b/packages/core/test/unit/query-interface/bulk-update.test.ts index 831bea2eb8d7..8b97dee745b9 100644 --- a/packages/core/test/unit/query-interface/bulk-update.test.ts +++ b/packages/core/test/unit/query-interface/bulk-update.test.ts @@ -17,7 +17,7 @@ describe('QueryInterface#bulkUpdate', () => { const stub = sinon.stub(sequelize, 'queryRaw').resolves([[], 0]); await sequelize.getQueryInterface().bulkUpdate( - User.tableName, + User.table, { // values firstName: ':injection', @@ -51,7 +51,7 @@ describe('QueryInterface#bulkUpdate', () => { sinon.stub(sequelize, 'queryRaw'); await expect(sequelize.getQueryInterface().bulkUpdate( - User.tableName, + User.table, { firstName: literal('$sequelize_test'), }, @@ -68,7 +68,7 @@ describe('QueryInterface#bulkUpdate', () => { const stub = sinon.stub(sequelize, 'queryRaw'); await sequelize.getQueryInterface().bulkUpdate( - User.tableName, + User.table, { firstName: 'newName', }, @@ -98,7 +98,7 @@ describe('QueryInterface#bulkUpdate', () => { const stub = sinon.stub(sequelize, 'queryRaw'); await sequelize.getQueryInterface().bulkUpdate( - User.tableName, + User.table, { firstName: 'newName', }, diff --git a/packages/core/test/unit/query-interface/decrement.test.ts b/packages/core/test/unit/query-interface/decrement.test.ts index c21c0beabfca..4bfeae4af61b 100644 --- a/packages/core/test/unit/query-interface/decrement.test.ts +++ b/packages/core/test/unit/query-interface/decrement.test.ts @@ -18,9 +18,9 @@ describe('QueryInterface#decrement', () => { await sequelize.getQueryInterface().decrement( User, - User.tableName, + User.table, // where - { id: ':id' }, + { firstName: ':firstName' }, // incrementAmountsByField { age: ':age' }, // extraAttributesToBeUpdated @@ -39,9 +39,9 @@ describe('QueryInterface#decrement', () => { expect(stub.callCount).to.eq(1); const firstCall = stub.getCall(0); expectsql(firstCall.args[0] as string, { - default: `UPDATE [Users] SET [age]=[age]- ':age',[name]=':name' WHERE [id] = ':id'`, - postgres: `UPDATE "Users" SET "age"="age"- ':age',"name"=':name' WHERE "id" = ':id' RETURNING ":data"`, - mssql: `UPDATE [Users] SET [age]=[age]- N':age',[name]=N':name' OUTPUT INSERTED.[:data] WHERE [id] = N':id'`, + default: `UPDATE [Users] SET [age]=[age]- ':age',[name]=':name' WHERE [firstName] = ':firstName'`, + postgres: `UPDATE "Users" SET "age"="age"- ':age',"name"=':name' WHERE "firstName" = ':firstName' RETURNING ":data"`, + mssql: `UPDATE [Users] SET [age]=[age]- N':age',[name]=N':name' OUTPUT INSERTED.[:data] WHERE [firstName] = N':firstName'`, }); expect(firstCall.args[1]?.bind).to.be.undefined; }); diff --git a/packages/core/test/unit/query-interface/delete.test.ts b/packages/core/test/unit/query-interface/delete.test.ts index a8ab561bdce6..171bd2d4c028 100644 --- a/packages/core/test/unit/query-interface/delete.test.ts +++ b/packages/core/test/unit/query-interface/delete.test.ts @@ -20,7 +20,7 @@ describe('QueryInterface#delete', () => { await sequelize.getQueryInterface().delete( instance, - User.tableName, + User.table, { firstName: ':id' }, { replacements: { diff --git a/packages/core/test/unit/query-interface/increment.test.ts b/packages/core/test/unit/query-interface/increment.test.ts index 32ab62e06578..9500a066d222 100644 --- a/packages/core/test/unit/query-interface/increment.test.ts +++ b/packages/core/test/unit/query-interface/increment.test.ts @@ -18,9 +18,9 @@ describe('QueryInterface#increment', () => { await sequelize.getQueryInterface().increment( User, - User.tableName, + User.table, // where - { id: ':id' }, + { firstName: ':firstName' }, // incrementAmountsByField { age: ':age' }, // extraAttributesToBeUpdated @@ -39,9 +39,9 @@ describe('QueryInterface#increment', () => { expect(stub.callCount).to.eq(1); const firstCall = stub.getCall(0); expectsql(firstCall.args[0] as string, { - default: `UPDATE [Users] SET [age]=[age]+ ':age',[name]=':name' WHERE [id] = ':id'`, - postgres: `UPDATE "Users" SET "age"="age"+ ':age',"name"=':name' WHERE "id" = ':id' RETURNING ":data"`, - mssql: `UPDATE [Users] SET [age]=[age]+ N':age',[name]=N':name' OUTPUT INSERTED.[:data] WHERE [id] = N':id'`, + default: `UPDATE [Users] SET [age]=[age]+ ':age',[name]=':name' WHERE [firstName] = ':firstName'`, + postgres: `UPDATE "Users" SET "age"="age"+ ':age',"name"=':name' WHERE "firstName" = ':firstName' RETURNING ":data"`, + mssql: `UPDATE [Users] SET [age]=[age]+ N':age',[name]=N':name' OUTPUT INSERTED.[:data] WHERE [firstName] = N':firstName'`, }); expect(firstCall.args[1]?.bind).to.be.undefined; }); diff --git a/packages/core/test/unit/query-interface/insert.test.ts b/packages/core/test/unit/query-interface/insert.test.ts index d87e040eae9c..36aed691aed9 100644 --- a/packages/core/test/unit/query-interface/insert.test.ts +++ b/packages/core/test/unit/query-interface/insert.test.ts @@ -16,7 +16,7 @@ describe('QueryInterface#insert', () => { it('does not parse replacements outside of raw sql', async () => { const stub = sinon.stub(sequelize, 'queryRaw'); - await sequelize.getQueryInterface().insert(null, User.tableName, { + await sequelize.getQueryInterface().insert(null, User.table, { firstName: 'Zoe', }, { returning: [':data'], @@ -42,7 +42,7 @@ describe('QueryInterface#insert', () => { it('throws if a bind parameter name starts with the reserved "sequelize_" prefix', async () => { sinon.stub(sequelize, 'queryRaw'); - await expect(sequelize.getQueryInterface().insert(null, User.tableName, { + await expect(sequelize.getQueryInterface().insert(null, User.table, { firstName: literal('$sequelize_test'), }, { bind: { @@ -54,7 +54,7 @@ describe('QueryInterface#insert', () => { it('merges user-provided bind parameters with sequelize-generated bind parameters (object bind)', async () => { const stub = sinon.stub(sequelize, 'queryRaw'); - await sequelize.getQueryInterface().insert(null, User.tableName, { + await sequelize.getQueryInterface().insert(null, User.table, { firstName: literal('$firstName'), lastName: 'Doe', }, { @@ -80,7 +80,7 @@ describe('QueryInterface#insert', () => { it('merges user-provided bind parameters with sequelize-generated bind parameters (array bind)', async () => { const stub = sinon.stub(sequelize, 'queryRaw'); - await sequelize.getQueryInterface().insert(null, User.tableName, { + await sequelize.getQueryInterface().insert(null, User.table, { firstName: literal('$1'), lastName: 'Doe', }, { diff --git a/packages/core/test/unit/query-interface/raw-select.test.ts b/packages/core/test/unit/query-interface/raw-select.test.ts index 13b590ca3b66..c80cb4d4bb0d 100644 --- a/packages/core/test/unit/query-interface/raw-select.test.ts +++ b/packages/core/test/unit/query-interface/raw-select.test.ts @@ -16,7 +16,7 @@ describe('QueryInterface#rawSelect', () => { it('does not parse user-provided data as replacements', async () => { const stub = sinon.stub(sequelize, 'queryRaw'); - await sequelize.getQueryInterface().rawSelect(User.tableName, { + await sequelize.getQueryInterface().rawSelect(User.table, { // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript attributes: ['id'], where: { diff --git a/packages/core/test/unit/query-interface/select.test.ts b/packages/core/test/unit/query-interface/select.test.ts index 1709e371f739..f6fbec596ec7 100644 --- a/packages/core/test/unit/query-interface/select.test.ts +++ b/packages/core/test/unit/query-interface/select.test.ts @@ -16,7 +16,7 @@ describe('QueryInterface#select', () => { it('does not parse user-provided data as replacements', async () => { const stub = sinon.stub(sequelize, 'queryRaw'); - await sequelize.getQueryInterface().select(User, User.tableName, { + await sequelize.getQueryInterface().select(User, User.table, { // @ts-expect-error -- we'll fix the typings when we migrate query-generator to TypeScript attributes: ['id'], where: { diff --git a/packages/core/test/unit/query-interface/update.test.ts b/packages/core/test/unit/query-interface/update.test.ts index aff666daffd1..d481d285883c 100644 --- a/packages/core/test/unit/query-interface/update.test.ts +++ b/packages/core/test/unit/query-interface/update.test.ts @@ -20,9 +20,9 @@ describe('QueryInterface#update', () => { await sequelize.getQueryInterface().update( instance, - User.tableName, + User.table, { firstName: ':name' }, - { id: ':id' }, + { firstName: ':firstName' }, { returning: [':data'], replacements: { @@ -35,14 +35,14 @@ describe('QueryInterface#update', () => { expect(stub.callCount).to.eq(1); const firstCall = stub.getCall(0); expectsql(firstCall.args[0] as string, { - default: 'UPDATE [Users] SET [firstName]=$sequelize_1 WHERE [id] = $sequelize_2', - postgres: 'UPDATE "Users" SET "firstName"=$sequelize_1 WHERE "id" = $sequelize_2 RETURNING ":data"', - mssql: 'UPDATE [Users] SET [firstName]=$sequelize_1 OUTPUT INSERTED.[:data] WHERE [id] = $sequelize_2', - db2: `SELECT * FROM FINAL TABLE (UPDATE "Users" SET "firstName"=$sequelize_1 WHERE "id" = $sequelize_2);`, + default: 'UPDATE [Users] SET [firstName]=$sequelize_1 WHERE [firstName] = $sequelize_2', + postgres: 'UPDATE "Users" SET "firstName"=$sequelize_1 WHERE "firstName" = $sequelize_2 RETURNING ":data"', + mssql: 'UPDATE [Users] SET [firstName]=$sequelize_1 OUTPUT INSERTED.[:data] WHERE [firstName] = $sequelize_2', + db2: `SELECT * FROM FINAL TABLE (UPDATE "Users" SET "firstName"=$sequelize_1 WHERE "firstName" = $sequelize_2);`, }); expect(firstCall.args[1]?.bind).to.deep.eq({ sequelize_1: ':name', - sequelize_2: ':id', + sequelize_2: ':firstName', }); }); @@ -53,7 +53,7 @@ describe('QueryInterface#update', () => { await expect(sequelize.getQueryInterface().update( instance, - User.tableName, + User.table, { firstName: 'newName' }, { id: literal('$sequelize_test') }, { @@ -71,7 +71,7 @@ describe('QueryInterface#update', () => { await sequelize.getQueryInterface().update( instance, - User.tableName, + User.table, { firstName: 'newName' }, { id: { [Op.eq]: literal('$id') } }, { @@ -101,7 +101,7 @@ describe('QueryInterface#update', () => { await sequelize.getQueryInterface().update( instance, - User.tableName, + User.table, { firstName: 'newName' }, { id: { [Op.eq]: literal('$1') } }, { diff --git a/packages/core/test/unit/query-interface/upsert.test.ts b/packages/core/test/unit/query-interface/upsert.test.ts index 8a723108f55c..623c7656b575 100644 --- a/packages/core/test/unit/query-interface/upsert.test.ts +++ b/packages/core/test/unit/query-interface/upsert.test.ts @@ -230,17 +230,17 @@ describe('QueryInterface#upsert', () => { default: 'INSERT INTO `Users` (`firstName`,`counter`) VALUES ($sequelize_1,`counter` + 1) ON DUPLICATE KEY UPDATE `counter`=`counter` + 1;', postgres: 'INSERT INTO "Users" ("firstName","counter") VALUES ($sequelize_1,`counter` + 1) ON CONFLICT ("id") DO UPDATE SET "counter"=EXCLUDED."counter";', mssql: ` - MERGE INTO [Users] WITH(HOLDLOCK) AS [Users_target] - USING (VALUES(N'Jonh', \`counter\` + 1)) AS [Users_source]([firstName], [counter]) - ON [Users_target].[id] = [Users_source].[id] WHEN MATCHED THEN UPDATE SET [Users_target].[counter] = \`counter\` + 1 + MERGE INTO [Users] WITH(HOLDLOCK) AS [Users_target] + USING (VALUES(N'Jonh', \`counter\` + 1)) AS [Users_source]([firstName], [counter]) + ON [Users_target].[id] = [Users_source].[id] WHEN MATCHED THEN UPDATE SET [Users_target].[counter] = \`counter\` + 1 WHEN NOT MATCHED THEN INSERT ([firstName], [counter]) VALUES(N'Jonh', \`counter\` + 1) OUTPUT $action, INSERTED.*; `, sqlite: 'INSERT INTO `Users` (`firstName`,`counter`) VALUES ($sequelize_1,`counter` + 1) ON CONFLICT (`id`) DO UPDATE SET `counter`=EXCLUDED.`counter`;', snowflake: 'INSERT INTO "Users" ("firstName","counter") VALUES ($sequelize_1,`counter` + 1);', db2: ` - MERGE INTO "Users" AS "Users_target" - USING (VALUES('Jonh', \`counter\` + 1)) AS "Users_source"("firstName", "counter") - ON "Users_target"."id" = "Users_source"."id" WHEN MATCHED THEN UPDATE SET "Users_target"."counter" = \`counter\` + 1 + MERGE INTO "Users" AS "Users_target" + USING (VALUES('Jonh', \`counter\` + 1)) AS "Users_source"("firstName", "counter") + ON "Users_target"."id" = "Users_source"."id" WHEN MATCHED THEN UPDATE SET "Users_target"."counter" = \`counter\` + 1 WHEN NOT MATCHED THEN INSERT ("firstName", "counter") VALUES('Jonh', \`counter\` + 1); `, ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "Users" ("firstName","counter") VALUES ($sequelize_1,`counter` + 1))', diff --git a/packages/core/test/unit/sql/insert.test.js b/packages/core/test/unit/sql/insert.test.js index 2f4655e52a8e..60e53594e17c 100644 --- a/packages/core/test/unit/sql/insert.test.js +++ b/packages/core/test/unit/sql/insert.test.js @@ -29,7 +29,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { returning: true, hasTrigger: true, }; - expectsql(sql.insertQuery(User.tableName, { user_name: 'triggertest' }, User.getAttributes(), options), + expectsql(sql.insertQuery(User.table, { user_name: 'triggertest' }, User.getAttributes(), options), { query: { ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "users" ("user_name") VALUES ($sequelize_1))', @@ -53,7 +53,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, }); - expectsql(sql.insertQuery(M.tableName, { id: 0 }, M.getAttributes()), + expectsql(sql.insertQuery(M.table, { id: 0 }, M.getAttributes()), { query: { mssql: 'SET IDENTITY_INSERT [ms] ON; INSERT INTO [ms] ([id]) VALUES ($sequelize_1); SET IDENTITY_INSERT [ms] OFF;', @@ -104,7 +104,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { try { result = sql.insertQuery( - User.tableName, + User.table, { user_name: 'testuser', pass_word: '12345' }, User.fieldRawAttributesMap, { @@ -149,7 +149,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { timestamps: false, }); - expectsql(timezoneSequelize.dialect.queryGenerator.insertQuery(User.tableName, { date: new Date(Date.UTC(2015, 0, 20)) }, User.getAttributes(), {}), + expectsql(timezoneSequelize.dialect.queryGenerator.insertQuery(User.table, { date: new Date(Date.UTC(2015, 0, 20)) }, User.getAttributes(), {}), { query: { default: 'INSERT INTO [users] ([date]) VALUES ($sequelize_1);', @@ -176,7 +176,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { timestamps: false, }); - expectsql(current.dialect.queryGenerator.insertQuery(User.tableName, { date: new Date(Date.UTC(2015, 0, 20)) }, User.getAttributes(), {}), + expectsql(current.dialect.queryGenerator.insertQuery(User.table, { date: new Date(Date.UTC(2015, 0, 20)) }, User.getAttributes(), {}), { query: { ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "users" ("date") VALUES ($sequelize_1))', @@ -208,7 +208,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { timestamps: false, }); - expectsql(current.dialect.queryGenerator.insertQuery(User.tableName, { date: new Date(Date.UTC(2015, 0, 20, 1, 2, 3, 89)) }, User.getAttributes(), {}), + expectsql(current.dialect.queryGenerator.insertQuery(User.table, { date: new Date(Date.UTC(2015, 0, 20, 1, 2, 3, 89)) }, User.getAttributes(), {}), { query: { ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "users" ("date") VALUES ($sequelize_1))', @@ -243,7 +243,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { timestamps: false, }); - expectsql(sql.insertQuery(User.tableName, { user_name: 'null\0test' }, User.getAttributes()), + expectsql(sql.insertQuery(User.table, { user_name: 'null\0test' }, User.getAttributes()), { query: { ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "users" ("user_name") VALUES ($sequelize_1))', @@ -286,7 +286,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { // mapping primary keys to their "field" override values const primaryKeys = User.primaryKeyAttributes.map(attr => User.getAttributes()[attr].field || attr); - expectsql(sql.bulkInsertQuery(User.tableName, [{ user_name: 'testuser', pass_word: '12345' }], { updateOnDuplicate: ['user_name', 'pass_word', 'updated_at'], upsertKeys: primaryKeys }, User.fieldRawAttributesMap), + expectsql(sql.bulkInsertQuery(User.table, [{ user_name: 'testuser', pass_word: '12345' }], { updateOnDuplicate: ['user_name', 'pass_word', 'updated_at'], upsertKeys: primaryKeys }, User.fieldRawAttributesMap), { default: 'INSERT INTO `users` (`user_name`,`pass_word`) VALUES (\'testuser\',\'12345\');', ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "users" ("user_name","pass_word") VALUES (\'testuser\',\'12345\'))', @@ -309,7 +309,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, }); - expectsql(sql.bulkInsertQuery(M.tableName, [{ id: 0 }, { id: null }], {}, M.fieldRawAttributesMap), + expectsql(sql.bulkInsertQuery(M.table, [{ id: 0 }, { id: null }], {}, M.fieldRawAttributesMap), { query: { mssql: 'SET IDENTITY_INSERT [ms] ON; INSERT INTO [ms] DEFAULT VALUES;INSERT INTO [ms] ([id]) VALUES (0),(NULL); SET IDENTITY_INSERT [ms] OFF;', @@ -363,7 +363,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { try { result = sql.bulkInsertQuery( - User.tableName, + User.table, [{ user_name: 'testuser', pass_word: '12345' }], { updateOnDuplicate: ['user_name', 'pass_word', 'updated_at'], diff --git a/packages/core/test/unit/sql/update.test.js b/packages/core/test/unit/sql/update.test.js index e7032ae959a9..9904b3dbfe90 100644 --- a/packages/core/test/unit/sql/update.test.js +++ b/packages/core/test/unit/sql/update.test.js @@ -24,7 +24,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { const options = { returning: false, }; - expectsql(sql.updateQuery(User.tableName, { user_name: 'triggertest' }, { id: 2 }, options, User.getAttributes()), + expectsql(sql.updateQuery(User.table, { user_name: 'triggertest' }, { id: 2 }, options, User.getAttributes()), { query: { db2: 'SELECT * FROM FINAL TABLE (UPDATE "users" SET "user_name"=$sequelize_1 WHERE "id" = $sequelize_2);', @@ -52,7 +52,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { returning: true, hasTrigger: true, }; - expectsql(sql.updateQuery(User.tableName, { user_name: 'triggertest' }, { id: 2 }, options, User.getAttributes()), + expectsql(sql.updateQuery(User.table, { user_name: 'triggertest' }, { id: 2 }, options, User.getAttributes()), { query: { ibmi: 'UPDATE "users" SET "user_name"=$sequelize_1 WHERE "id" = $sequelize_2', @@ -80,7 +80,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { timestamps: false, }); - expectsql(sql.updateQuery(User.tableName, { username: 'new.username' }, { username: 'username' }, { limit: 1 }), { + expectsql(sql.updateQuery(User.table, { username: 'new.username' }, { username: 'username' }, { limit: 1 }), { query: { ibmi: 'UPDATE "Users" SET "username"=$sequelize_1 WHERE "username" = $sequelize_2', mssql: 'UPDATE TOP(1) [Users] SET [username]=$sequelize_1 WHERE [username] = $sequelize_2', diff --git a/packages/core/test/unit/utils/sql.test.ts b/packages/core/test/unit/utils/sql.test.ts index ad435ed10fc4..793378839986 100644 --- a/packages/core/test/unit/utils/sql.test.ts +++ b/packages/core/test/unit/utils/sql.test.ts @@ -61,7 +61,7 @@ describe('mapBindParameters', () => { }); }); - it('parses bind parameters following JSONB indexing', () => { + it('parses bind parameters following JSON extraction', () => { const { sql } = mapBindParameters(`SELECT * FROM users WHERE json_col->>$key`, dialect); expectsql(sql, { @@ -391,7 +391,7 @@ describe('injectReplacements (named replacements)', () => { }); }); - it('parses named replacements following JSONB indexing', () => { + it('parses named replacements following JSON extraction', () => { const sql = injectReplacements(`SELECT * FROM users WHERE json_col->>:key`, dialect, { key: 'name', }); @@ -608,7 +608,7 @@ describe('injectReplacements (positional replacements)', () => { }); }); - it('parses named replacements following JSONB indexing', () => { + it('parses named replacements following JSON extraction', () => { const sql = injectReplacements(`SELECT * FROM users WHERE json_col->>?`, dialect, ['name']); expectsql(sql, { diff --git a/packages/core/test/unit/utils/utils.test.ts b/packages/core/test/unit/utils/utils.test.ts index d2cee9f69720..d9e1fe2d4c57 100644 --- a/packages/core/test/unit/utils/utils.test.ts +++ b/packages/core/test/unit/utils/utils.test.ts @@ -9,8 +9,6 @@ import { underscoredIf, camelizeIf, pluralize, singularize } from '@sequelize/co import { parseConnectionString } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/url.js'; import { sequelize, getTestDialect, expectsql } from '../../support'; -const dialect = sequelize.dialect; - describe('Utils', () => { describe('underscore', () => { describe('underscoredIf', () => { @@ -200,22 +198,22 @@ describe('Utils', () => { describe('toDefaultValue', () => { it('return plain data types', () => { - expect(() => toDefaultValue(DataTypes.UUIDV4, dialect)).to.throw(); + expect(() => toDefaultValue(DataTypes.UUIDV4)).to.throw(); }); it('return uuid v1', () => { - expect(/^[\da-z-]{36}$/.test(toDefaultValue(DataTypes.UUIDV1(), dialect) as string)).to.be.equal(true); + expect(/^[\da-z-]{36}$/.test(toDefaultValue(DataTypes.UUIDV1()) as string)).to.be.equal(true); }); it('return uuid v4', () => { - expect(/^[\da-z-]{36}/.test(toDefaultValue(DataTypes.UUIDV4(), dialect) as string)).to.be.equal(true); + expect(/^[\da-z-]{36}/.test(toDefaultValue(DataTypes.UUIDV4()) as string)).to.be.equal(true); }); it('return now', () => { - expect(Object.prototype.toString.call(toDefaultValue(DataTypes.NOW(), dialect))).to.be.equal('[object Date]'); + expect(Object.prototype.toString.call(toDefaultValue(DataTypes.NOW()))).to.be.equal('[object Date]'); }); it('return plain string', () => { - expect(toDefaultValue('Test', dialect)).to.equal('Test'); + expect(toDefaultValue('Test')).to.equal('Test'); }); it('return plain object', () => { - expect(toDefaultValue({}, dialect)).to.deep.equal({}); + expect(toDefaultValue({})).to.deep.equal({}); }); }); From 50898cac7c979edd94cd7eb68242d9aff7362378 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Zo=C3=A9?= Date: Sun, 12 Mar 2023 11:09:03 +0100 Subject: [PATCH 18/18] feat: rewrite the part of QueryGenerator responsible for WHERE (#15598) - The `Op.not` operator *always* means `NOT (x)`. If you need `IS NOT`, use `Op.isNot`, if you need `!=`, use `Op.ne`. This should not impact you much however, because writing `{ [Op.not]: 1 }` will be interpreted as `{ [Op.not]: { [Op.eq]: 1 } }` and will result in `NOT (x = 1)` instead of `x != 1` - Removed the ability to use string operators in `where` (use the `sql` tag for that) - Removed operator aliases (they were deprecated since Sequelize 5) - Removed the ability to use an attribute object (one of the values of `Model.rawAttributes`) as one of the operands of `where`, it made little sense, was undocumented, and complicated the code. - `or([])` & `or({})` produce `''` instead of `'0=1'` - `not({})` produces `''` instead of `'0=1'` - JSON operations now produce JSON strings (`->` operator) instead of unquoting them (`->>` operator) to fix https://github.com/sequelize/sequelize/issues/15238 - The `Json` class, that is produced by `json()` has been removed. `json()` now returns either `Where` or `Attribute` depending on its parameters. - `json()` only accepts the sequelize JSON notation, it will not accept the dialect-specific syntaxes, or `json()` functions anymore. Use the `sql` tag, `literal` or `fn` for that - Array values in replacements are not escaped as SQL Lists anymore, but as SQL arrays. If the user wishes to use a list, they need to wrap their array with the new `list()` method - The signature of the `escape` method has changed. `escape` is now able to handle any SQL value (dynamic like `fn` or literal like `'a string'`) and accepts a `bindParam` option which can be used to make it add bind parameters instead of escaping - Removed the ability to pass the value of the primary key directly to `where`. `User.findOne({ where: 1 })` will throw. Use `User.findByPk(1)` instead. - `SequelizeMethod` has been renamed to `BaseSqlExpression` --- packages/core/package.json | 1 + .../core/src/associations/belongs-to-many.ts | 2 +- packages/core/src/associations/has-many.ts | 2 +- .../core/src/dialects/abstract/data-types.ts | 15 + .../abstract/query-generator-typescript.ts | 365 +++- .../dialects/abstract/query-generator.d.ts | 45 +- .../src/dialects/abstract/query-generator.js | 1013 +--------- .../abstract/query-generator/operators.js | 92 - .../dialects/abstract/query-interface.d.ts | 2 +- .../src/dialects/abstract/query-interface.js | 2 +- .../abstract/where-sql-builder-types.ts | 105 + .../dialects/abstract/where-sql-builder.ts | 905 +++++++++ .../core/src/dialects/db2/query-generator.js | 74 +- .../core/src/dialects/ibmi/query-generator.js | 142 +- .../core/src/dialects/mariadb/data-types.ts | 6 +- .../mariadb/query-generator-typescript.ts | 15 + .../src/dialects/mariadb/query-generator.js | 21 - .../src/dialects/mssql/query-generator.js | 38 +- .../core/src/dialects/mysql/data-types.ts | 13 + .../core/src/dialects/mysql/mysql-utils.ts | 2 +- .../mysql/query-generator-typescript.ts | 42 +- .../src/dialects/mysql/query-generator.js | 176 +- .../core/src/dialects/postgres/data-types.ts | 11 +- .../postgres/query-generator-typescript.ts | 22 +- .../src/dialects/postgres/query-generator.js | 145 +- .../src/dialects/snowflake/query-generator.js | 138 +- .../src/dialects/sqlite/query-generator.js | 143 +- .../expression-builders/association-path.ts | 12 + .../core/src/expression-builders/attribute.ts | 60 + .../base-sql-expression.ts | 16 +- packages/core/src/expression-builders/cast.ts | 28 +- packages/core/src/expression-builders/col.ts | 24 +- .../expression-builders/dialect-aware-fn.ts | 73 + packages/core/src/expression-builders/fn.ts | 27 +- .../src/expression-builders/identifier.ts | 33 + .../core/src/expression-builders/json-path.ts | 71 + packages/core/src/expression-builders/json.ts | 66 +- packages/core/src/expression-builders/list.ts | 33 + .../core/src/expression-builders/literal.ts | 11 +- packages/core/src/expression-builders/sql.ts | 54 + .../core/src/expression-builders/value.ts | 14 + .../core/src/expression-builders/where.ts | 163 +- packages/core/src/index.d.ts | 12 +- packages/core/src/index.mjs | 8 +- packages/core/src/model-definition.ts | 36 + packages/core/src/model.d.ts | 120 +- packages/core/src/model.js | 5 +- packages/core/src/operators.ts | 24 +- packages/core/src/sequelize.d.ts | 20 +- packages/core/src/sequelize.js | 54 +- packages/core/src/sql-string.ts | 46 +- packages/core/src/utils/attribute-syntax.ts | 270 +++ packages/core/src/utils/deprecations.ts | 4 +- packages/core/src/utils/format.ts | 72 +- packages/core/src/utils/sql.ts | 22 +- .../integration/data-types/data-types.test.ts | 22 +- .../integration/dialects/postgres/dao.test.js | 1792 ++++++++--------- .../integration/dialects/sqlite/dao.test.js | 10 +- .../test/integration/instance/values.test.js | 7 +- packages/core/test/integration/json.test.js | 394 ---- packages/core/test/integration/json.test.ts | 476 +++++ packages/core/test/integration/model.test.js | 43 +- .../test/integration/model/create.test.js | 6 +- .../test/integration/model/findAll.test.js | 5 - .../core/test/integration/model/json.test.js | 521 +---- .../test/integration/model/paranoid.test.js | 4 +- .../test/integration/model/schema.test.js | 16 +- .../core/test/integration/model/scope.test.js | 2 +- .../core/test/integration/sequelize.test.js | 10 - .../test/integration/sequelize/query.test.js | 4 +- packages/core/test/integration/support.ts | 21 +- packages/core/test/support.ts | 6 +- packages/core/test/types/sequelize.ts | 1 - .../core/test/unit/data-types/arrays.test.ts | 38 +- .../unit/data-types/misc-data-types.test.ts | 61 +- .../dialects/abstract/query-generator.test.js | 145 -- .../unit/dialects/db2/query-generator.test.js | 176 +- .../dialects/mariadb/query-generator.test.js | 176 +- .../dialects/mysql/query-generator.test.js | 170 +- .../dialects/postgres/query-generator.test.js | 221 +- .../dialects/postgres/range-data-type.test.ts | 66 +- .../snowflake/query-generator.test.js | 355 +--- .../dialects/sqlite/query-generator.test.js | 156 -- packages/core/test/unit/model/include.test.js | 2 +- .../core/test/unit/model/validation.test.js | 8 +- .../get-where-conditions.test.ts | 15 - .../unit/query-generator/insert-query.test.ts | 114 ++ .../json-path-extraction-query.test.ts | 83 +- .../unit/query-generator/select-query.test.ts | 177 +- .../unit/query-generator/update-query.test.ts | 122 ++ .../core/test/unit/sql/add-constraint.test.js | 2 +- packages/core/test/unit/sql/delete.test.js | 14 +- .../core/test/unit/sql/generateJoin.test.js | 4 +- .../unit/sql/get-constraint-snippet.test.js | 2 +- packages/core/test/unit/sql/index.test.js | 10 +- packages/core/test/unit/sql/json.test.js | 209 -- packages/core/test/unit/sql/literal.test.ts | 152 ++ packages/core/test/unit/sql/select.test.js | 76 +- packages/core/test/unit/sql/where.test.ts | 1327 ++++++------ .../test/unit/utils/attribute-syntax.test.ts | 132 ++ packages/core/test/unit/utils/sql.test.ts | 15 +- packages/core/test/unit/utils/utils.test.ts | 177 +- yarn.lock | 5 + 103 files changed, 5616 insertions(+), 6849 deletions(-) delete mode 100644 packages/core/src/dialects/abstract/query-generator/operators.js create mode 100644 packages/core/src/dialects/abstract/where-sql-builder-types.ts create mode 100644 packages/core/src/dialects/abstract/where-sql-builder.ts create mode 100644 packages/core/src/expression-builders/association-path.ts create mode 100644 packages/core/src/expression-builders/attribute.ts create mode 100644 packages/core/src/expression-builders/dialect-aware-fn.ts create mode 100644 packages/core/src/expression-builders/identifier.ts create mode 100644 packages/core/src/expression-builders/json-path.ts create mode 100644 packages/core/src/expression-builders/list.ts create mode 100644 packages/core/src/expression-builders/sql.ts create mode 100644 packages/core/src/expression-builders/value.ts create mode 100644 packages/core/src/utils/attribute-syntax.ts delete mode 100644 packages/core/test/integration/json.test.js create mode 100644 packages/core/test/integration/json.test.ts delete mode 100644 packages/core/test/unit/dialects/abstract/query-generator.test.js delete mode 100644 packages/core/test/unit/query-generator/get-where-conditions.test.ts delete mode 100644 packages/core/test/unit/sql/json.test.js create mode 100644 packages/core/test/unit/sql/literal.test.ts create mode 100644 packages/core/test/unit/utils/attribute-syntax.test.ts diff --git a/packages/core/package.json b/packages/core/package.json index bbcef94c7978..d578fe8a4e9a 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -48,6 +48,7 @@ "dependencies": { "@types/debug": "^4.1.7", "@types/validator": "^13.7.5", + "bnf-parser": "^3.1.1", "dayjs": "^1.11.5", "debug": "^4.3.4", "dottie": "^2.0.2", diff --git a/packages/core/src/associations/belongs-to-many.ts b/packages/core/src/associations/belongs-to-many.ts index 5d6bb21e9c05..e6c2735fb77f 100644 --- a/packages/core/src/associations/belongs-to-many.ts +++ b/packages/core/src/associations/belongs-to-many.ts @@ -2,6 +2,7 @@ import each from 'lodash/each'; import isEqual from 'lodash/isEqual'; import omit from 'lodash/omit'; import upperFirst from 'lodash/upperFirst'; +import type { WhereOptions } from '../dialects/abstract/where-sql-builder-types.js'; import { AssociationError } from '../errors'; import { col } from '../expression-builders/col.js'; import { fn } from '../expression-builders/fn.js'; @@ -23,7 +24,6 @@ import type { ModelStatic, Transactionable, UpdateOptions, - WhereOptions, } from '../model'; import { Op } from '../operators'; import type { Sequelize } from '../sequelize'; diff --git a/packages/core/src/associations/has-many.ts b/packages/core/src/associations/has-many.ts index ca3b0db7dbdf..43bbfba17c83 100644 --- a/packages/core/src/associations/has-many.ts +++ b/packages/core/src/associations/has-many.ts @@ -1,4 +1,5 @@ import upperFirst from 'lodash/upperFirst'; +import type { WhereOptions } from '../dialects/abstract/where-sql-builder-types.js'; import { AssociationError } from '../errors/index.js'; import { col } from '../expression-builders/col.js'; import { fn } from '../expression-builders/fn.js'; @@ -12,7 +13,6 @@ import type { Transactionable, ModelStatic, AttributeNames, UpdateValues, Attributes, - WhereOptions, } from '../model'; import { Op } from '../operators'; import { isPlainObject } from '../utils/check.js'; diff --git a/packages/core/src/dialects/abstract/data-types.ts b/packages/core/src/dialects/abstract/data-types.ts index 123e5fbb7b2c..a221810613ba 100644 --- a/packages/core/src/dialects/abstract/data-types.ts +++ b/packages/core/src/dialects/abstract/data-types.ts @@ -173,6 +173,14 @@ export abstract class AbstractDataType< return isEqual(value, originalValue); } + /** + * Whether this DataType wishes to handle NULL values itself. + * This is almost exclusively used by {@link JSON} and {@link JSONB} which serialize `null` as the JSON string `'null'`. + */ + acceptsNull(): boolean { + return false; + } + /** * Called when a value is retrieved from the Database, and its DataType is specified. * Used to normalize values from the database. @@ -1605,6 +1613,13 @@ export class JSON extends AbstractDataType { } } + /** + * We stringify null too. + */ + acceptsNull(): boolean { + return true; + } + toBindableValue(value: any): string { return globalThis.JSON.stringify(value); } diff --git a/packages/core/src/dialects/abstract/query-generator-typescript.ts b/packages/core/src/dialects/abstract/query-generator-typescript.ts index 103ef2d6f360..648c70411bc4 100644 --- a/packages/core/src/dialects/abstract/query-generator-typescript.ts +++ b/packages/core/src/dialects/abstract/query-generator-typescript.ts @@ -1,11 +1,38 @@ import NodeUtil from 'node:util'; import isObject from 'lodash/isObject'; -import type { ModelStatic } from '../../model.js'; -import type { Sequelize } from '../../sequelize.js'; -import { isPlainObject, isString } from '../../utils/check.js'; +import { AssociationPath } from '../../expression-builders/association-path.js'; +import { Attribute } from '../../expression-builders/attribute.js'; +import { + BaseSqlExpression, + +} from '../../expression-builders/base-sql-expression.js'; +import { Cast } from '../../expression-builders/cast.js'; +import { Col } from '../../expression-builders/col.js'; +import { DialectAwareFn } from '../../expression-builders/dialect-aware-fn.js'; +import { Fn } from '../../expression-builders/fn.js'; +import { Identifier } from '../../expression-builders/identifier.js'; +import { JsonPath } from '../../expression-builders/json-path.js'; +import { List } from '../../expression-builders/list.js'; +import { Literal } from '../../expression-builders/literal.js'; +import { Value } from '../../expression-builders/value.js'; +import { Where } from '../../expression-builders/where.js'; +import type { ModelStatic, Attributes, Model } from '../../model.js'; +import { Op } from '../../operators.js'; +import type { BindOrReplacements, Sequelize, Expression } from '../../sequelize.js'; +import { bestGuessDataTypeOfVal } from '../../sql-string.js'; +import { isDictionary, isNullish, isPlainObject, isString } from '../../utils/check.js'; +import { noOpCol } from '../../utils/deprecations.js'; import { quoteIdentifier } from '../../utils/dialect.js'; import { isModelStatic } from '../../utils/model-utils.js'; +import { EMPTY_OBJECT } from '../../utils/object.js'; +import { injectReplacements } from '../../utils/sql.js'; +import { attributeTypeToSql, validateDataType } from './data-types-utils.js'; +import type { DataType, BindParamOptions } from './data-types.js'; +import { AbstractDataType } from './data-types.js'; +import type { AbstractQueryGenerator } from './query-generator.js'; import type { TableName, TableNameWithSchema } from './query-interface.js'; +import type { WhereOptions } from './where-sql-builder-types.js'; +import { PojoWhere, WhereSqlBuilder, wrapAmbiguousWhere } from './where-sql-builder.js'; import type { AbstractDialect } from './index.js'; export type TableNameOrModel = TableName | ModelStatic; @@ -24,6 +51,50 @@ export interface QueryGeneratorOptions { dialect: AbstractDialect; } +/** + * Options accepted by {@link AbstractQueryGeneratorTypeScript#escape} + */ +export interface EscapeOptions extends FormatWhereOptions { + readonly type?: DataType | undefined; +} + +export interface FormatWhereOptions extends Bindable { + /** + * These are used to inline replacements into the query, when one is found inside of a {@link Literal}. + */ + readonly replacements?: BindOrReplacements | undefined; + + /** + * The model of the main alias. Used to determine the type & column name of attributes referenced in the where clause. + */ + readonly model?: ModelStatic | undefined; + + /** + * The alias of the main table corresponding to {@link FormatWhereOptions.model}. + * Used as the prefix for attributes that do not reference an association, e.g. + * + * ```ts + * const where = { name: 'foo' }; + * ``` + * + * will produce + * + * ```sql + * WHERE ""."name" = 'foo' + * ``` + */ + readonly mainAlias?: string | undefined; +} + +/** + * Methods that support this option are functions that add values to the query. + * If {@link Bindable.bindParam} is specified, the value will be added to the query as a bind parameter. + * If it is not specified, the value will be added to the query as a literal. + */ +export interface Bindable { + bindParam?(value: unknown): string; +} + // DO NOT MAKE THIS CLASS PUBLIC! /** * This is a temporary class used to progressively migrate the AbstractQueryGenerator class to TypeScript by slowly moving its functions here. @@ -31,7 +102,8 @@ export interface QueryGeneratorOptions { */ export class AbstractQueryGeneratorTypeScript { - protected readonly dialect: AbstractDialect; + protected readonly whereSqlBuilder: WhereSqlBuilder; + readonly dialect: AbstractDialect; protected readonly sequelize: Sequelize; constructor(options: QueryGeneratorOptions) { @@ -45,6 +117,8 @@ export class AbstractQueryGeneratorTypeScript { this.sequelize = options.sequelize; this.dialect = options.dialect; + // TODO: remove casting once all AbstractQueryGenerator functions are moved here + this.whereSqlBuilder = new WhereSqlBuilder(this as unknown as AbstractQueryGenerator); } protected get options() { @@ -144,6 +218,7 @@ export class AbstractQueryGeneratorTypeScript { * @param identifier * @param _force */ + // TODO: memoize last result quoteIdentifier(identifier: string, _force?: boolean) { return quoteIdentifier(identifier, this.dialect.TICK_CHAR_LEFT, this.dialect.TICK_CHAR_RIGHT); } @@ -158,4 +233,286 @@ export class AbstractQueryGeneratorTypeScript { return tableA.tableName === tableB.tableName && tableA.schema === tableB.schema; } + + whereQuery(where: WhereOptions>, options?: FormatWhereOptions) { + const query = this.whereItemsQuery(where, options); + if (query && query.length > 0) { + return `WHERE ${query}`; + } + + return ''; + } + + whereItemsQuery(where: WhereOptions> | undefined, options?: FormatWhereOptions) { + return this.whereSqlBuilder.formatWhereOptions(where, options); + } + + formatSqlExpression(piece: BaseSqlExpression, options?: EscapeOptions): string { + if (piece instanceof Literal) { + return this.formatLiteral(piece, options); + } + + if (piece instanceof Fn) { + return this.formatFn(piece, options); + } + + if (piece instanceof List) { + return this.escapeList(piece.values, options); + } + + if (piece instanceof Value) { + return this.escape(piece.value, options); + } + + if (piece instanceof Identifier) { + return this.quoteIdentifier(piece.value); + } + + if (piece instanceof Cast) { + return this.formatCast(piece, options); + } + + if (piece instanceof Col) { + return this.formatCol(piece, options); + } + + if (piece instanceof Attribute) { + return this.formatAttribute(piece, options); + } + + if (piece instanceof Where) { + if (piece.where instanceof PojoWhere) { + return this.whereSqlBuilder.formatPojoWhere(piece.where, options); + } + + return this.whereSqlBuilder.formatWhereOptions(piece.where, options); + } + + if (piece instanceof JsonPath) { + return this.formatJsonPath(piece, options); + } + + if (piece instanceof AssociationPath) { + return this.formatAssociationPath(piece); + } + + if (piece instanceof DialectAwareFn) { + return this.formatDialectAwareFn(piece, options); + } + + throw new Error(`Unknown sequelize method ${piece.constructor.name}`); + } + + protected formatAssociationPath(associationPath: AssociationPath): string { + return `${this.quoteIdentifier(associationPath.associationPath.join('->'))}.${this.quoteIdentifier(associationPath.attributeName)}`; + } + + protected formatJsonPath(jsonPathVal: JsonPath, options?: EscapeOptions): string { + const value = this.escape(jsonPathVal.expression, options); + + if (jsonPathVal.path.length === 0) { + return value; + } + + return this.jsonPathExtractionQuery(value, jsonPathVal.path, false); + } + + /** + * The goal of this method is to execute the equivalent of json_unquote for the current dialect. + * + * @param _arg + * @param _options + */ + formatUnquoteJson(_arg: Expression, _options: EscapeOptions | undefined): string { + if (!this.dialect.supports.jsonOperations) { + throw new Error(`Unquoting JSON is not supported by ${this.dialect.name} dialect.`); + } + + throw new Error(`formatUnquoteJson has not been implemented in ${this.dialect.name}.`); + } + + /** + * @param _sqlExpression ⚠️ This is not an identifier, it's a raw SQL expression. It will be inlined in the query. + * @param _path The JSON path, where each item is one level of the path + * @param _unquote Whether the result should be unquoted (depending on dialect: ->> and #>> operators, json_unquote function). Defaults to `false`. + */ + jsonPathExtractionQuery(_sqlExpression: string, _path: ReadonlyArray, _unquote: boolean): string { + if (!this.dialect.supports.jsonOperations) { + throw new Error(`JSON Paths are not supported in ${this.dialect.name}.`); + } + + throw new Error(`jsonPathExtractionQuery has not been implemented in ${this.dialect.name}.`); + } + + protected formatLiteral(piece: Literal, options?: EscapeOptions): string { + const sql = piece.val.map(part => { + if (part instanceof BaseSqlExpression) { + return this.formatSqlExpression(part, options); + } + + return part; + }).join(''); + + if (options?.replacements) { + return injectReplacements(sql, this.dialect, options.replacements, { + onPositionalReplacement: () => { + throw new TypeError(`The following literal includes positional replacements (?). +Only named replacements (:name) are allowed in literal() because we cannot guarantee the order in which they will be evaluated: +➜ literal(${JSON.stringify(sql)})`); + }, + }); + } + + return sql; + } + + protected formatAttribute(piece: Attribute, options?: EscapeOptions): string { + const model = options?.model; + + // This handles special attribute syntaxes like $association.references$, json.paths, and attribute::casting + const columnName = model?.modelDefinition.getColumnNameLoose(piece.attributeName) + ?? piece.attributeName; + + if (options?.mainAlias) { + return `${this.quoteIdentifier(options.mainAlias)}.${this.quoteIdentifier(columnName)}`; + } + + return this.quoteIdentifier(columnName); + } + + protected formatFn(piece: Fn, options?: EscapeOptions): string { + // arguments of a function can be anything, it's not necessarily the type of the attribute, + // so we need to remove the type from their escape options + const argEscapeOptions = piece.args.length > 0 && options?.type ? { ...options, type: undefined } : options; + const args = piece.args.map(arg => { + return this.escape(arg, argEscapeOptions); + }).join(', '); + + return `${piece.fn}(${args})`; + } + + protected formatDialectAwareFn(piece: DialectAwareFn, options?: EscapeOptions): string { + // arguments of a function can be anything, it's not necessarily the type of the attribute, + // so we need to remove the type from their escape options + const argEscapeOptions = piece.args.length > 0 && options?.type ? { ...options, type: undefined } : options; + + return piece.apply(this.dialect, argEscapeOptions); + } + + protected formatCast(cast: Cast, options?: EscapeOptions) { + const type = this.sequelize.normalizeDataType(cast.type); + + const castSql = wrapAmbiguousWhere(cast.expression, this.escape(cast.expression, { ...options, type })); + const targetSql = attributeTypeToSql(type).toUpperCase(); + + // TODO: if we're casting to the same SQL DataType, we could skip the SQL cast (but keep the JS cast) + // This is useful because sometimes you want to cast the Sequelize DataType to another Sequelize DataType, + // but they are both the same SQL type, so a SQL cast would be redundant. + + return `CAST(${castSql} AS ${targetSql})`; + } + + protected formatCol(piece: Col, options?: EscapeOptions) { + // TODO: can this be removed? + if (piece.identifiers.length === 1 && piece.identifiers[0].startsWith('*')) { + return '*'; + } + + // Weird legacy behavior + const identifiers = piece.identifiers.length === 1 ? piece.identifiers[0] : piece.identifiers; + + // TODO: use quoteIdentifiers? + // @ts-expect-error -- quote is declared on child class + return this.quote(identifiers, options?.model, undefined, options); + } + + /** + * Escapes a value (e.g. a string, number or date) as an SQL value (as opposed to an identifier). + * + * @param value The value to escape + * @param options The options to use when escaping the value + */ + escape(value: unknown, options: EscapeOptions = EMPTY_OBJECT): string { + if (isDictionary(value) && Op.col in value) { + noOpCol(); + value = new Col(value[Op.col] as string); + } + + if (value instanceof BaseSqlExpression) { + return this.formatSqlExpression(value, options); + } + + if (value === undefined) { + throw new TypeError('"undefined" cannot be escaped'); + } + + let { type } = options; + if (type != null) { + type = this.sequelize.normalizeDataType(type); + } + + if ( + value === null + // we handle null values ourselves by default, unless the data type explicitly accepts null + && (!(type instanceof AbstractDataType) || !type.acceptsNull()) + ) { + if (options.bindParam) { + return options.bindParam(null); + } + + return 'NULL'; + } + + if (type == null || typeof type === 'string') { + type = bestGuessDataTypeOfVal(value, this.dialect); + } else { + type = this.sequelize.normalizeDataType(type); + } + + this.validate(value, type); + + if (options.bindParam) { + return type.getBindParamSql(value, options as BindParamOptions); + } + + return type.escape(value); + } + + /** + * Validate a value against a field specification + * + * @param value The value to validate + * @param type The DataType to validate against + */ + validate(value: unknown, type: DataType) { + if (this.sequelize.options.noTypeValidation || isNullish(value)) { + return; + } + + if (isString(type)) { + return; + } + + type = this.sequelize.normalizeDataType(type); + + const error = validateDataType(value, type); + if (error) { + throw error; + } + } + + /** + * Escapes an array of values (e.g. strings, numbers or dates) as an SQL List of values. + * + * @param values The list of values to escape + * @param options + * + * @example + * ```ts + * const values = [1, 2, 3]; + * queryGenerator.escapeList([1, 2, 3]); // '(1, 2, 3)' + */ + escapeList(values: unknown[], options?: EscapeOptions): string { + return `(${values.map(value => this.escape(value, options)).join(', ')})`; + } } diff --git a/packages/core/src/dialects/abstract/query-generator.d.ts b/packages/core/src/dialects/abstract/query-generator.d.ts index 13bc2de3f4ed..5115b537f4fe 100644 --- a/packages/core/src/dialects/abstract/query-generator.d.ts +++ b/packages/core/src/dialects/abstract/query-generator.d.ts @@ -1,6 +1,5 @@ // TODO: complete me - this file is a stub that will be completed when query-generator.ts is migrated to TS -import type { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; import type { Col } from '../../expression-builders/col.js'; import type { Literal } from '../../expression-builders/literal.js'; import type { @@ -10,27 +9,19 @@ import type { AttributeOptions, ModelStatic, SearchPathable, - WhereOptions, } from '../../model.js'; -import type { QueryTypes } from '../../query-types.js'; import type { DataType } from './data-types.js'; import type { QueryGeneratorOptions } from './query-generator-typescript.js'; import { AbstractQueryGeneratorTypeScript } from './query-generator-typescript.js'; import type { QueryWithBindParams } from './query-generator.types.js'; import type { TableName } from './query-interface.js'; +import type { WhereOptions } from './where-sql-builder-types.js'; type ParameterOptions = { // only named replacements are allowed replacements?: { [key: string]: unknown }, }; -type EscapeOptions = ParameterOptions & { - /** - * Set to true if the value to escape is in a list (e.g. used inside of Op.any or Op.all). - */ - isList?: boolean, -}; - type SelectOptions = FindOptions & { model: ModelStatic, }; @@ -66,17 +57,6 @@ type ArithmeticQueryOptions = ParameterOptions & { returning?: boolean | Array, }; -export type WhereItemsQueryOptions = ParameterOptions & { - model?: ModelStatic, - type?: QueryTypes, - prefix?: string | Literal, - field?: AttributeOptions, -}; - -type HandleSequelizeMethodOptions = ParameterOptions & { - -}; - // keep CREATE_DATABASE_QUERY_SUPPORTABLE_OPTIONS updated when modifying this export interface CreateDatabaseQueryOptions { collate?: string; @@ -125,30 +105,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { setImmediateQuery(constraints: string[]): string; setDeferredQuery(constraints: string[]): string; generateTransactionId(): string; - whereQuery(where: object, options?: ParameterOptions): string; - whereItemsQuery(where: WhereOptions, options: WhereItemsQueryOptions, binding?: string): string; - validate(value: unknown, field?: NormalizedAttributeOptions): void; - escape(value: unknown, field?: NormalizedAttributeOptions, options?: EscapeOptions): string; quoteIdentifiers(identifiers: string): string; - handleSequelizeMethod( - smth: BaseSqlExpression, - tableName?: TableName, - factory?: ModelStatic, - options?: HandleSequelizeMethodOptions, - prepend?: boolean, - ): string; - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} column The JSON column - * @param {string|Array} [path] The path to extract (optional) - * @param {boolean} [isJson] The value is JSON use alt symbols (optional) - * @returns {string} The generated sql query - * @private - */ - // TODO: see how we can make the typings protected/private while still allowing it to be typed in tests - jsonPathExtractionQuery(column: string, path?: string | string[], isJson?: boolean): string; selectQuery(tableName: TableName, options?: SelectOptions, model?: ModelStatic): string; insertQuery( diff --git a/packages/core/src/dialects/abstract/query-generator.js b/packages/core/src/dialects/abstract/query-generator.js index 4dd6d9331c70..ea19ef729f6e 100644 --- a/packages/core/src/dialects/abstract/query-generator.js +++ b/packages/core/src/dialects/abstract/query-generator.js @@ -2,32 +2,26 @@ import NodeUtil from 'node:util'; import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; -import { Cast } from '../../expression-builders/cast.js'; import { Col } from '../../expression-builders/col.js'; -import { Fn } from '../../expression-builders/fn.js'; import { Literal } from '../../expression-builders/literal.js'; -import { Where } from '../../expression-builders/where.js'; import { conformIndex } from '../../model-internals'; -import { getTextDataTypeForDialect } from '../../sql-string'; -import { rejectInvalidOptions, isNullish, canTreatArrayAsAnd, isColString } from '../../utils/check'; +import { and } from '../../sequelize'; +import { rejectInvalidOptions, canTreatArrayAsAnd, isColString } from '../../utils/check'; import { mapFinderOptions, removeNullishValuesFromHash, } from '../../utils/format'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { isModelStatic } from '../../utils/model-utils'; -import { injectReplacements } from '../../utils/sql'; import { nameIndex, spliceStr } from '../../utils/string'; -import { getComplexKeys, getComplexSize, getOperators } from '../../utils/where.js'; -import { AbstractDataType } from './data-types'; -import { attributeTypeToSql, validateDataType } from './data-types-utils'; +import { attributeTypeToSql } from './data-types-utils'; import { AbstractQueryGeneratorTypeScript } from './query-generator-typescript'; +import { joinWithLogicalOperator } from './where-sql-builder'; const util = require('node:util'); const _ = require('lodash'); const crypto = require('node:crypto'); -const SqlString = require('../../sql-string'); const DataTypes = require('../../data-types'); const { Association } = require('../../associations/base'); const { BelongsTo } = require('../../associations/belongs-to'); @@ -144,7 +138,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { const returningModelAttributes = []; const values = Object.create(null); const quotedTable = this.quoteTable(table); - const bindParam = options.bindParam === undefined ? this.bindParam(bind) : options.bindParam; + let bindParam = options.bindParam === undefined ? this.bindParam(bind) : options.bindParam; let query; let valueQuery = ''; let emptyQuery = ''; @@ -179,22 +173,23 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (_.get(this, ['sequelize', 'options', 'dialectOptions', 'prependSearchPath']) || options.searchPath) { // Not currently supported with search path (requires output of multiple queries) - options.bindParam = false; + bindParam = undefined; } if (this.dialect.supports.EXCEPTION && options.exception) { // Not currently supported with bind parameters (requires output of multiple queries) - options.bindParam = false; + bindParam = undefined; } valueHash = removeNullishValuesFromHash(valueHash, this.options.omitNull); for (const key in valueHash) { if (Object.prototype.hasOwnProperty.call(valueHash, key)) { - const value = valueHash[key]; + // if value is undefined, we replace it with null + const value = valueHash[key] ?? null; fields.push(this.quoteIdentifier(key)); // SERIALS' can't be NULL in postgresql, use DEFAULT where supported - if (modelAttributeMap && modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true && value == null) { + if (modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true && value == null) { if (!this.dialect.supports.autoIncrement.defaultValue) { fields.splice(-1, 1); } else if (this.dialect.supports.DEFAULT) { @@ -203,15 +198,16 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { values[key] = this.escape(null); } } else { - if (modelAttributeMap && modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true) { + if (modelAttributeMap[key] && modelAttributeMap[key].autoIncrement === true) { identityWrapperRequired = true; } - if (value instanceof BaseSqlExpression || options.bindParam === false) { - values[key] = this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'INSERT', replacements: options.replacements }); - } else { - values[key] = this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'INSERT' }, bindParam); - } + values[key] = this.escape(value, { + model: options.model, + type: modelAttributeMap[key]?.type, + replacements: options.replacements, + bindParam, + }); } } } @@ -367,7 +363,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return fieldValueHash[key] != null ? fieldValueHash[key] : 'DEFAULT'; } - return this.escape(fieldValueHash[key], fieldMappedAttributes[key], { context: 'INSERT', replacements: options.replacements }); + return this.escape(fieldValueHash[key] ?? null, { + // model // TODO: make bulkInsertQuery accept model instead of fieldValueHashes + // bindParam // TODO: support bind params + type: fieldMappedAttributes[key]?.type, + replacements: options.replacements, + }); }); tuples.push(`(${values.join(',')})`); @@ -469,7 +470,8 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { const bindParam = options.bindParam === undefined ? this.bindParam(bind) : options.bindParam; if (this.dialect.supports['LIMIT ON UPDATE'] && options.limit && this.dialect.name !== 'mssql' && this.dialect.name !== 'db2') { - suffix = ` LIMIT ${this.escape(options.limit, undefined, options)} `; + // TODO: use bind parameter + suffix = ` LIMIT ${this.escape(options.limit, options)} `; } if (this.dialect.supports.returnValues && options.returning) { @@ -502,13 +504,14 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { continue; } - const value = attrValueHash[key]; + const value = attrValueHash[key] ?? null; - if (value instanceof BaseSqlExpression || options.bindParam === false) { - values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); - } else { - values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE' }, bindParam)}`); - } + values.push(`${this.quoteIdentifier(key)}=${this.escape(value, { + // model // TODO: receive modelDefinition instead of columnDefinitions + type: modelAttributeMap?.[key]?.type, + replacements: options.replacements, + bindParam, + })}`); } const whereOptions = { ...options, bindParam }; @@ -545,8 +548,11 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { options = options || {}; _.defaults(options, { returning: true }); + const { model } = options; - const replacementOptions = _.pick(options, ['replacements']); + // TODO: add attribute DataType + // TODO: add model + const escapeOptions = _.pick(options, ['replacements', 'model']); extraAttributesToBeUpdated = removeNullishValuesFromHash(extraAttributesToBeUpdated, this.options.omitNull); @@ -562,16 +568,19 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { const updateSetSqlFragments = []; for (const attributeName in incrementAmountsByAttribute) { - const incrementAmount = incrementAmountsByAttribute[attributeName]; - const quotedField = this.quoteIdentifier(attributeName); - const escapedAmount = this.escape(incrementAmount, undefined, replacementOptions); + const columnName = model ? model.modelDefinition.getColumnNameLoose(attributeName) : attributeName; + const incrementAmount = incrementAmountsByAttribute[columnName]; + const quotedField = this.quoteIdentifier(columnName); + const escapedAmount = this.escape(incrementAmount, escapeOptions); updateSetSqlFragments.push(`${quotedField}=${quotedField}${operator} ${escapedAmount}`); } for (const attributeName in extraAttributesToBeUpdated) { - const newValue = extraAttributesToBeUpdated[attributeName]; - const quotedField = this.quoteIdentifier(attributeName); - const escapedValue = this.escape(newValue, undefined, replacementOptions); + const columnName = model ? model.modelDefinition.getColumnNameLoose(attributeName) : attributeName; + + const newValue = extraAttributesToBeUpdated[columnName]; + const quotedField = this.quoteIdentifier(columnName); + const escapedValue = this.escape(newValue, escapeOptions); updateSetSqlFragments.push(`${quotedField}=${escapedValue}`); } @@ -581,7 +590,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { 'SET', updateSetSqlFragments.join(','), outputFragment, - this.whereQuery(where, replacementOptions), + this.whereQuery(where, escapeOptions), returningFragment, ]); } @@ -623,7 +632,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { const fieldsSql = options.fields.map(field => { if (field instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(field); + return this.formatSqlExpression(field); } if (typeof field === 'string') { @@ -760,7 +769,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { } if (field instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(field); + return this.formatSqlExpression(field); } if (field.attribute) { @@ -813,7 +822,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { } constraintName = this.quoteIdentifier(options.name || `${tableName}_${fieldsSqlString}_df`); - constraintSnippet = `CONSTRAINT ${constraintName} DEFAULT (${this.escape(options.defaultValue, undefined, options)}) FOR ${quotedFields[0]}`; + constraintSnippet = `CONSTRAINT ${constraintName} DEFAULT (${this.escape(options.defaultValue, options)}) FOR ${quotedFields[0]}`; break; case 'PRIMARY KEY': constraintName = this.quoteIdentifier(options.name || `${tableName}_${fieldsSqlString}_pk`); @@ -1045,7 +1054,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { } if (collection instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(collection, undefined, undefined, options); + return this.formatSqlExpression(collection, options); } if (_.isPlainObject(collection) && collection.raw) { @@ -1084,49 +1093,6 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { return this.quoteIdentifier(identifiers); } - /** - * Escape a value (e.g. a string, number or date) - * - * @param {unknown} value - * @param {object} attribute - * @param {object} options - * @private - */ - escape(value, attribute, options = {}) { - if (value instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(value, undefined, undefined, { replacements: options.replacements }); - } - - if (value == null || attribute?.type == null || typeof attribute.type === 'string') { - // use default escape mechanism instead of the DataType's. - return SqlString.escape(value, this.dialect); - } - - if (!attribute.type.belongsToDialect(this.dialect)) { - attribute = { - ...attribute, - type: attribute.type.toDialectDataType(this.dialect), - }; - } - - if (options.isList && Array.isArray(value)) { - const escapeOptions = { ...options, isList: false }; - - return `(${value.map(valueItem => { - return this.escape(valueItem, attribute, escapeOptions); - }).join(', ')})`; - } - - this.validate(value, attribute, options); - - return attribute.type.escape(value, { - field: attribute, - timezone: this.options.timezone, - operation: options.operation, - dialect: this.dialect, - }); - } - bindParam(bind) { let i = 0; @@ -1139,72 +1105,6 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { }; } - /* - Returns a bind parameter representation of a value (e.g. a string, number or date) - @private - */ - format(value, field, options, bindParam) { - options = options || {}; - - if (value instanceof BaseSqlExpression) { - throw new TypeError('Cannot pass SequelizeMethod as a bind parameter - use escape instead'); - } - - if (value == null || !field?.type || typeof field.type === 'string') { - return bindParam(value); - } - - this.validate(value, field, options); - - return field.type.getBindParamSql(value, { - field, - timezone: this.options.timezone, - operation: options.operation, - bindParam, - dialect: this.dialect, - }); - } - - /* - Validate a value against a field specification - @private - */ - validate(value, field) { - if (this.noTypeValidation || isNullish(value)) { - return; - } - - const error = field.type instanceof AbstractDataType - ? validateDataType(value, field.type, field.fieldName, null) - : null; - if (error) { - throw error; - } - } - - /** - * @param {string} identifier - * - * @deprecated Do not use this method. A string starting & ending with the identifier quote (", `, []) does - * not mean that it's already quoted. These characters are valid inside of identifiers and should be properly escaped. - */ - isIdentifierQuoted(identifier) { - return /^\s*(?:(["'`])(?:(?!\1).|\1{2})*\1\.?)+\s*$/i.test(identifier); - } - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} _column The JSON column - * @param {string|Array} [_path] The path to extract (optional) - * @param {boolean} [_isJson] The value is JSON use alt symbols (optional) - * @returns {string} The generated sql query - * @private - */ - jsonPathExtractionQuery(_column, _path, _isJson) { - throw new Error(`JSON operations are not supported in ${this.dialect.name}.`); - } - /* Returns a query for selecting elements in the table . Options: @@ -1322,7 +1222,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { mainTable.as = mainTable.name; } - const where = { ...options.where }; + let where = { ...options.where }; let groupedLimitOrder; let whereKey; let include; @@ -1334,6 +1234,9 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { whereKey = options.groupedLimit.on.identifierField; } + // TODO: do not use a placeholder! + const placeholder = '"$PLACEHOLDER$" = true'; + if (options.groupedLimit.on instanceof BelongsToMany) { // BTM includes needs to join the through table on to check ID groupedTableName = options.groupedLimit.on.throughModel.name; @@ -1344,10 +1247,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { association: options.groupedLimit.on.fromSourceToThrough, duplicating: false, // The UNION'ed query may contain duplicates, but each sub-query cannot required: true, - where: { - [Op.placeholder]: true, - ...options.groupedLimit.through?.where, - }, + where: and(new Literal(placeholder), options.groupedLimit.through?.where), }], model, }); @@ -1384,7 +1284,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { // Ordering is handled by the subqueries, so ordering the UNION'ed result is not needed groupedLimitOrder = options.order; delete options.order; - where[Op.placeholder] = true; + where = and(new Literal(placeholder), where); } // Caching the base query and splicing the where part into it is consistently > twice @@ -1405,8 +1305,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { }, model, ).replace(/;$/, '')}) AS sub`; // Every derived table must have its own alias - const placeHolder = this.whereItemQuery(Op.placeholder, true, { model }); - const splicePos = baseQuery.indexOf(placeHolder); + const splicePos = baseQuery.indexOf(placeholder); mainQueryItems.push(this.selectFromTableFragment(options, mainTable.model, attributes.main, `(${ options.groupedLimit.values.map(value => { @@ -1423,7 +1322,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { }; } - return spliceStr(baseQuery, splicePos, placeHolder.length, this.getWhereConditions(groupWhere, groupedTableName, undefined, options)); + return spliceStr(baseQuery, splicePos, placeholder.length, this.whereItemsQuery(groupWhere, { ...options, mainAlias: groupedTableName })); }).join( this.dialect.supports['UNION ALL'] ? ' UNION ALL ' : ' UNION ', ) @@ -1437,7 +1336,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { // Add WHERE to sub or main query if (Object.prototype.hasOwnProperty.call(options, 'where') && !options.groupedLimit) { - options.where = this.getWhereConditions(options.where, mainTable.as || tableName, model, options); + options.where = this.whereItemsQuery(options.where, { + ...options, + model, + mainAlias: mainTable.as || tableName, + }); + if (options.where) { if (subQuery) { subQueryItems.push(` WHERE ${options.where}`); @@ -1468,7 +1372,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { // Add HAVING to sub or main query if (Object.prototype.hasOwnProperty.call(options, 'having')) { - options.having = this.getWhereConditions(options.having, tableName, model, options, false); + options.having = this.whereItemsQuery(options.having, { + ...options, + model, + mainAlias: mainTable.as || tableName, + }); + if (options.having) { if (subQuery) { subQueryItems.push(` HAVING ${options.having}`); @@ -1546,7 +1455,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { let addTable = true; if (attr instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(attr, undefined, undefined, options); + return this.formatSqlExpression(attr, options); } if (Array.isArray(attr)) { @@ -1557,7 +1466,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { attr = [...attr]; if (attr[0] instanceof BaseSqlExpression) { - attr[0] = this.handleSequelizeMethod(attr[0], undefined, undefined, options); + attr[0] = this.formatSqlExpression(attr[0], options); addTable = false; } else { attr[0] = this.quoteIdentifier(attr[0]); @@ -1616,15 +1525,11 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { let verbatim = false; if (Array.isArray(attr) && attr.length === 2) { - if (attr[0] instanceof BaseSqlExpression && ( - attr[0] instanceof Literal - || attr[0] instanceof Cast - || attr[0] instanceof Fn - )) { + if (attr[0] instanceof BaseSqlExpression) { verbatim = true; } - attr = attr.map(attrPart => (attrPart instanceof BaseSqlExpression ? this.handleSequelizeMethod(attrPart, undefined, undefined, options) : attrPart)); + attr = attr.map(attrPart => (attrPart instanceof BaseSqlExpression ? this.formatSqlExpression(attrPart, options) : attrPart)); attrAs = attr[1]; attr = attr[0]; @@ -1632,13 +1537,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (attr instanceof Literal) { // We trust the user to rename the field correctly - return this.handleSequelizeMethod(attr, undefined, undefined, options); + return this.formatLiteral(attr, options); } - if (attr instanceof Cast || attr instanceof Fn) { + if (attr instanceof BaseSqlExpression) { throw new TypeError( - 'Tried to select attributes using Sequelize.cast or Sequelize.fn without specifying an alias for the result, during eager loading. ' - + 'This means the attribute will not be added to the returned instance', + `Tried to select attributes using ${attr.constructor.name} without specifying an alias for the result, during eager loading. This means the attribute will not be added to the returned instance`, ); } @@ -1828,6 +1732,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { asRight = `${asLeft}->${asRight}`; } + // TODO: use whereItemsQuery to generate the entire "ON" condition. let joinOn = `${this.quoteTable(asLeft)}.${this.quoteIdentifier(columnNameLeft)}`; const subqueryAttributes = []; @@ -1856,7 +1761,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (include.on) { joinOn = this.whereItemsQuery(include.on, { - prefix: new Literal(this.quoteIdentifier(asRight)), + mainAlias: asRight, model: include.model, replacements: options?.replacements, }); @@ -1864,16 +1769,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { if (include.where) { joinWhere = this.whereItemsQuery(include.where, { - prefix: new Literal(this.quoteIdentifier(asRight)), + mainAlias: asRight, model: include.model, replacements: options?.replacements, }); if (joinWhere) { - if (include.or) { - joinOn += ` OR ${joinWhere}`; - } else { - joinOn += ` AND ${joinWhere}`; - } + joinOn = joinWithLogicalOperator([joinOn, joinWhere], include.or ? Op.or : Op.and); } } @@ -1922,9 +1823,9 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { throw new Error(`literal() cannot be used in the "returning" option array in ${this.dialect.name}. Use col(), or a string instead.`); } - return this.handleSequelizeMethod(field); + return this.formatSqlExpression(field); } else if (field instanceof Col) { - return this.handleSequelizeMethod(field); + return this.formatSqlExpression(field); } throw new Error(`Unsupported value in "returning" option: ${NodeUtil.inspect(field)}. This option only accepts true, false, or an array of strings, col() or literal().`); @@ -2048,7 +1949,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { targetJoinOn += `${this.quoteIdentifier(throughAs)}.${this.quoteIdentifier(identTarget)}`; if (through.where) { - throughWhere = this.getWhereConditions(through.where, new Literal(this.quoteIdentifier(throughAs)), through.model, topLevelInfo.options); + throughWhere = this.whereItemsQuery(through.where, { ...topLevelInfo.options, model: through.model, mainAlias: throughAs }); } // Generate a wrapped join so that the through table join can be dependent on the target join @@ -2061,7 +1962,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { joinCondition = sourceJoinOn; if ((include.where || include.through.where) && include.where) { - targetWhere = this.getWhereConditions(include.where, new Literal(this.quoteIdentifier(includeAs.internalAs)), include.model, topLevelInfo.options); + targetWhere = this.whereItemsQuery(include.where, { ...topLevelInfo.options, model: include.model, mainAlias: includeAs.internalAs }); if (targetWhere) { joinCondition += ` AND ${targetWhere}`; } @@ -2159,7 +2060,7 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { where: { [Op.and]: [ topInclude.where, - { [Op.join]: new Literal(join) }, + new Literal(join), ], }, limit: 1, @@ -2168,16 +2069,12 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { }, topInclude.model); } - if (!topLevelInfo.options.where[Op.and]) { - topLevelInfo.options.where[Op.and] = []; - } - - topLevelInfo.options.where[`__${includeAs.internalAs}`] = new Literal([ + topLevelInfo.options.where = and(topLevelInfo.options.where, new Literal([ '(', query.replace(/;$/, ''), ')', 'IS NOT NULL', - ].join(' ')); + ].join(' '))); } /* @@ -2309,744 +2206,19 @@ export class AbstractQueryGenerator extends AbstractQueryGeneratorTypeScript { addLimitAndOffset(options, model) { let fragment = ''; if (options.limit != null) { - fragment += ` LIMIT ${this.escape(options.limit, undefined, options)}`; + fragment += ` LIMIT ${this.escape(options.limit, options)}`; } else if (options.offset) { // limit must be specified if offset is specified. fragment += ` LIMIT 18446744073709551615`; } if (options.offset) { - fragment += ` OFFSET ${this.escape(options.offset, undefined, options)}`; + fragment += ` OFFSET ${this.escape(options.offset, options)}`; } return fragment; } - handleSequelizeMethod(smth, tableName, factory, options, prepend) { - let result; - - if (Object.prototype.hasOwnProperty.call(this.OperatorMap, smth.comparator)) { - smth.comparator = this.OperatorMap[smth.comparator]; - } - - if (smth instanceof Where) { - let value = smth.logic; - let key; - - if (smth.attribute instanceof BaseSqlExpression) { - key = this.getWhereConditions(smth.attribute, tableName, factory, options, prepend); - } else { - key = `${this.quoteTable(smth.attribute.Model.name)}.${this.quoteIdentifier(smth.attribute.field || smth.attribute.fieldName)}`; - } - - if (value && value instanceof BaseSqlExpression) { - value = this.getWhereConditions(value, tableName, factory, options, prepend); - - if (value === 'NULL') { - if (smth.comparator === '=') { - smth.comparator = 'IS'; - } - - if (smth.comparator === '!=') { - smth.comparator = 'IS NOT'; - } - } - - return [key, value].join(` ${smth.comparator} `); - } - - if (_.isPlainObject(value)) { - return this.whereItemQuery(smth.attribute, value, { - model: factory, - }); - } - - if ([this.OperatorMap[Op.between], this.OperatorMap[Op.notBetween]].includes(smth.comparator)) { - value = `${this.escape(value[0], undefined, options)} AND ${this.escape(value[1], undefined, options)}`; - } else if (typeof value === 'boolean') { - value = this.booleanValue(value); - } else { - value = this.escape(value, undefined, options); - } - - if (value === 'NULL') { - if (smth.comparator === '=') { - smth.comparator = 'IS'; - } - - if (smth.comparator === '!=') { - smth.comparator = 'IS NOT'; - } - } - - return [key, value].join(` ${smth.comparator} `); - } - - if (smth instanceof Literal) { - if (options?.replacements) { - return injectReplacements(smth.val, this.dialect, options.replacements, { - onPositionalReplacement: () => { - throw new TypeError(`The following literal includes positional replacements (?). -Only named replacements (:name) are allowed in literal() because we cannot guarantee the order in which they will be evaluated: -➜ literal(${JSON.stringify(smth.val)})`); - }, - }); - } - - return smth.val; - - } - - if (smth instanceof Cast) { - if (smth.val instanceof BaseSqlExpression) { - result = this.handleSequelizeMethod(smth.val, tableName, factory, options, prepend); - } else if (_.isPlainObject(smth.val)) { - result = this.whereItemsQuery(smth.val); - } else { - result = this.escape(smth.val, undefined, options); - } - - return `CAST(${result} AS ${smth.type.toUpperCase()})`; - } - - if (smth instanceof Fn) { - return `${smth.fn}(${ - smth.args.map(arg => { - if (arg instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(arg, tableName, factory, options, prepend); - } - - if (_.isPlainObject(arg)) { - return this.whereItemsQuery(arg); - } - - return this.escape(arg, undefined, options); - }).join(', ') - })`; - } - - if (smth instanceof Col) { - if (Array.isArray(smth.col) && !factory) { - throw new Error('Cannot call Sequelize.col() with array outside of order / group clause'); - } - - if (smth.col.startsWith('*')) { - return '*'; - } - - return this.quote(smth.col, factory, undefined, options); - } - - return smth.toString(this, factory); - } - - whereQuery(where, options) { - const query = this.whereItemsQuery(where, options); - if (query && query.length > 0) { - return `WHERE ${query}`; - } - - return ''; - } - - whereItemsQuery(where, options, binding) { - if ( - where === null - || where === undefined - || getComplexSize(where) === 0 - ) { - // NO OP - return ''; - } - - if (typeof where === 'string') { - throw new TypeError('Support for `{where: \'raw query\'}` has been removed.'); - } - - const items = []; - - binding = binding || 'AND'; - if (binding[0] !== ' ') { - binding = ` ${binding} `; - } - - if (_.isPlainObject(where)) { - for (const prop of getComplexKeys(where)) { - const item = where[prop]; - items.push(this.whereItemQuery(prop, item, options)); - } - } else { - items.push(this.whereItemQuery(undefined, where, options)); - } - - return items.length && items.filter(item => item && item.length).join(binding) || ''; - } - - whereItemQuery(key, value, options = {}) { - if (value === undefined) { - throw new Error(`WHERE parameter "${key}" has invalid "undefined" value`); - } - - if (typeof key === 'string' && key.includes('.') && options.model) { - const keyParts = key.split('.'); - const { attributes } = options.model.modelDefinition; - const attribute = attributes.get(keyParts[0]); - if (attribute?.type instanceof DataTypes.JSON) { - const tmp = {}; - _.set(tmp, keyParts.slice(1), value); - - return this.whereItemQuery(attribute.columnName, tmp, { field: attribute, ...options }); - } - } - - const field = this._findField(key, options); - const fieldType = field && field.type || options.type; - - const isPlainObject = _.isPlainObject(value); - const isArray = !isPlainObject && Array.isArray(value); - key = this.OperatorsAliasMap && this.OperatorsAliasMap[key] || key; - if (isPlainObject) { - value = this._replaceAliases(value); - } - - const valueKeys = isPlainObject && getComplexKeys(value); - - if (key === undefined) { - if (typeof value === 'string') { - return value; - } - - if (isPlainObject && valueKeys.length === 1) { - return this.whereItemQuery(valueKeys[0], value[valueKeys[0]], options); - } - } - - if (value === null) { - const opValue = options.bindParam ? 'NULL' : this.escape(value, field, options); - - return this._joinKeyValue(key, opValue, this.OperatorMap[Op.is], options.prefix); - } - - if (!value) { - const opValue = options.bindParam ? this.format(value, field, options, options.bindParam) : this.escape(value, field, options); - - return this._joinKeyValue(key, opValue, this.OperatorMap[Op.eq], options.prefix); - } - - if (value instanceof BaseSqlExpression && !(key !== undefined && value instanceof Fn)) { - return this.handleSequelizeMethod(value, undefined, undefined, options); - } - - // Convert where: [] to Op.and if possible, else treat as literal/replacements - if (key === undefined && isArray) { - if (canTreatArrayAsAnd(value)) { - key = Op.and; - } else { - throw new Error('Support for literal replacements in the `where` object has been removed.'); - } - } - - if (key === Op.or || key === Op.and || key === Op.not) { - return this._whereGroupBind(key, value, options); - } - - if (value[Op.or]) { - return this._whereBind(this.OperatorMap[Op.or], key, value[Op.or], options); - } - - if (value[Op.and]) { - return this._whereBind(this.OperatorMap[Op.and], key, value[Op.and], options); - } - - if (isArray && fieldType instanceof DataTypes.ARRAY) { - const opValue = options.bindParam ? this.format(value, field, options, options.bindParam) : this.escape(value, field, options); - - return this._joinKeyValue(key, opValue, this.OperatorMap[Op.eq], options.prefix); - } - - if (isPlainObject && fieldType instanceof DataTypes.JSON && options.json !== false) { - return this._whereJSON(key, value, options); - } - - // If multiple keys we combine the different logic conditions - if (isPlainObject && valueKeys.length > 1) { - return this._whereBind(this.OperatorMap[Op.and], key, value, options); - } - - if (isArray) { - return this._whereParseSingleValueObject(key, field, Op.in, value, options); - } - - if (isPlainObject) { - if (this.OperatorMap[valueKeys[0]]) { - return this._whereParseSingleValueObject(key, field, valueKeys[0], value[valueKeys[0]], options); - } - - return this._whereParseSingleValueObject(key, field, this.OperatorMap[Op.eq], value, options); - } - - if (key === Op.placeholder) { - const opValue = options.bindParam ? this.format(value, field, options, options.bindParam) : this.escape(value, field, options); - - return this._joinKeyValue(this.OperatorMap[key], opValue, this.OperatorMap[Op.eq], options.prefix); - } - - const opValue = options.bindParam ? this.format(value, field, options, options.bindParam) : this.escape(value, field, options); - - return this._joinKeyValue(key, opValue, this.OperatorMap[Op.eq], options.prefix); - } - - _findField(key, options) { - if (options.field) { - return options.field; - } - - const modelDefinition = options.model?.modelDefinition; - const attribute = modelDefinition?.attributes.get(key); - if (attribute) { - return attribute; - } - - const column = modelDefinition?.columns.get(key); - if (column) { - return column; - } - } - - // OR/AND/NOT grouping logic - _whereGroupBind(key, value, options) { - const binding = key === Op.or ? this.OperatorMap[Op.or] : this.OperatorMap[Op.and]; - const outerBinding = key === Op.not ? 'NOT ' : ''; - - if (Array.isArray(value)) { - value = value.map(item => { - let itemQuery = this.whereItemsQuery(item, options, this.OperatorMap[Op.and]); - if (itemQuery && itemQuery.length > 0 && (Array.isArray(item) || _.isPlainObject(item)) && getComplexSize(item) > 1) { - itemQuery = `(${itemQuery})`; - } - - return itemQuery; - }).filter(item => item && item.length); - - value = value.length && value.join(binding); - } else { - value = this.whereItemsQuery(value, options, binding); - } - - // Op.or: [] should return no data. - // Op.not of no restriction should also return no data - if ((key === Op.or || key === Op.not) && !value) { - return '0 = 1'; - } - - return value ? `${outerBinding}(${value})` : undefined; - } - - _whereBind(binding, key, value, options) { - if (_.isPlainObject(value)) { - value = getComplexKeys(value).map(prop => { - const item = value[prop]; - - return this.whereItemQuery(key, { [prop]: item }, options); - }); - } else { - value = value.map(item => this.whereItemQuery(key, item, options)); - } - - value = value.filter(item => item && item.length); - - return value.length > 0 ? `(${value.join(binding)})` : undefined; - } - - _whereJSON(key, value, options) { - const items = []; - let baseKey = this.quoteIdentifier(key); - if (options.prefix) { - if (options.prefix instanceof Literal) { - baseKey = `${this.handleSequelizeMethod(options.prefix)}.${baseKey}`; - } else { - baseKey = `${this.quoteTable(options.prefix)}.${baseKey}`; - } - } - - for (const op of getOperators(value)) { - const where = { - [op]: value[op], - }; - items.push(this.whereItemQuery(key, where, { ...options, json: false })); - } - - _.forOwn(value, (item, prop) => { - this._traverseJSON(items, baseKey, prop, item, [prop]); - }); - - const result = items.join(this.OperatorMap[Op.and]); - - return items.length > 1 ? `(${result})` : result; - } - - _traverseJSON(items, baseKey, prop, item, path) { - let cast; - - if (path[path.length - 1].includes('::')) { - const tmp = path[path.length - 1].split('::'); - cast = tmp[1]; - path[path.length - 1] = tmp[0]; - } - - let pathKey = this.jsonPathExtractionQuery(baseKey, path); - - if (_.isPlainObject(item)) { - for (const op of getOperators(item)) { - const value = this._toJSONValue(item[op]); - let isJson = false; - if (typeof value === 'string' && op === Op.contains) { - try { - JSON.stringify(value); - isJson = true; - } catch { - // failed to parse, is not json so isJson remains false - } - } - - pathKey = this.jsonPathExtractionQuery(baseKey, path, isJson); - items.push(this.whereItemQuery(this._castKey(pathKey, value, cast), { [op]: value })); - } - - _.forOwn(item, (value, itemProp) => { - this._traverseJSON(items, baseKey, itemProp, value, [...path, itemProp]); - }); - - return; - } - - item = this._toJSONValue(item); - items.push(this.whereItemQuery(this._castKey(pathKey, item, cast), { [Op.eq]: item })); - } - - _toJSONValue(value) { - return value; - } - - _castKey(key, value, cast, json) { - cast = cast || this._getJsonCast(Array.isArray(value) ? value[0] : value); - if (cast) { - return new Literal(this.handleSequelizeMethod(new Cast(new Literal(key), cast, json))); - } - - return new Literal(key); - } - - _getJsonCast(value) { - if (typeof value === 'number') { - return 'double precision'; - } - - if (value instanceof Date) { - return 'timestamptz'; - } - - if (typeof value === 'boolean') { - return 'boolean'; - } - - } - - _joinKeyValue(key, value, comparator, prefix) { - if (!key) { - return value; - } - - if (comparator === undefined) { - throw new Error(`${key} and ${value} has no comparator`); - } - - key = this._getSafeKey(key, prefix); - - return [key, value].join(` ${comparator} `); - } - - _getSafeKey(key, prefix) { - if (key instanceof BaseSqlExpression) { - key = this.handleSequelizeMethod(key); - - return this._prefixKey(this.handleSequelizeMethod(key), prefix); - } - - if (isColString(key)) { - key = key.slice(1, 1 + key.length - 2).split('.'); - - if (key.length > 2) { - key = [ - // join the tables by -> to match out internal namings - key.slice(0, -1).join('->'), - key[key.length - 1], - ]; - } - - return key.map(identifier => this.quoteIdentifier(identifier)).join('.'); - } - - return this._prefixKey(this.quoteIdentifier(key), prefix); - } - - _prefixKey(key, prefix) { - if (prefix) { - if (prefix instanceof Literal) { - return [this.handleSequelizeMethod(prefix), key].join('.'); - } - - return [this.quoteTable(prefix), key].join('.'); - } - - return key; - } - - _whereParseSingleValueObject(key, field, prop, value, options) { - if (prop === Op.not) { - if (Array.isArray(value)) { - prop = Op.notIn; - } else if (value !== null && value !== true && value !== false) { - prop = Op.ne; - } - } - - let comparator = this.OperatorMap[prop] || this.OperatorMap[Op.eq]; - - switch (prop) { - case Op.in: - case Op.notIn: - if (value instanceof Literal) { - return this._joinKeyValue(key, value.val, comparator, options.prefix); - } - - if (value.length > 0) { - return this._joinKeyValue(key, `(${value.map(item => this.escape(item, field, { where: true, replacements: options.replacements })).join(', ')})`, comparator, options.prefix); - } - - if (comparator === this.OperatorMap[Op.in]) { - return this._joinKeyValue(key, '(NULL)', comparator, options.prefix); - } - - return ''; - case Op.any: - case Op.all: - comparator = `${this.OperatorMap[Op.eq]} ${comparator}`; - if (value[Op.values]) { - return this._joinKeyValue(key, `(VALUES ${value[Op.values].map(item => `(${this.escape(item, undefined, options)})`).join(', ')})`, comparator, options.prefix); - } - - return this._joinKeyValue(key, `(${this.escape(value, field, options)})`, comparator, options.prefix); - case Op.between: - case Op.notBetween: - return this._joinKeyValue(key, `${this.escape(value[0], field, options)} AND ${this.escape(value[1], field, options)}`, comparator, options.prefix); - case Op.raw: - throw new Error('The `$raw` where property is no longer supported. Use `sequelize.literal` instead.'); - case Op.col: - comparator = this.OperatorMap[Op.eq]; - value = value.split('.'); - - if (value.length > 2) { - value = [ - // join the tables by -> to match out internal namings - value.slice(0, -1).join('->'), - value[value.length - 1], - ]; - } - - return this._joinKeyValue(key, value.map(identifier => this.quoteIdentifier(identifier)).join('.'), comparator, options.prefix); - case Op.startsWith: - case Op.endsWith: - case Op.substring: - comparator = this.OperatorMap[Op.like]; - case Op.notStartsWith: - case Op.notEndsWith: - case Op.notSubstring: { - if (comparator !== this.OperatorMap[Op.like]) { - comparator = this.OperatorMap[Op.notLike]; - } - - if (value instanceof Literal) { - value = value.val; - } - - let pattern = `${value}%`; - - if (prop === Op.endsWith || prop === Op.notEndsWith) { - pattern = `%${value}`; - } - - if (prop === Op.substring || prop === Op.notSubstring) { - pattern = `%${value}%`; - } - - return this._joinKeyValue(key, this.escape(pattern, undefined, options), comparator, options.prefix); - } - - case Op.anyKeyExists: - case Op.allKeysExist: { - if (value instanceof BaseSqlExpression) { - return this._joinKeyValue(key, this.handleSequelizeMethod(value, undefined, undefined, options), comparator, options.prefix); - } - - if (value.length === 0) { - return this._joinKeyValue(key, `ARRAY[]::text[]`, comparator, options.prefix); - } - - return this._joinKeyValue(key, `ARRAY[${value.map(item => this.escape(item, undefined, options)).join(', ')}]`, comparator, options.prefix); - } - } - - const escapeOptions = { - replacements: options.replacements, - }; - - // because UUID is implemented as CHAR() in most dialects (except postgres) - // we accept comparing to non-uuid values when using LIKE and similar operators. - // TODO: https://github.com/sequelize/sequelize/issues/13828 - in postgres, automatically cast to CHAR(36) - // to have the same behavior as the others dialects. - if (comparator.includes(this.OperatorMap[Op.like]) && field?.type) { - field = { - ...field, - // replace DataType with DataTypes.TEXT() to accept all string values. - type: getTextDataTypeForDialect(this.dialect), - }; - } - - if (_.isPlainObject(value)) { - if (value[Op.col]) { - return this._joinKeyValue(key, this.whereItemQuery(null, value), comparator, options.prefix); - } - - if (value[Op.any]) { - escapeOptions.isList = true; - - return this._joinKeyValue(key, `(${this.escape(value[Op.any], field, escapeOptions)})`, `${comparator} ${this.OperatorMap[Op.any]}`, options.prefix); - } - - if (value[Op.all]) { - escapeOptions.isList = true; - - return this._joinKeyValue(key, `(${this.escape(value[Op.all], field, escapeOptions)})`, `${comparator} ${this.OperatorMap[Op.all]}`, options.prefix); - } - } - - if (value === null && comparator === this.OperatorMap[Op.eq]) { - return this._joinKeyValue(key, this.escape(value, field, escapeOptions), this.OperatorMap[Op.is], options.prefix); - } - - if (value === null && comparator === this.OperatorMap[Op.ne]) { - return this._joinKeyValue(key, this.escape(value, field, escapeOptions), this.OperatorMap[Op.not], options.prefix); - } - - // In postgres, Op.contains has multiple signatures: - // - RANGE Op.contains RANGE (both represented by fixed-size arrays in JS) - // - RANGE Op.contains VALUE - // - ARRAY Op.contains ARRAY - // Since the left operand is a RANGE, the type validation must allow the right operand to be either RANGE or VALUE. - if (prop === Op.contains && field?.type instanceof DataTypes.RANGE && !Array.isArray(value)) { - // Since the right operand is not an array, it must be a value. - // We'll serialize using the range's subtype (i.e. if a range of integers, we'll serialize "value" as an integer). - return this._joinKeyValue(key, this.escape(value, { - ...field, - type: field.type.options.subtype, - }, escapeOptions), comparator, options.prefix); - - // The case where "value" is a 'RANGE' is not a special case and is handled by the default case below. - } - - return this._joinKeyValue(key, this.escape(value, field, escapeOptions), comparator, options.prefix); - } - - /* - Takes something and transforms it into values of a where condition. - @private - */ - getWhereConditions(smth, tableName, factory, options, prepend) { - const where = {}; - - if (Array.isArray(tableName)) { - tableName = tableName[0]; - if (Array.isArray(tableName)) { - tableName = tableName[1]; - } - } - - options = options || {}; - - if (prepend === undefined) { - prepend = true; - } - - if (smth && smth instanceof BaseSqlExpression) { // Checking a property is cheaper than a lot of instanceof calls - return this.handleSequelizeMethod(smth, tableName, factory, options, prepend); - } - - if (_.isPlainObject(smth)) { - return this.whereItemsQuery(smth, { - model: factory, - prefix: prepend && tableName, - type: options.type, - replacements: options.replacements, - }); - } - - if (typeof smth === 'number' || typeof smth === 'bigint') { - let primaryKeys = factory ? Object.keys(factory.primaryKeys) : []; - - if (primaryKeys.length > 0) { - // Since we're just a number, assume only the first key - primaryKeys = primaryKeys[0]; - } else { - primaryKeys = 'id'; - } - - where[primaryKeys] = smth; - - return this.whereItemsQuery(where, { - model: factory, - prefix: prepend && tableName, - replacements: options.replacements, - }); - } - - if (typeof smth === 'string') { - return this.whereItemsQuery(smth, { - model: factory, - prefix: prepend && tableName, - replacements: options.replacements, - }); - } - - if (Buffer.isBuffer(smth)) { - return this.escape(smth, undefined, options); - } - - if (Array.isArray(smth)) { - if (smth.length === 0 || smth.length > 0 && smth[0].length === 0) { - return '1=1'; - } - - if (canTreatArrayAsAnd(smth)) { - const _smth = { [Op.and]: smth }; - - return this.getWhereConditions(_smth, tableName, factory, options, prepend); - } - - throw new Error('Support for literal replacements in the `where` object has been removed.'); - } - - if (smth === null) { - return this.whereItemsQuery(smth, { - model: factory, - prefix: prepend && tableName, - replacements: options.replacements, - }); - } - - throw new Error(`Unsupported where option value: ${NodeUtil.inspect(smth)}. Please refer to the Sequelize documentation to learn more about which values are accepted as part of the where option.`); - } - // A recursive parser for nested where conditions parseConditionObject(conditions, path) { path = path || []; @@ -3061,11 +2233,6 @@ Only named replacements (:name) are allowed in literal() because we cannot guara return result; }, []); } - - booleanValue(value) { - return value; - } } -Object.assign(AbstractQueryGenerator.prototype, require('./query-generator/operators')); Object.assign(AbstractQueryGenerator.prototype, require('./query-generator/transaction')); diff --git a/packages/core/src/dialects/abstract/query-generator/operators.js b/packages/core/src/dialects/abstract/query-generator/operators.js deleted file mode 100644 index 5403bbb8bc53..000000000000 --- a/packages/core/src/dialects/abstract/query-generator/operators.js +++ /dev/null @@ -1,92 +0,0 @@ -'use strict'; - -const _ = require('lodash'); -const { Op } = require('../../../operators'); -const { getOperators } = require('../../../utils/where.js'); - -const OperatorHelpers = { - OperatorMap: { - [Op.eq]: '=', - [Op.ne]: '!=', - [Op.gte]: '>=', - [Op.gt]: '>', - [Op.lte]: '<=', - [Op.lt]: '<', - [Op.not]: 'IS NOT', - [Op.is]: 'IS', - [Op.in]: 'IN', - [Op.notIn]: 'NOT IN', - [Op.like]: 'LIKE', - [Op.notLike]: 'NOT LIKE', - [Op.iLike]: 'ILIKE', - [Op.notILike]: 'NOT ILIKE', - [Op.startsWith]: 'LIKE', - [Op.notStartsWith]: 'NOT LIKE', - [Op.endsWith]: 'LIKE', - [Op.notEndsWith]: 'NOT LIKE', - [Op.substring]: 'LIKE', - [Op.notSubstring]: 'NOT LIKE', - [Op.regexp]: '~', - [Op.notRegexp]: '!~', - [Op.iRegexp]: '~*', - [Op.notIRegexp]: '!~*', - [Op.between]: 'BETWEEN', - [Op.notBetween]: 'NOT BETWEEN', - [Op.overlap]: '&&', - [Op.contains]: '@>', - [Op.contained]: '<@', - [Op.adjacent]: '-|-', - [Op.strictLeft]: '<<', - [Op.strictRight]: '>>', - [Op.noExtendRight]: '&<', - [Op.noExtendLeft]: '&>', - [Op.any]: 'ANY', - [Op.all]: 'ALL', - [Op.and]: ' AND ', - [Op.or]: ' OR ', - [Op.col]: 'COL', - [Op.placeholder]: '$$PLACEHOLDER$$', - [Op.match]: '@@', - [Op.anyKeyExists]: '?|', - [Op.allKeysExist]: '?&', - }, - - OperatorsAliasMap: {}, - - setOperatorsAliases(aliases) { - if (!aliases || _.isEmpty(aliases)) { - this.OperatorsAliasMap = false; - } else { - this.OperatorsAliasMap = { ...aliases }; - } - }, - - _replaceAliases(orig) { - const obj = {}; - if (!this.OperatorsAliasMap) { - return orig; - } - - for (const op of getOperators(orig)) { - const item = orig[op]; - if (_.isPlainObject(item)) { - obj[op] = this._replaceAliases(item); - } else { - obj[op] = item; - } - } - - _.forOwn(orig, (item, prop) => { - prop = this.OperatorsAliasMap[prop] || prop; - if (_.isPlainObject(item)) { - item = this._replaceAliases(item); - } - - obj[prop] = item; - }); - - return obj; - }, -}; - -module.exports = OperatorHelpers; diff --git a/packages/core/src/dialects/abstract/query-interface.d.ts b/packages/core/src/dialects/abstract/query-interface.d.ts index f872a62b28c9..4738a4c6f606 100644 --- a/packages/core/src/dialects/abstract/query-interface.d.ts +++ b/packages/core/src/dialects/abstract/query-interface.d.ts @@ -7,7 +7,6 @@ import type { Logging, Model, AttributeOptions, - WhereOptions, Filterable, ModelStatic, CreationAttributes, @@ -21,6 +20,7 @@ import type { DataType } from './data-types.js'; import type { RemoveIndexQueryOptions, TableNameOrModel } from './query-generator-typescript'; import type { AbstractQueryGenerator, AddColumnQueryOptions, RemoveColumnQueryOptions } from './query-generator.js'; import { AbstractQueryInterfaceTypeScript } from './query-interface-typescript'; +import type { WhereOptions } from './where-sql-builder-types.js'; interface Replaceable { /** diff --git a/packages/core/src/dialects/abstract/query-interface.js b/packages/core/src/dialects/abstract/query-interface.js index 0d36cc221e5b..7110b321d963 100644 --- a/packages/core/src/dialects/abstract/query-interface.js +++ b/packages/core/src/dialects/abstract/query-interface.js @@ -943,7 +943,7 @@ export class AbstractQueryInterface extends AbstractQueryInterfaceTypeScript { const modelDefinition = instance?.constructor.modelDefinition; - options = { ...options }; + options = { ...options, model: instance?.constructor }; options.hasTrigger = modelDefinition?.options.hasTrigger; const { query, bind } = this.queryGenerator.updateQuery( diff --git a/packages/core/src/dialects/abstract/where-sql-builder-types.ts b/packages/core/src/dialects/abstract/where-sql-builder-types.ts new file mode 100644 index 000000000000..3de3d7b68c28 --- /dev/null +++ b/packages/core/src/dialects/abstract/where-sql-builder-types.ts @@ -0,0 +1,105 @@ +import type { DynamicSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import type { WhereOperators } from '../../model.js'; +import type { Op } from '../../operators.js'; +import type { AllowArray } from '../../utils/types.js'; + +/** + * This type allows using `Op.or`, `Op.and`, and `Op.not` recursively around another type. + * It also supports using a plain Array as an alias for `Op.and`. (unlike {@link AllowNotOrAndRecursive}). + * + * Example of plain-array treated as `Op.and`: + * ```ts + * User.findAll({ where: [{ id: 1 }, { id: 2 }] }); + * ``` + * + * Meant to be used by {@link WhereOptions}. + */ +export type AllowNotOrAndWithImplicitAndArrayRecursive = AllowArray< + // this is the equivalent of Op.and + | T + | { [Op.or]: AllowArray> } + | { [Op.and]: AllowArray> } + | { [Op.not]: AllowNotOrAndWithImplicitAndArrayRecursive } +>; + +/** + * The type accepted by every `where` option + */ +export type WhereOptions = + // "where" is typically optional. If the user sets it to undefined, we treat is as if the option was not set. + | undefined + | AllowNotOrAndWithImplicitAndArrayRecursive< + | WhereAttributeHash + | DynamicSqlExpression + >; + +/** + * This type allows using `Op.or`, `Op.and`, and `Op.not` recursively around another type. + * Unlike {@link AllowNotOrAndWithImplicitAndArrayRecursive}, it does not allow the 'implicit AND Array'. + * + * Example of plain-array NOT treated as Op.and: + * ```ts + * User.findAll({ where: { id: [1, 2] } }); + * ``` + * + * Meant to be used by {@link WhereAttributeHashValue}. + */ +type AllowNotOrAndRecursive = + | T + | { [Op.or]: AllowArray> } + | { [Op.and]: AllowArray> } + | { [Op.not]: AllowNotOrAndRecursive }; + +/** + * Types that can be compared to an attribute in a WHERE context. + */ +export type WhereAttributeHashValue = + | AllowNotOrAndRecursive< + // if the right-hand side is an array, it will be equal to Op.in + // otherwise it will be equal to Op.eq + // Exception: array attribtues always use Op.eq, never Op.in. + | AttributeType extends any[] + ? WhereOperators[typeof Op.eq] | WhereOperators + : ( + | WhereOperators[typeof Op.in] + | WhereOperators[typeof Op.eq] + | WhereOperators + ) +> + // TODO: this needs a simplified version just for JSON columns + | WhereAttributeHash; // for JSON columns + +/** + * A hash of attributes to describe your search. + * + * Possible key values: + * + * - An attribute name: `{ id: 1 }` + * - A nested attribute: `{ '$projects.id$': 1 }` + * - A JSON key: `{ 'object.key': 1 }` + * - A cast: `{ 'id::integer': 1 }` + * + * - A combination of the above: `{ '$join.attribute$.json.path::integer': 1 }` + */ +export type WhereAttributeHash = { + // support 'attribute' & '$attribute$' + [AttributeName in keyof TAttributes as AttributeName extends string ? AttributeName | `$${AttributeName}$` : never]?: WhereAttributeHashValue; +} & { + [AttributeName in keyof TAttributes as AttributeName extends string ? + // support 'json.path', '$json$.path', json[index]', '$json$[index]' + | `${AttributeName}.${string}` | `$${AttributeName}$.${string}` + | `${AttributeName}[${string}` | `$${AttributeName}$[${string}` + // support 'attribute::cast', '$attribute$::cast', 'json.path::cast' & '$json$.path::cast' + | `${AttributeName | `$${AttributeName}$` | `${AttributeName}.${string}` | `$${AttributeName}$.${string}`}:${string}` + : never]?: WhereAttributeHashValue; +} & { + // support '$nested.attribute$', '$nested.attribute$::cast', '$nested.attribute$.json.path', & '$nested.attribute$.json.path::cast', '$nested.attribute$[index]', & '$nested.attribute$[index]::cast' + [attribute: + | `$${string}.${string}$` + | `$${string}.${string}$::${string}` + | `$${string}.${string}$.${string}` + | `$${string}.${string}$.${string}:${string}` + | `$${string}.${string}$[${string}` + | `$${string}.${string}$[${string}:${string}` + ]: WhereAttributeHashValue, +}; diff --git a/packages/core/src/dialects/abstract/where-sql-builder.ts b/packages/core/src/dialects/abstract/where-sql-builder.ts new file mode 100644 index 000000000000..41cb08adf7fc --- /dev/null +++ b/packages/core/src/dialects/abstract/where-sql-builder.ts @@ -0,0 +1,905 @@ +import NodeUtil from 'node:util'; +import { BaseError } from '../../errors/base-error.js'; +import { AssociationPath } from '../../expression-builders/association-path.js'; +import { Attribute } from '../../expression-builders/attribute.js'; +import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; +import { Cast } from '../../expression-builders/cast.js'; +import { Col } from '../../expression-builders/col.js'; +import { JsonPath } from '../../expression-builders/json-path.js'; +import { Literal } from '../../expression-builders/literal.js'; +import { Value } from '../../expression-builders/value.js'; +import { Where } from '../../expression-builders/where.js'; +import type { + ModelStatic, + WhereOptions, + Expression, +} from '../../index.js'; +import { Op } from '../../operators'; +import type { ParsedJsonPropertyKey } from '../../utils/attribute-syntax.js'; +import { parseAttributeSyntax, parseNestedJsonKeySyntax } from '../../utils/attribute-syntax.js'; +import { isDictionary, isPlainObject, isString } from '../../utils/check.js'; +import { noOpCol } from '../../utils/deprecations.js'; +import { EMPTY_ARRAY, EMPTY_OBJECT } from '../../utils/object.js'; +import type { Nullish } from '../../utils/types.js'; +import { getComplexKeys, getOperators } from '../../utils/where.js'; +import type { NormalizedDataType } from './data-types.js'; +import * as DataTypes from './data-types.js'; +import { AbstractDataType } from './data-types.js'; +import type { FormatWhereOptions } from './query-generator-typescript.js'; +import type { AbstractQueryGenerator } from './query-generator.js'; +import type { WhereAttributeHashValue } from './where-sql-builder-types.js'; + +export class PojoWhere { + declare leftOperand: Expression; + declare whereValue: WhereAttributeHashValue; + + static create( + leftOperand: Expression, + whereAttributeHashValue: WhereAttributeHashValue, + ): PojoWhere { + const pojoWhere = new PojoWhere(); + pojoWhere.leftOperand = leftOperand; + pojoWhere.whereValue = whereAttributeHashValue; + + return pojoWhere; + } +} + +class ObjectPool { + #freeItems: T[]; + #factory: () => T; + #lastOccupiedIndex: number; + constructor(factory: () => T, initialSize: number) { + this.#freeItems = Array.from({ length: initialSize }).map(factory); + this.#lastOccupiedIndex = initialSize - 1; + this.#factory = factory; + } + + getObject(): T { + if (this.#lastOccupiedIndex < 0) { + return this.#factory(); + } + + return this.#freeItems[this.#lastOccupiedIndex--]; + } + + free(val: T): void { + if (this.#lastOccupiedIndex >= (this.#freeItems.length - 1)) { + this.#freeItems.push(val); + + return; + } + + this.#freeItems[++this.#lastOccupiedIndex] = val; + } +} + +const pojoWherePool = new ObjectPool(() => new PojoWhere(), 20); + +export class WhereSqlBuilder { + #operatorMap: Record = { + [Op.eq]: '=', + [Op.ne]: '!=', + [Op.gte]: '>=', + [Op.gt]: '>', + [Op.lte]: '<=', + [Op.lt]: '<', + [Op.is]: 'IS', + [Op.isNot]: 'IS NOT', + [Op.in]: 'IN', + [Op.notIn]: 'NOT IN', + [Op.like]: 'LIKE', + [Op.notLike]: 'NOT LIKE', + [Op.iLike]: 'ILIKE', + [Op.notILike]: 'NOT ILIKE', + [Op.regexp]: '~', + [Op.notRegexp]: '!~', + [Op.iRegexp]: '~*', + [Op.notIRegexp]: '!~*', + [Op.between]: 'BETWEEN', + [Op.notBetween]: 'NOT BETWEEN', + [Op.overlap]: '&&', + [Op.contains]: '@>', + [Op.contained]: '<@', + [Op.adjacent]: '-|-', + [Op.strictLeft]: '<<', + [Op.strictRight]: '>>', + [Op.noExtendRight]: '&<', + [Op.noExtendLeft]: '&>', + [Op.any]: 'ANY', + [Op.all]: 'ALL', + [Op.match]: '@@', + [Op.anyKeyExists]: '?|', + [Op.allKeysExist]: '?&', + }; + + #jsonType: NormalizedDataType | undefined; + #arrayOfTextType: NormalizedDataType | undefined; + + constructor(protected readonly queryGenerator: AbstractQueryGenerator) { + this.#jsonType = this.dialect.supports.dataTypes.JSON + ? new DataTypes.JSON().toDialectDataType(queryGenerator.dialect) + : undefined; + + this.#arrayOfTextType = this.dialect.supports.dataTypes.ARRAY + ? new DataTypes.ARRAY(new DataTypes.TEXT()).toDialectDataType(queryGenerator.dialect) + : undefined; + } + + protected get dialect() { + return this.queryGenerator.dialect; + } + + setOperatorKeyword(op: symbol, keyword: string): void { + this.#operatorMap[op] = keyword; + } + + /** + * Transforms any value accepted by {@link WhereOptions} into a SQL string. + * + * @param where + * @param options + */ + formatWhereOptions( + where: WhereOptions, + options: FormatWhereOptions = EMPTY_OBJECT, + ): string { + if (typeof where === 'string') { + throw new TypeError('Support for `{ where: \'raw query\' }` has been removed. Use `{ where: literal(\'raw query\') }` instead'); + } + + if (where === undefined) { + return ''; + } + + try { + return this.#handleRecursiveNotOrAndWithImplicitAndArray(where, (piece: PojoWhere | BaseSqlExpression) => { + if (piece instanceof BaseSqlExpression) { + return this.queryGenerator.formatSqlExpression(piece, options); + } + + return this.formatPojoWhere(piece, options); + }); + } catch (error) { + throw new BaseError(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts.\nValue: ${NodeUtil.inspect(where)}`, { + cause: error, + }); + } + } + + /** + * This is the recursive "and", "or" and "not" handler of the first level of {@link WhereOptions} (the level *before* encountering an attribute name). + * Unlike handleRecursiveNotOrAndNestedPathRecursive, this method accepts arrays at the top level, which are implicitly converted to "and" groups. + * and does not handle nested JSON paths. + * + * @param input + * @param handlePart + * @param logicalOperator AND / OR + */ + #handleRecursiveNotOrAndWithImplicitAndArray( + input: WhereOptions, + handlePart: (part: BaseSqlExpression | PojoWhere) => string, + logicalOperator: typeof Op.and | typeof Op.or = Op.and, + ): string { + // Arrays in this method are treated as an implicit "AND" operator + if (Array.isArray(input)) { + return joinWithLogicalOperator( + input.map(part => { + if (part === undefined) { + return ''; + } + + return this.#handleRecursiveNotOrAndWithImplicitAndArray(part, handlePart); + }), + logicalOperator, + ); + } + + // if the input is not a plan object, then it can't include Operators. + if (!isPlainObject(input)) { + // @ts-expect-error -- This catches a scenario where the user did not respect the typing + if (!(input instanceof BaseSqlExpression)) { + throw new TypeError(`Invalid Query: expected a plain object, an array or a sequelize SQL method but got ${NodeUtil.inspect(input)} `); + } + + return handlePart(input); + } + + const keys = getComplexKeys(input); + + const sqlArray = keys.map(operatorOrAttribute => { + if (operatorOrAttribute === Op.not) { + const generatedResult = this.#handleRecursiveNotOrAndWithImplicitAndArray( + // @ts-expect-error -- This is a recursive type, which TS does not handle well + input[Op.not] as WhereOptions, + handlePart, + ); + + return wrapWithNot(generatedResult); + } + + if (operatorOrAttribute === Op.and || operatorOrAttribute === Op.or) { + return this.#handleRecursiveNotOrAndWithImplicitAndArray( + // @ts-expect-error -- This is a recursive type, which TS does not handle well + input[operatorOrAttribute], + handlePart, + operatorOrAttribute as typeof Op.and | typeof Op.or, + ); + } + + // it *has* to be an attribute now + if (typeof operatorOrAttribute === 'symbol') { + throw new TypeError(`Invalid Query: ${NodeUtil.inspect(input)} includes the Symbol Operator Op.${operatorOrAttribute.description} but only attributes, Op.and, Op.or, and Op.not are allowed.`); + } + + let pojoWhereObject; + try { + pojoWhereObject = pojoWherePool.getObject(); + + pojoWhereObject.leftOperand = parseAttributeSyntax(operatorOrAttribute); + + // @ts-expect-error -- The type of "operatorOrAttribute" is too dynamic for TS + pojoWhereObject.whereValue = input[operatorOrAttribute]; + + return handlePart(pojoWhereObject); + } finally { + if (pojoWhereObject) { + pojoWherePool.free(pojoWhereObject); + } + } + }); + + return joinWithLogicalOperator(sqlArray, logicalOperator); + } + + /** + * This method is responsible for transforming a group "left operand" + "operators, right operands" (multiple) into a SQL string. + * + * @param pojoWhere The representation of the group. + * @param options Option bag. + */ + formatPojoWhere( + pojoWhere: PojoWhere, + options: FormatWhereOptions = EMPTY_OBJECT, + ): string { + // we need to parse the left operand early to determine the data type of the right operand + let leftDataType = this.#getOperandType(pojoWhere.leftOperand, options.model); + const operandIsJsonColumn = leftDataType == null || leftDataType instanceof DataTypes.JSON; + + return this.#handleRecursiveNotOrAndNestedPathRecursive( + pojoWhere.leftOperand, + pojoWhere.whereValue, + operandIsJsonColumn, + (left: Expression, operator: symbol | undefined, right: Expression) => { + // "left" could have been wrapped in a JSON path. If we still don't know its data type, it's very likely a JSON column + // if the user used a JSON path in the where clause. + if (leftDataType == null && left instanceof JsonPath) { + leftDataType = this.#jsonType; + } else if (left !== pojoWhere.leftOperand) { // if "left" was wrapped in a JSON path, we need to get its data type again as it might have been cast + leftDataType = this.#getOperandType(left, options.model); + } + + if (operator === Op.col) { + noOpCol(); + + right = new Col(right as string); + operator = Op.eq; + } + + // This happens when the user does something like `where: { id: { [Op.any]: { id: 1 } } }` + if (operator === Op.any || operator === Op.all) { + right = { [operator]: right }; + operator = Op.eq; + } + + if (operator == null) { + if (right === null && leftDataType instanceof DataTypes.JSON) { + throw new Error('Because JSON has two possible null values, comparing a JSON/JSONB attribute to NULL requires an explicit comparison operator. Use the `Op.is` operator to compare to SQL NULL, or the `Op.eq` operator to compare to JSON null.'); + } + + operator = Array.isArray(right) && !(leftDataType instanceof DataTypes.ARRAY) ? Op.in + : right === null ? Op.is + : Op.eq; + } + + // backwards compatibility + if (right === null && !(leftDataType instanceof DataTypes.JSON)) { + if (operator === Op.eq) { + operator = Op.is; + } + + if (operator === Op.ne) { + operator = Op.isNot; + } + } + + const rightDataType = this.#getOperandType(right, options.model); + + if (operator in this) { + // @ts-expect-error -- TS does not know that this is a method + return this[operator](left, leftDataType, operator, right, rightDataType, options); + } + + return this.formatBinaryOperation(left, leftDataType, operator, right, rightDataType, options); + }, + ); + } + + protected [Op.notIn](...args: Parameters): string { + return this[Op.in](...args); + } + + protected [Op.in]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + const rightEscapeOptions = { ...options, type: rightDataType ?? leftDataType }; + const leftEscapeOptions = { ...options, type: leftDataType ?? rightDataType }; + + let rightSql: string; + if (right instanceof Literal) { + rightSql = this.queryGenerator.escape(right, rightEscapeOptions); + } else if (Array.isArray(right)) { + if (right.length === 0) { + // NOT IN () does not exist in SQL, so we need to return a condition that is: + // - always false if the operator is IN + // - always true if the operator is NOT IN + if (operator === Op.notIn) { + return ''; + } + + rightSql = '(NULL)'; + } else { + rightSql = this.queryGenerator.escapeList(right, rightEscapeOptions); + } + } else { + throw new TypeError('Operators Op.in and Op.notIn must be called with an array of values, or a literal'); + } + + const leftSql = this.queryGenerator.escape(left, leftEscapeOptions); + + return `${leftSql} ${this.#operatorMap[operator]} ${rightSql}`; + } + + protected [Op.isNot](...args: Parameters): string { + return this[Op.is](...args); + } + + protected [Op.is]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + if (right !== null && typeof right !== 'boolean' && !(right instanceof Literal)) { + throw new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'); + } + + // "IS" operator does not accept bind parameters, only literals + if (options.bindParam) { + options = { + ...options, + bindParam: undefined, + }; + } + + return this.formatBinaryOperation( + left, undefined, operator, right, undefined, options, + ); + } + + protected [Op.notBetween](...args: Parameters): string { + return this[Op.between](...args); + } + + protected [Op.between]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + const rightEscapeOptions = { ...options, type: rightDataType ?? leftDataType }; + const leftEscapeOptions = { ...options, type: leftDataType ?? rightDataType }; + + const leftSql = this.queryGenerator.escape(left, leftEscapeOptions); + + let rightSql: string; + if (right instanceof BaseSqlExpression) { + rightSql = this.queryGenerator.escape(right, rightEscapeOptions); + } else if (Array.isArray(right) && right.length === 2) { + rightSql = `${this.queryGenerator.escape(right[0], rightEscapeOptions)} AND ${this.queryGenerator.escape(right[1], rightEscapeOptions)}`; + } else { + throw new Error('Operators Op.between and Op.notBetween must be used with an array of two values, or a literal.'); + } + + return `${leftSql} ${this.#operatorMap[operator]} ${rightSql}`; + } + + protected [Op.contains]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + // In postgres, Op.contains has multiple signatures: + // - RANGE Op.contains RANGE (both represented by fixed-size arrays in JS) + // - RANGE Op.contains VALUE + // - ARRAY Op.contains ARRAY + // When the left operand is a range RANGE, we must be able to serialize the right operand as either a RANGE or a VALUE. + if (!rightDataType && leftDataType instanceof DataTypes.RANGE && !Array.isArray(right)) { + // This serializes the right operand as a VALUE + return this.formatBinaryOperation( + left, + leftDataType, + operator, + right, + leftDataType.options.subtype, + options, + ); + } + + // This serializes the right operand as a RANGE (or an array for ARRAY contains ARRAY) + return this.formatBinaryOperation(left, leftDataType, operator, right, rightDataType, options); + } + + protected [Op.contained]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + // This function has the opposite semantics of Op.contains. It has the following signatures: + // - RANGE Op.contained RANGE (both represented by fixed-size arrays in JS) + // - VALUE Op.contained RANGE + // - ARRAY Op.contained ARRAY + + // This serializes VALUE contained RANGE + if ( + leftDataType instanceof AbstractDataType + && !(leftDataType instanceof DataTypes.RANGE) + && !(leftDataType instanceof DataTypes.ARRAY) + && Array.isArray(right) + ) { + return this.formatBinaryOperation( + left, + leftDataType, + operator, + right, + new DataTypes.RANGE(leftDataType).toDialectDataType(this.dialect), + options, + ); + } + + // This serializes: + // RANGE contained RANGE + // ARRAY contained ARRAY + return this.formatBinaryOperation(left, leftDataType, operator, right, rightDataType, options); + } + + protected [Op.startsWith]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.like, right, rightDataType, options, false, true); + } + + protected [Op.notStartsWith]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.notLike, right, rightDataType, options, false, true); + } + + protected [Op.endsWith]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.like, right, rightDataType, options, true, false); + } + + protected [Op.notEndsWith]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.notLike, right, rightDataType, options, true, false); + } + + protected [Op.substring]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.like, right, rightDataType, options, true, true); + } + + protected [Op.notSubstring]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + return this.formatSubstring(left, leftDataType, Op.notLike, right, rightDataType, options, true, true); + } + + protected formatSubstring( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + start: boolean, + end: boolean, + ) { + if (typeof right === 'string') { + const startToken = start ? '%' : ''; + const endToken = end ? '%' : ''; + + return this.formatBinaryOperation(left, leftDataType, operator, startToken + right + endToken, rightDataType, options); + } + + const escapedPercent = this.dialect.escapeString('%'); + const literalBuilder: Array = [`CONCAT(`]; + if (start) { + literalBuilder.push(escapedPercent, ', '); + } + + literalBuilder.push(new Value(right)); + + if (end) { + literalBuilder.push(', ', escapedPercent); + } + + literalBuilder.push(')'); + + return this.formatBinaryOperation(left, leftDataType, operator, new Literal(literalBuilder), rightDataType, options); + } + + [Op.anyKeyExists]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + if (!this.#arrayOfTextType) { + throw new Error('This dialect does not support Op.anyKeyExists'); + } + + return this.formatBinaryOperation(left, leftDataType, operator, right, this.#arrayOfTextType, options); + } + + [Op.allKeysExist]( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ): string { + if (!this.#arrayOfTextType) { + throw new Error('This dialect does not support Op.allKeysExist'); + } + + return this.formatBinaryOperation(left, leftDataType, operator, right, this.#arrayOfTextType, options); + } + + protected formatBinaryOperation( + left: Expression, + leftDataType: NormalizedDataType | undefined, + operator: symbol, + right: Expression, + rightDataType: NormalizedDataType | undefined, + options: FormatWhereOptions, + ) { + const operatorSql = this.#operatorMap[operator]; + if (!operatorSql) { + throw new TypeError(`Operator Op.${operator.description} does not exist or is not supported by this dialect.`); + } + + const leftSql = this.queryGenerator.escape(left, { ...options, type: leftDataType ?? rightDataType }); + const rightSql = this.#formatOpAnyAll(right, rightDataType ?? leftDataType) + || this.queryGenerator.escape(right, { ...options, type: rightDataType ?? leftDataType }); + + return `${wrapAmbiguousWhere(left, leftSql)} ${this.#operatorMap[operator]} ${wrapAmbiguousWhere(right, rightSql)}`; + } + + #formatOpAnyAll(value: unknown, type: NormalizedDataType | undefined): string { + if (!isDictionary(value)) { + return ''; + } + + if (Op.any in value) { + return `ANY (${this.#formatOpValues(value[Op.any], type)})`; + } + + if (Op.all in value) { + return `ALL (${this.#formatOpValues(value[Op.all], type)})`; + } + + return ''; + } + + #formatOpValues(value: unknown, type: NormalizedDataType | undefined): string { + if (isDictionary(value) && Op.values in value) { + const options = { type }; + + const operand: unknown[] = Array.isArray(value[Op.values]) + ? value[Op.values] as unknown[] + : [value[Op.values]]; + + const valueSql = operand.map(v => `(${this.queryGenerator.escape(v, options)})`).join(', '); + + return `VALUES ${valueSql}`; + } + + return this.queryGenerator.escape(value, { type: type && new DataTypes.ARRAY(type) }); + } + + /** + * This is the recursive "and", "or" and "not" handler of {@link WhereAttributeHashValue} (the level *after* encountering an attribute name). + * Unlike handleRecursiveNotOrAndWithImplicitAndArray, arrays at the top level have an implicit "IN" operator, instead of an implicit "AND" operator, + * and this method handles nested JSON paths. + * + * @param leftOperand + * @param whereValue + * @param allowJsonPath + * @param handlePart + * @param operator + * @param parentJsonPath + */ + #handleRecursiveNotOrAndNestedPathRecursive( + leftOperand: Expression, + whereValue: WhereAttributeHashValue, + allowJsonPath: boolean, + handlePart: ( + left: Expression, + operator: symbol | undefined, + right: Expression, + ) => string, + operator: typeof Op.and | typeof Op.or = Op.and, + parentJsonPath: ReadonlyArray = EMPTY_ARRAY, + ): string { + if (!isPlainObject(whereValue)) { + return handlePart(this.#wrapSimpleJsonPath(leftOperand, parentJsonPath), undefined, whereValue); + } + + const stringKeys = Object.keys(whereValue); + if (!allowJsonPath && stringKeys.length > 0) { + return handlePart(this.#wrapSimpleJsonPath(leftOperand, parentJsonPath), undefined, whereValue as Expression); + } + + const keys = [...stringKeys, ...getOperators(whereValue)]; + + const parts: string[] = keys.map(key => { + // @ts-expect-error -- this recursive type is too difficult for TS to handle + const value = whereValue[key]; + + // nested JSON path + if (typeof key === 'string') { + // parse path segments & cast syntax + const parsedKey = parseNestedJsonKeySyntax(key); + + // optimization for common simple scenario (to skip replacing leftOperand on every iteration) + if (parsedKey.castsAndModifiers.length === 0) { + return this.#handleRecursiveNotOrAndNestedPathRecursive( + leftOperand, + value, + allowJsonPath, + handlePart, + operator, + [...parentJsonPath, ...parsedKey.pathSegments], + ); + } + + // less optimized scenario: happens when we leave the JSON path (cast to another type or unquote), + // we need to replace leftOperand with the casted value or the unquote operation + const newOperand = this.#wrapComplexJsonPath(leftOperand, parentJsonPath, parsedKey); + + return this.#handleRecursiveNotOrAndNestedPathRecursive( + newOperand, + value, + // TODO: allow JSON if last cast is JSON? + // needs a mechanism to get JS DataType from SQL DataType first. To get last cast: + // newOperand instanceof Cast && isString(newOperand.type) && newOperand.type.toLowerCase(); + false, + handlePart, + operator, + // reset json path + EMPTY_ARRAY, + ); + } + + if (key === Op.not) { + return wrapWithNot( + this.#handleRecursiveNotOrAndNestedPathRecursive( + leftOperand, + value, + allowJsonPath, + handlePart, + Op.and, + ), + ); + } + + if (key === Op.and || key === Op.or) { + if (Array.isArray(value)) { + const sqlParts = value + .map(v => this.#handleRecursiveNotOrAndNestedPathRecursive( + leftOperand, + v, + allowJsonPath, + handlePart, + Op.and, + )); + + return joinWithLogicalOperator(sqlParts, key as typeof Op.and | typeof Op.or); + } + + return this.#handleRecursiveNotOrAndNestedPathRecursive( + leftOperand, + value, + allowJsonPath, + handlePart, + key as typeof Op.and | typeof Op.or, + ); + } + + return handlePart(this.#wrapSimpleJsonPath(leftOperand, parentJsonPath), key, value); + }); + + return joinWithLogicalOperator(parts, operator); + } + + #wrapSimpleJsonPath(operand: Expression, pathSegments: ReadonlyArray): Expression { + if (pathSegments.length === 0) { + return operand; + } + + // merge JSON paths + if (operand instanceof JsonPath) { + return new JsonPath(operand.expression, [...operand.path, ...pathSegments]); + } + + return new JsonPath(operand, pathSegments); + } + + #wrapComplexJsonPath( + operand: Expression, + parentJsonPath: ReadonlyArray, + parsedPath: ParsedJsonPropertyKey, + ): Expression { + const finalPathSegments = parentJsonPath.length > 0 + ? [...parentJsonPath, ...parsedPath.pathSegments] + : parsedPath.pathSegments; + + operand = this.#wrapSimpleJsonPath(operand, finalPathSegments); + + for (const castOrModifier of parsedPath.castsAndModifiers) { + if (isString(castOrModifier)) { + // casts are always strings + operand = new Cast(operand, castOrModifier); + } else { + // modifiers are always classes + operand = new castOrModifier(operand); + } + } + + return operand; + } + + #getOperandType(operand: Expression, model: Nullish): NormalizedDataType | undefined { + if (operand instanceof Cast) { + // TODO: if operand.type is a string (= SQL Type), look up a per-dialect mapping of SQL types to Sequelize types? + return this.dialect.sequelize.normalizeDataType(operand.type); + } + + if (operand instanceof JsonPath) { + // JsonPath can wrap Attributes + return this.#jsonType; + } + + if (!model) { + return undefined; + } + + if (operand instanceof AssociationPath) { + const association = model.modelDefinition.getAssociation(operand.associationPath); + + if (!association) { + return undefined; + } + + return this.#getOperandType(operand.attributeName, association.target); + } + + if (operand instanceof Attribute) { + return model.modelDefinition.attributes.get(operand.attributeName)?.type; + } + + return undefined; + } +} + +export function joinWithLogicalOperator(sqlArray: string[], operator: typeof Op.and | typeof Op.or): string { + const operatorSql = operator === Op.and ? ' AND ' : ' OR '; + + sqlArray = sqlArray.filter(val => Boolean(val)); + + if (sqlArray.length === 0) { + return ''; + } + + if (sqlArray.length === 1) { + return sqlArray[0]; + } + + return sqlArray.map(sql => { + if (/ AND | OR /i.test(sql)) { + return `(${sql})`; + } + + return sql; + }).join(operatorSql); +} + +function wrapWithNot(sql: string): string { + if (!sql) { + return ''; + } + + if (sql.startsWith('(') && sql.endsWith(')')) { + return `NOT ${sql}`; + } + + return `NOT (${sql})`; +} + +export function wrapAmbiguousWhere(operand: Expression, sql: string): string { + // where() can produce ambiguous SQL when used as an operand: + // + // { booleanAttr: where(fn('lower', col('name')), Op.is, null) } + // produces the ambiguous SQL: + // [booleanAttr] = lower([name]) IS NULL + // which is better written as: + // [booleanAttr] = (lower([name]) IS NULL) + if (operand instanceof Where && sql.includes(' ')) { + return `(${sql})`; + } + + return sql; +} diff --git a/packages/core/src/dialects/db2/query-generator.js b/packages/core/src/dialects/db2/query-generator.js index 037d1c83e807..bbbd5dc54458 100644 --- a/packages/core/src/dialects/db2/query-generator.js +++ b/packages/core/src/dialects/db2/query-generator.js @@ -1,9 +1,8 @@ 'use strict'; -import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; import { rejectInvalidOptions } from '../../utils/check'; import { removeNullishValuesFromHash } from '../../utils/format'; -import { removeTrailingSemicolon, underscore } from '../../utils/string'; +import { removeTrailingSemicolon } from '../../utils/string'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; import { @@ -31,10 +30,9 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { constructor(options) { super(options); - this.OperatorMap = { - ...this.OperatorMap, [Op.regexp]: 'REGEXP_LIKE', - [Op.notRegexp]: 'NOT REGEXP_LIKE', - }; + this.whereSqlBuilder.setOperatorKeyword(Op.regexp, 'REGEXP_LIKE'); + this.whereSqlBuilder.setOperatorKeyword(Op.notRegexp, 'NOT REGEXP_LIKE'); + this.autoGenValue = 1; } @@ -107,7 +105,7 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { const commentText = commentMatch[2].replace(/COMMENT/, '').trim(); commentStr += _.template(commentTemplate, this._templateSettings)({ table: this.quoteTable(tableName), - comment: this.escape(commentText, undefined, { replacements: options.replacements }), + comment: this.escape(commentText, { replacements: options.replacements }), column: this.quoteIdentifier(attr), }); // remove comment related substring from dataType @@ -380,7 +378,8 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { if (allAttributes.length > 0) { _.forEach(attrValueHashes, attrValueHash => { tuples.push(`(${ - allAttributes.map(key => this.escape(attrValueHash[key], undefined, { context: 'INSERT', replacements: options.replacements })).join(',')})`); + // TODO: pass type of attribute & model + allAttributes.map(key => this.escape(attrValueHash[key] ?? null, { replacements: options.replacements })).join(',')})`); }); allQueries.push(query); } @@ -424,13 +423,15 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { } for (const key in attrValueHash) { - const value = attrValueHash[key]; + const value = attrValueHash[key] ?? null; + const escapedValue = this.escape(value, { + // TODO: pass model + type: modelAttributeMap[key]?.type, + replacements: options.replacements, + bindParam, + }); - if (value instanceof BaseSqlExpression || options.bindParam === false) { - values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); - } else { - values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements }, bindParam)}`); - } + values.push(`${this.quoteIdentifier(key)}=${escapedValue}`); } let query; @@ -450,8 +451,9 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { const uniqueAttrs = []; const tableNameQuoted = this.quoteTable(tableName); + const modelDefinition = model.modelDefinition; // Obtain primaryKeys, uniquekeys and identity attrs from rawAttributes as model is not passed - const attributes = model.modelDefinition.attributes; + const attributes = modelDefinition.attributes; for (const attribute of attributes.values()) { if (attribute.primaryKey) { primaryKeysColumns.push(attribute.columnName); @@ -478,7 +480,14 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { const updateKeys = Object.keys(updateValues); const insertKeys = Object.keys(insertValues); const insertKeysQuoted = insertKeys.map(key => this.quoteIdentifier(key)).join(', '); - const insertValuesEscaped = insertKeys.map(key => this.escape(insertValues[key], undefined, { replacements: options.replacements })).join(', '); + const insertValuesEscaped = insertKeys.map(key => { + return this.escape(insertValues[key], { + // TODO: pass type + // TODO: bind param + replacements: options.replacements, + model, + }); + }).join(', '); const sourceTableQuery = `VALUES(${insertValuesEscaped})`; // Virtual Table let joinCondition; @@ -516,7 +525,9 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { // Search for primary key attribute in clauses -- Model can have two separate unique keys for (const key in clauses) { const keys = Object.keys(clauses[key]); - if (primaryKeysColumns.includes(keys[0])) { + const columnName = modelDefinition.getColumnNameLoose(keys[0]); + + if (primaryKeysColumns.includes(columnName)) { joinCondition = getJoinSnippet(primaryKeysColumns).join(' AND '); break; } @@ -557,23 +568,16 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { deleteQuery(tableName, where, options = {}, model) { const table = this.quoteTable(tableName); + let query = `DELETE FROM ${table}`; - let whereStr = this.getWhereConditions(where, null, model, options); - if (whereStr) { - whereStr = ` WHERE ${whereStr}`; + const whereSql = this.whereQuery(where, { ...options, model }); + if (whereSql) { + query += ` ${whereSql}`; } - let query = `DELETE FROM ${table} ${whereStr}`; + query += this.addLimitAndOffset(options); - if (options.offset > 0) { - query += ` OFFSET ${this.escape(options.offset, undefined, { replacements: options.replacements })} ROWS`; - } - - if (options.limit) { - query += ` FETCH NEXT ${this.escape(options.limit, undefined, { replacements: options.replacements })} ROWS ONLY`; - } - - return query.trim(); + return query; } addIndexQuery(tableName, attributes, options, rawTablename) { @@ -633,7 +637,7 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { // Blobs/texts cannot have a defaultValue if (attribute.type !== 'TEXT' && attribute.type._binary !== true && defaultValueSchemable(attribute.defaultValue)) { - template += ` DEFAULT ${this.escape(attribute.defaultValue, undefined, { replacements: options?.replacements })}`; + template += ` DEFAULT ${this.escape(attribute.defaultValue, { replacements: options?.replacements, type: attribute.type })}`; } if (attribute.unique === true && (options?.context !== 'changeColumn' || this.dialect.supports.alterColumn.unique)) { @@ -873,20 +877,16 @@ export class Db2QueryGenerator extends Db2QueryGeneratorTypeScript { let fragment = ''; if (offset) { - fragment += ` OFFSET ${this.escape(offset, undefined, { replacements: options.replacements })} ROWS`; + fragment += ` OFFSET ${this.escape(offset, { replacements: options.replacements })} ROWS`; } if (options.limit) { - fragment += ` FETCH NEXT ${this.escape(options.limit, undefined, { replacements: options.replacements })} ROWS ONLY`; + fragment += ` FETCH NEXT ${this.escape(options.limit, { replacements: options.replacements })} ROWS ONLY`; } return fragment; } - booleanValue(value) { - return value ? 1 : 0; - } - addUniqueFields(dataValues, rawAttributes, uniqno) { uniqno = uniqno === undefined ? 1 : uniqno; for (const key in rawAttributes) { diff --git a/packages/core/src/dialects/ibmi/query-generator.js b/packages/core/src/dialects/ibmi/query-generator.js index 11eb7a1eab2f..d3b8fcd1ac7d 100644 --- a/packages/core/src/dialects/ibmi/query-generator.js +++ b/packages/core/src/dialects/ibmi/query-generator.js @@ -1,9 +1,6 @@ 'use strict'; -import { underscore } from 'inflection'; import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; -import { Cast } from '../../expression-builders/cast.js'; -import { Json } from '../../expression-builders/json.js'; import { conformIndex } from '../../model-internals'; import { rejectInvalidOptions } from '../../utils/check'; import { addTicks } from '../../utils/dialect'; @@ -248,106 +245,6 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { return `ALTER TABLE ${this.quoteTable(tableName)} RENAME COLUMN ${attrString.join(', ')};`; } - handleSequelizeMethod(smth, tableName, factory, options, prepend) { - if (smth instanceof Json) { - // Parse nested object - if (smth.conditions) { - const conditions = this.parseConditionObject(smth.conditions).map(condition => `${this.quoteIdentifier(condition.path[0])}->>'$.${_.tail(condition.path).join('.')}' = '${condition.value}'`); - - return conditions.join(' and '); - } - - if (smth.path) { - let str; - - // Allow specifying conditions using the sqlite json functions - if (this._checkValidJsonStatement(smth.path)) { - str = smth.path; - } else { - // Also support json dot notation - let path = smth.path; - let startWithDot = true; - - // Convert .number. to [number]. - path = path.replace(/\.(\d+)\./g, '[$1].'); - // Convert .number$ to [number] - path = path.replace(/\.(\d+)$/, '[$1]'); - - path = path.split('.'); - - let columnName = path.shift(); - const match = columnName.match(/\[\d+\]$/); - // If columnName ends with [\d+] - if (match !== null) { - path.unshift(columnName.slice(match.index)); - columnName = columnName.slice(0, Math.max(0, match.index)); - startWithDot = false; - } - - str = `${this.quoteIdentifier(columnName)}->>'$${startWithDot ? '.' : ''}${path.join('.')}'`; - } - - if (smth.value) { - str += util.format(' = %s', this.escape(smth.value, undefined, { replacements: options.replacements })); - } - - return str; - } - } else if (smth instanceof Cast) { - if (/timestamp/i.test(smth.type)) { - smth.type = 'timestamp'; - } else if (smth.json && /boolean/i.test(smth.type)) { - // true or false cannot be casted as booleans within a JSON structure - smth.type = 'char'; - } else if (/double precision/i.test(smth.type) || /boolean/i.test(smth.type) || /integer/i.test(smth.type)) { - smth.type = 'integer'; - } else if (/text/i.test(smth.type)) { - smth.type = 'char'; - } - } - - return super.handleSequelizeMethod(smth, tableName, factory, options, prepend); - } - - escape(value, attribute, options) { - if (value instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(value, undefined, undefined, { replacements: options.replacements }); - } - - if (value == null || attribute?.type == null || typeof attribute.type === 'string') { - const format = (value === null && options.where); - - // use default escape mechanism instead of the DataType's. - return SqlString.escape(value, this.dialect, format); - } - - if (!attribute.type.belongsToDialect(this.dialect)) { - attribute = { - ...attribute, - type: attribute.type.toDialectDataType(this.dialect), - }; - } - - if (options.isList && Array.isArray(value)) { - const escapeOptions = { ...options, isList: false }; - - return `(${value.map(valueItem => { - return this.escape(valueItem, attribute, escapeOptions); - }).join(', ')})`; - } - - this.validate(value, attribute); - - return attribute.type.escape(value, { - // Users shouldn't have to worry about these args - just give them a function that takes a single arg - escape: this.simpleEscape, - field: attribute, - timezone: this.options.timezone, - operation: options.operation, - dialect: this.dialect, - }); - } - /* Returns an add index query. Parameters: @@ -387,7 +284,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { } if (field instanceof BaseSqlExpression) { - return this.handleSequelizeMethod(field); + return this.formatSqlExpression(field); } let result = ''; @@ -461,20 +358,6 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { return query.replace(/;$/, ''); } - // _toJSONValue(value) { - // // true/false are stored as strings in mysql - // if (typeof value === 'boolean') { - // return value.toString(); - // } - - // // null is stored as a string in mysql - // if (value === null) { - // return 'null'; - // } - - // return value; - // } - updateQuery(tableName, attrValueHash, where, options, columnDefinitions) { const out = super.updateQuery(tableName, attrValueHash, where, options, columnDefinitions); @@ -543,10 +426,9 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { deleteQuery(tableName, where, options = {}, model) { let query = `DELETE FROM ${this.quoteTable(tableName)}`; - where = this.getWhereConditions(where, null, model, options); - - if (where) { - query += ` WHERE ${where}`; + const whereSql = this.whereQuery(where, { ...options, model }); + if (whereSql) { + query += ` ${whereSql}`; } if (options.offset || options.limit) { @@ -567,11 +449,11 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { let fragment = ''; if (options.offset) { - fragment += ` OFFSET ${this.escape(options.offset, undefined, options)} ROWS`; + fragment += ` OFFSET ${this.escape(options.offset, options)} ROWS`; } if (options.limit) { - fragment += ` FETCH NEXT ${this.escape(options.limit, undefined, options)} ROWS ONLY`; + fragment += ` FETCH NEXT ${this.escape(options.limit, options)} ROWS ONLY`; } return fragment; @@ -630,7 +512,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { } template += ` CHECK (${this.quoteIdentifier(attribute.field)} IN(${attribute.type.options.values.map(value => { - return this.escape(value, undefined, { replacements: options?.replacements }); + return this.escape(value); }).join(', ')}))`; } else { template = attributeTypeToSql(attribute.type, { dialect: this.dialect }); @@ -656,7 +538,7 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { attribute.defaultValue = 0; } - template += ` DEFAULT ${this.escape(attribute.defaultValue, undefined, { replacements: options?.replacements })}`; + template += ` DEFAULT ${this.escape(attribute.defaultValue)}`; } if (attribute.unique === true && !attribute.primaryKey) { @@ -800,14 +682,6 @@ export class IBMiQueryGenerator extends IBMiQueryGeneratorTypeScript { return `ALTER TABLE ${this.quoteTable(tableName)} DROP FOREIGN KEY ${this.quoteIdentifier(foreignKey)};`; } - - booleanValue(value) { - if (value) { - return 1; - } - - return 0; - } } /** diff --git a/packages/core/src/dialects/mariadb/data-types.ts b/packages/core/src/dialects/mariadb/data-types.ts index faecd0881e0f..4ad2a3fdc4cf 100644 --- a/packages/core/src/dialects/mariadb/data-types.ts +++ b/packages/core/src/dialects/mariadb/data-types.ts @@ -1 +1,5 @@ -export * from '../mysql/data-types.js'; +export { BIGINT, DATE, BOOLEAN, DECIMAL, DOUBLE, FLOAT, ENUM, INTEGER, GEOMETRY, MEDIUMINT, SMALLINT, UUID, TINYINT, REAL } from '../mysql/data-types.js'; + +// Unlike MySQL, MariaDB does not need to cast JSON values when comparing them to other values, +// so we do not need that override. +export { JSON } from '../abstract/data-types.js'; diff --git a/packages/core/src/dialects/mariadb/query-generator-typescript.ts b/packages/core/src/dialects/mariadb/query-generator-typescript.ts index 0ed982d2025f..faa62f781ca6 100644 --- a/packages/core/src/dialects/mariadb/query-generator-typescript.ts +++ b/packages/core/src/dialects/mariadb/query-generator-typescript.ts @@ -42,4 +42,19 @@ export class MariaDbQueryGeneratorTypeScript extends MySqlQueryGenerator { this.quoteTable(tableName), ]); } + + jsonPathExtractionQuery(sqlExpression: string, path: ReadonlyArray, unquote: boolean): string { + const sql = super.jsonPathExtractionQuery(sqlExpression, path, unquote); + + if (unquote) { + return sql; + } + + // MariaDB has a very annoying behavior with json_extract: It returns the JSON value as a proper JSON string (e.g. "true" or "null" instead true or null) + // Except if the value is going to be used in a comparison, in which case it unquotes it automatically (even if we did not call JSON_UNQUOTE). + // This is a problem because it makes it impossible to distinguish between a JSON text "true" and a JSON boolean true. + // This useless function call is here to make mariadb not think the value will be used in a comparison, and thus not unquote it. + // We could replace it with a custom function that does nothing, but this would require a custom function to be created on the database ahead of time. + return `json_compact(${sql})`; + } } diff --git a/packages/core/src/dialects/mariadb/query-generator.js b/packages/core/src/dialects/mariadb/query-generator.js index f968aa51bfd8..d09fd0913a39 100644 --- a/packages/core/src/dialects/mariadb/query-generator.js +++ b/packages/core/src/dialects/mariadb/query-generator.js @@ -47,25 +47,4 @@ export class MariaDbQueryGenerator extends MariaDbQueryGeneratorTypeScript { ';', ]); } - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} column The JSON column - * @param {string|Array} [path] The path to extract (optional) - * @returns {string} The generated sql query - * @private - */ - jsonPathExtractionQuery(column, path) { - const quotedColumn = this.isIdentifierQuoted(column) - ? column - : this.quoteIdentifier(column); - - const pathStr = this.escape(['$'] - .concat(_.toPath(path)) - .join('.') - .replace(/\.(\d+)(?:(?=\.)|$)/g, (__, digit) => `[${digit}]`)); - - return `json_unquote(json_extract(${quotedColumn},${pathStr}))`; - } } diff --git a/packages/core/src/dialects/mssql/query-generator.js b/packages/core/src/dialects/mssql/query-generator.js index eb2072071b7d..4e56b4412db9 100644 --- a/packages/core/src/dialects/mssql/query-generator.js +++ b/packages/core/src/dialects/mssql/query-generator.js @@ -419,7 +419,12 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { if (allAttributes.length > 0) { for (const attrValueHash of attrValueHashes) { tuples.push(`(${ - allAttributes.map(key => this.escape(attrValueHash[key], undefined, options)).join(',') + allAttributes.map(key => { + // TODO: pass "type" + // TODO: bindParam + // TODO: pass "model" + return this.escape(attrValueHash[key] ?? null, options); + }).join(',') })`); } @@ -494,7 +499,10 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { const updateKeys = Object.keys(updateValues); const insertKeys = Object.keys(insertValues); const insertKeysQuoted = insertKeys.map(key => this.quoteIdentifier(key)).join(', '); - const insertValuesEscaped = insertKeys.map(key => this.escape(insertValues[key], undefined, options)).join(', '); + const insertValuesEscaped = insertKeys.map(key => { + // TODO: pass "model", "type" and "bindParam" options + return this.escape(insertValues[key], options); + }).join(', '); const sourceTableQuery = `VALUES(${insertValuesEscaped})`; // Virtual Table let joinCondition; @@ -543,7 +551,9 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { // Search for primary key attribute in clauses -- Model can have two separate unique keys for (const key in clauses) { const keys = Object.keys(clauses[key]); - if (primaryKeysColumns.includes(keys[0])) { + const columnName = modelDefinition.getColumnNameLoose(keys[0]); + + if (primaryKeysColumns.includes(columnName)) { joinCondition = getJoinSnippet(primaryKeysColumns).join(' AND '); break; } @@ -581,14 +591,16 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { deleteQuery(tableName, where, options = {}, model) { const table = this.quoteTable(tableName); - const whereClause = this.getWhereConditions(where, null, model, options); + + const escapeOptions = { ...options, model }; + const whereClause = this.whereQuery(where, escapeOptions); return joinSQLFragments([ 'DELETE', - options.limit && `TOP(${this.escape(options.limit, undefined, options)})`, + options.limit && `TOP(${this.escape(options.limit, escapeOptions)})`, 'FROM', table, - whereClause && `WHERE ${whereClause}`, + whereClause, ';', 'SELECT @@ROWCOUNT AS AFFECTEDROWS', ';', @@ -620,7 +632,7 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { // enums are a special case template = attribute.type.toSql({ dialect: this.dialect }); template += ` CHECK (${this.quoteIdentifier(attribute.field)} IN(${attribute.type.options.values.map(value => { - return this.escape(value, undefined, options); + return this.escape(value, options); }).join(', ')}))`; return template; @@ -641,7 +653,7 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { // Blobs/texts cannot have a defaultValue if (attribute.type !== 'TEXT' && attribute.type._binary !== true && defaultValueSchemable(attribute.defaultValue)) { - template += ` DEFAULT ${this.escape(attribute.defaultValue, attribute, options)}`; + template += ` DEFAULT ${this.escape(attribute.defaultValue, { ...options, type: attribute.type })}`; } if (attribute.unique === true && (options?.context !== 'changeColumn' || this.dialect.supports.alterColumn.unique)) { @@ -918,7 +930,7 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { const value = Array.isArray(order) ? order[0] : order; if (value instanceof Col) { - return value.col; + return value.identifiers[0]; } if (value instanceof Literal) { @@ -939,20 +951,16 @@ export class MsSqlQueryGenerator extends MsSqlQueryGeneratorTypeScript { } if (options.offset || options.limit) { - fragment += ` OFFSET ${this.escape(offset, undefined, options)} ROWS`; + fragment += ` OFFSET ${this.escape(offset, options)} ROWS`; } if (options.limit) { - fragment += ` FETCH NEXT ${this.escape(options.limit, undefined, options)} ROWS ONLY`; + fragment += ` FETCH NEXT ${this.escape(options.limit, options)} ROWS ONLY`; } } return fragment; } - - booleanValue(value) { - return value ? 1 : 0; - } } /** diff --git a/packages/core/src/dialects/mysql/data-types.ts b/packages/core/src/dialects/mysql/data-types.ts index 0d3aed849d51..2472a23bf519 100644 --- a/packages/core/src/dialects/mysql/data-types.ts +++ b/packages/core/src/dialects/mysql/data-types.ts @@ -109,6 +109,19 @@ export class DATE extends BaseTypes.DATE { } } +export class JSON extends BaseTypes.JSON { + escape(value: any): string { + // In MySQL, JSON cannot be directly compared to a text, we need to cast it to JSON + // This is not necessary for the values of INSERT & UPDATE statements, so we could omit this + // if we add context to the escape & getBindParamSql methods + return `CAST(${super.escape(value)} AS JSON)`; + } + + getBindParamSql(value: any, options: BindParamOptions): string { + return `CAST(${super.getBindParamSql(value, options)} AS JSON)`; + } +} + export class UUID extends BaseTypes.UUID { // TODO: add check constraint to enforce GUID format toSql() { diff --git a/packages/core/src/dialects/mysql/mysql-utils.ts b/packages/core/src/dialects/mysql/mysql-utils.ts index 332097919a63..2b6a9069be10 100644 --- a/packages/core/src/dialects/mysql/mysql-utils.ts +++ b/packages/core/src/dialects/mysql/mysql-utils.ts @@ -1,6 +1,6 @@ export function escapeMysqlString(value: string): string { // eslint-disable-next-line no-control-regex -- \u001A is intended to be in this regex - value = value.replace(/[\b\0\t\n\r\u001A"'\\]/g, s => { + value = value.replace(/[\b\0\t\n\r\u001A'\\]/g, s => { switch (s) { case '\0': return '\\0'; case '\n': return '\\n'; diff --git a/packages/core/src/dialects/mysql/query-generator-typescript.ts b/packages/core/src/dialects/mysql/query-generator-typescript.ts index d874914c6cac..6811eff4bf05 100644 --- a/packages/core/src/dialects/mysql/query-generator-typescript.ts +++ b/packages/core/src/dialects/mysql/query-generator-typescript.ts @@ -1,8 +1,10 @@ +import { Op } from '../../operators.js'; +import type { Expression } from '../../sequelize.js'; import { rejectInvalidOptions } from '../../utils/check'; import { generateIndexName } from '../../utils/string'; import { AbstractQueryGenerator } from '../abstract/query-generator'; import { REMOVE_INDEX_QUERY_SUPPORTABLE_OPTIONS } from '../abstract/query-generator-typescript'; -import type { RemoveIndexQueryOptions, TableNameOrModel } from '../abstract/query-generator-typescript'; +import type { RemoveIndexQueryOptions, TableNameOrModel, QueryGeneratorOptions, EscapeOptions } from '../abstract/query-generator-typescript'; const REMOVE_INDEX_QUERY_SUPPORTED_OPTIONS = new Set(); @@ -10,6 +12,13 @@ const REMOVE_INDEX_QUERY_SUPPORTED_OPTIONS = new Set, unquote: boolean): string { + let jsonPathStr = '$'; + for (const pathElement of path) { + if (typeof pathElement === 'number') { + jsonPathStr += `[${pathElement}]`; + } else { + jsonPathStr += `.${this.#quoteJsonPathIdentifier(pathElement)}`; + } + } + + const extractQuery = `json_extract(${sqlExpression},${this.escape(jsonPathStr)})`; + if (unquote) { + return `json_unquote(${extractQuery})`; + } + + return extractQuery; + } + + formatUnquoteJson(arg: Expression, options?: EscapeOptions) { + return `json_unquote(${this.escape(arg, options)})`; + } + + #quoteJsonPathIdentifier(identifier: string): string { + if (/^[a-z_][a-z0-9_]*$/i.test(identifier)) { + return identifier; + } + + // Escape backslashes and double quotes + return `"${identifier.replace(/["\\]/g, s => `\\${s}`)}"`; + } } diff --git a/packages/core/src/dialects/mysql/query-generator.js b/packages/core/src/dialects/mysql/query-generator.js index c6cbdf575d10..0e11a3ccbe72 100644 --- a/packages/core/src/dialects/mysql/query-generator.js +++ b/packages/core/src/dialects/mysql/query-generator.js @@ -1,13 +1,10 @@ 'use strict'; -import { Cast } from '../../expression-builders/cast.js'; -import { Json } from '../../expression-builders/json.js'; import { rejectInvalidOptions } from '../../utils/check'; import { addTicks } from '../../utils/dialect'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; -import { underscore } from '../../utils/string'; import { attributeTypeToSql, normalizeDataType } from '../abstract/data-types-utils'; import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, @@ -41,16 +38,6 @@ const ADD_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set(); const REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set(); export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { - constructor(options) { - super(options); - - this.OperatorMap = { - ...this.OperatorMap, - [Op.regexp]: 'REGEXP', - [Op.notRegexp]: 'NOT REGEXP', - }; - } - createSchemaQuery(schemaName, options) { return joinSQLFragments([ 'CREATE SCHEMA IF NOT EXISTS', @@ -280,64 +267,6 @@ export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { ]); } - handleSequelizeMethod(smth, tableName, factory, options, prepend) { - if (smth instanceof Json) { - // Parse nested object - if (smth.conditions) { - const conditions = this.parseConditionObject(smth.conditions).map(condition => `${this.jsonPathExtractionQuery(condition.path[0], _.tail(condition.path))} = '${condition.value}'`); - - return conditions.join(' AND '); - } - - if (smth.path) { - let str; - - // Allow specifying conditions using the sqlite json functions - if (this._checkValidJsonStatement(smth.path)) { - str = smth.path; - } else { - // Also support json property accessors - const paths = _.toPath(smth.path); - const column = paths.shift(); - str = this.jsonPathExtractionQuery(column, paths); - } - - if (smth.value) { - str += ` = ${this.escape(smth.value, undefined, options)}`; - } - - return str; - } - } else if (smth instanceof Cast) { - if (/timestamp/i.test(smth.type)) { - smth.type = 'datetime'; - } else if (smth.json && /boolean/i.test(smth.type)) { - // true or false cannot be casted as booleans within a JSON structure - smth.type = 'char'; - } else if (/double precision/i.test(smth.type) || /boolean/i.test(smth.type) || /integer/i.test(smth.type)) { - smth.type = 'decimal'; - } else if (/text/i.test(smth.type)) { - smth.type = 'char'; - } - } - - return super.handleSequelizeMethod(smth, tableName, factory, options, prepend); - } - - _toJSONValue(value) { - // true/false are stored as strings in mysql - if (typeof value === 'boolean') { - return value.toString(); - } - - // null is stored as a string in mysql - if (value === null) { - return 'null'; - } - - return value; - } - truncateTableQuery(tableName) { return `TRUNCATE ${this.quoteTable(tableName)}`; } @@ -345,13 +274,14 @@ export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { let query = `DELETE FROM ${this.quoteTable(tableName)}`; - where = this.getWhereConditions(where, null, model, options); - if (where) { - query += ` WHERE ${where}`; + const escapeOptions = { ...options, model }; + const whereSql = this.whereQuery(where, escapeOptions); + if (whereSql) { + query += ` ${whereSql}`; } if (options.limit) { - query += ` LIMIT ${this.escape(options.limit, undefined, _.pick(options, ['bind', 'replacements']))}`; + query += ` LIMIT ${this.escape(options.limit, escapeOptions)}`; } return query; @@ -459,70 +389,6 @@ export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { return result; } - /** - * Check whether the statement is json function or simple path - * - * @param {string} stmt The statement to validate - * @returns {boolean} true if the given statement is json function - * @throws {Error} throw if the statement looks like json function but has invalid token - * @private - */ - _checkValidJsonStatement(stmt) { - if (typeof stmt !== 'string') { - return false; - } - - let currentIndex = 0; - let openingBrackets = 0; - let closingBrackets = 0; - let hasJsonFunction = false; - let hasInvalidToken = false; - - while (currentIndex < stmt.length) { - const string = stmt.slice(currentIndex); - const functionMatches = JSON_FUNCTION_REGEX.exec(string); - if (functionMatches) { - currentIndex += functionMatches[0].indexOf('('); - hasJsonFunction = true; - continue; - } - - const operatorMatches = JSON_OPERATOR_REGEX.exec(string); - if (operatorMatches) { - currentIndex += operatorMatches[0].length; - hasJsonFunction = true; - continue; - } - - const tokenMatches = TOKEN_CAPTURE_REGEX.exec(string); - if (tokenMatches) { - const capturedToken = tokenMatches[1]; - - if (capturedToken === '(') { - openingBrackets++; - } else if (capturedToken === ')') { - closingBrackets++; - } else if (capturedToken === ';') { - hasInvalidToken = true; - break; - } - - currentIndex += tokenMatches[0].length; - continue; - } - - break; - } - - // Check invalid json statement - if (hasJsonFunction && (hasInvalidToken || openingBrackets !== closingBrackets)) { - throw new Error(`Invalid json statement: ${stmt}`); - } - - // return true if the statement has valid json function - return hasJsonFunction; - } - /** * Generates an SQL query that returns all foreign keys of a table. * @@ -596,38 +462,6 @@ export class MySqlQueryGenerator extends MySqlQueryGeneratorTypeScript { ';', ]); } - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} column The JSON column - * @param {string|Array} [path] The path to extract (optional) - * @returns {string} The generated sql query - * @private - */ - jsonPathExtractionQuery(column, path) { - let paths = _.toPath(path); - const quotedColumn = this.isIdentifierQuoted(column) - ? column - : this.quoteIdentifier(column); - - /** - * Non digit sub paths need to be quoted as ECMAScript identifiers - * https://bugs.mysql.com/bug.php?id=81896 - */ - paths = paths.map(subPath => { - return /\D/.test(subPath) - ? addTicks(subPath, '"') - : subPath; - }); - - const pathStr = this.escape(['$'] - .concat(paths) - .join('.') - .replace(/\.(\d+)(?:(?=\.)|$)/g, (__, digit) => `[${digit}]`)); - - return `json_unquote(json_extract(${quotedColumn},${pathStr}))`; - } } /** diff --git a/packages/core/src/dialects/postgres/data-types.ts b/packages/core/src/dialects/postgres/data-types.ts index 598848703708..2bcb96099712 100644 --- a/packages/core/src/dialects/postgres/data-types.ts +++ b/packages/core/src/dialects/postgres/data-types.ts @@ -324,7 +324,7 @@ export class RANGE> extends BaseTypes. const mappedValues = isString(type) ? values : values.map(value => type.escape(value)); - return `ARRAY[${mappedValues.join(',')}]::${attributeTypeToSql(type)}[]`; + // Types that don't need to specify their cast + const unambiguousType = type instanceof BaseTypes.STRING + || type instanceof BaseTypes.TEXT + || type instanceof BaseTypes.INTEGER; + + const cast = mappedValues.length === 0 || !unambiguousType ? `::${attributeTypeToSql(type)}[]` : ''; + + return `ARRAY[${mappedValues.join(',')}]${cast}`; } getBindParamSql( diff --git a/packages/core/src/dialects/postgres/query-generator-typescript.ts b/packages/core/src/dialects/postgres/query-generator-typescript.ts index 8f4f0e1d6ed7..9470712814c9 100644 --- a/packages/core/src/dialects/postgres/query-generator-typescript.ts +++ b/packages/core/src/dialects/postgres/query-generator-typescript.ts @@ -1,7 +1,8 @@ +import type { Expression } from '../../sequelize.js'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { generateIndexName } from '../../utils/string'; import { AbstractQueryGenerator } from '../abstract/query-generator'; -import type { RemoveIndexQueryOptions, TableNameOrModel } from '../abstract/query-generator-typescript'; +import type { RemoveIndexQueryOptions, TableNameOrModel, EscapeOptions } from '../abstract/query-generator-typescript'; /** * Temporary class to ease the TypeScript migration @@ -75,4 +76,23 @@ export class PostgresQueryGeneratorTypeScript extends AbstractQueryGenerator { options?.cascade ? 'CASCADE' : '', ]); } + + jsonPathExtractionQuery(sqlExpression: string, path: ReadonlyArray, unquote: boolean): string { + const operator = path.length === 1 + ? (unquote ? '->>' : '->') + : (unquote ? '#>>' : '#>'); + + const pathSql = path.length === 1 + // when accessing an array index with ->, the index must be a number + // when accessing an object key with ->, the key must be a string + ? this.escape(path[0]) + // when accessing with #>, the path is always an array of strings + : this.escape(path.map(value => String(value))); + + return sqlExpression + operator + pathSql; + } + + formatUnquoteJson(arg: Expression, options?: EscapeOptions) { + return `${this.escape(arg, options)}#>>ARRAY[]::TEXT[]`; + } } diff --git a/packages/core/src/dialects/postgres/query-generator.js b/packages/core/src/dialects/postgres/query-generator.js index 88ed87a68e03..508e76412318 100644 --- a/packages/core/src/dialects/postgres/query-generator.js +++ b/packages/core/src/dialects/postgres/query-generator.js @@ -1,6 +1,5 @@ 'use strict'; -import { Json } from '../../expression-builders/json.js'; import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { generateIndexName } from '../../utils/string'; @@ -181,107 +180,6 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { return `SELECT table_name FROM information_schema.tables WHERE table_schema = ${this.escape(schema)} AND table_name = ${this.escape(table)}`; } - /** - * Check whether the statmement is json function or simple path - * - * @param {string} stmt The statement to validate - * @returns {boolean} true if the given statement is json function - * @throws {Error} throw if the statement looks like json function but has invalid token - */ - _checkValidJsonStatement(stmt) { - if (typeof stmt !== 'string') { - return false; - } - - // https://www.postgresql.org/docs/current/static/functions-json.html - const jsonFunctionRegex = /^\s*((?:[a-z]+_){0,2}jsonb?(?:_[a-z]+){0,2})\([^)]*\)/i; - const jsonOperatorRegex = /^\s*(->>?|#>>?|@>|<@|\?[&|]?|\|{2}|#-)/i; - const tokenCaptureRegex = /^\s*((?:(["'`])(?:(?!\2).|\2{2})*\2)|[\s\w]+|[()+,.;-])/i; - - let currentIndex = 0; - let openingBrackets = 0; - let closingBrackets = 0; - let hasJsonFunction = false; - let hasInvalidToken = false; - - while (currentIndex < stmt.length) { - const string = stmt.slice(currentIndex); - const functionMatches = jsonFunctionRegex.exec(string); - if (functionMatches) { - currentIndex += functionMatches[0].indexOf('('); - hasJsonFunction = true; - continue; - } - - const operatorMatches = jsonOperatorRegex.exec(string); - if (operatorMatches) { - currentIndex += operatorMatches[0].length; - hasJsonFunction = true; - continue; - } - - const tokenMatches = tokenCaptureRegex.exec(string); - if (tokenMatches) { - const capturedToken = tokenMatches[1]; - if (capturedToken === '(') { - openingBrackets++; - } else if (capturedToken === ')') { - closingBrackets++; - } else if (capturedToken === ';') { - hasInvalidToken = true; - break; - } - - currentIndex += tokenMatches[0].length; - continue; - } - - break; - } - - // Check invalid json statement - hasInvalidToken |= openingBrackets !== closingBrackets; - if (hasJsonFunction && hasInvalidToken) { - throw new Error(`Invalid json statement: ${stmt}`); - } - - // return true if the statement has valid json function - return hasJsonFunction; - } - - handleSequelizeMethod(smth, tableName, factory, options, prepend) { - if (smth instanceof Json) { - // Parse nested object - if (smth.conditions) { - const conditions = this.parseConditionObject(smth.conditions).map(condition => `${this.jsonPathExtractionQuery(condition.path[0], _.tail(condition.path))} = '${condition.value}'`); - - return conditions.join(' AND '); - } - - if (smth.path) { - let str; - - // Allow specifying conditions using the postgres json syntax - if (this._checkValidJsonStatement(smth.path)) { - str = smth.path; - } else { - // Also support json property accessors - const paths = _.toPath(smth.path); - const column = paths.shift(); - str = this.jsonPathExtractionQuery(column, paths); - } - - if (smth.value) { - str += util.format(' = %s', this.escape(smth.value)); - } - - return str; - } - } - - return super.handleSequelizeMethod.call(this, smth, tableName, factory, options, prepend); - } - addColumnQuery(table, key, attribute, options) { options = options || {}; @@ -390,13 +288,19 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { const table = this.quoteTable(tableName); - let whereClause = this.getWhereConditions(where, null, model, options); - const limit = options.limit ? ` LIMIT ${this.escape(options.limit, undefined, _.pick(options, ['replacements', 'bind']))}` : ''; + + const escapeOptions = { + replacements: options.replacements, + model, + }; + + const limit = options.limit ? ` LIMIT ${this.escape(options.limit, escapeOptions)}` : ''; let primaryKeys = ''; let primaryKeysSelection = ''; + let whereClause = this.whereQuery(where, { ...options, model }); if (whereClause) { - whereClause = ` WHERE ${whereClause}`; + whereClause = ` ${whereClause}`; } if (options.limit) { @@ -428,18 +332,18 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { 'is_deferrable AS "isDeferrable",', 'initially_deferred AS "initiallyDeferred"', 'from INFORMATION_SCHEMA.table_constraints', - `WHERE table_name='${tableName}';`, + `WHERE table_name=${this.escape(tableName)};`, ].join(' '); } addLimitAndOffset(options) { let fragment = ''; if (options.limit != null) { - fragment += ` LIMIT ${this.escape(options.limit, undefined, options)}`; + fragment += ` LIMIT ${this.escape(options.limit, options)}`; } if (options.offset) { - fragment += ` OFFSET ${this.escape(options.offset, undefined, options)}`; + fragment += ` OFFSET ${this.escape(options.offset, options)}`; } return fragment; @@ -491,7 +395,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { } if (defaultValueSchemable(attribute.defaultValue)) { - sql += ` DEFAULT ${this.escape(attribute.defaultValue, attribute)}`; + sql += ` DEFAULT ${this.escape(attribute.defaultValue, { type: attribute.type })}`; } if (attribute.unique === true) { @@ -950,6 +854,7 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { */ quoteIdentifier(identifier, force) { const optForceQuote = force || false; + // TODO: remove "quoteIdentifiers: false" option const optQuoteIdentifiers = this.options.quoteIdentifiers !== false; if ( @@ -970,26 +875,4 @@ export class PostgresQueryGenerator extends PostgresQueryGeneratorTypeScript { return identifier; } - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} column The JSON column - * @param {string|Array} [path] The path to extract (optional) - * @param {boolean} [isJson] The value is JSON use alt symbols (optional) - * @returns {string} The generated sql query - * @private - */ - jsonPathExtractionQuery(column, path, isJson) { - const quotedColumn = this.isIdentifierQuoted(column) - ? column - : this.quoteIdentifier(column); - - const join = isJson ? '#>' : '#>>'; - - // TODO: drop this custom array building and use the stringifier of the Array DataType - const pathStr = this.escape(`{${_.toPath(path).join(',')}}`); - - return `(${quotedColumn}${join}${pathStr})`; - } } diff --git a/packages/core/src/dialects/snowflake/query-generator.js b/packages/core/src/dialects/snowflake/query-generator.js index e56242ae09ae..26b2fb47ec11 100644 --- a/packages/core/src/dialects/snowflake/query-generator.js +++ b/packages/core/src/dialects/snowflake/query-generator.js @@ -1,13 +1,10 @@ 'use strict'; -import { Cast } from '../../expression-builders/cast.js'; -import { Json } from '../../expression-builders/json.js'; import { joinSQLFragments } from '../../utils/join-sql-fragments'; import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { addTicks, quoteIdentifier } from '../../utils/dialect.js'; import { rejectInvalidOptions } from '../../utils/check'; -import { underscore } from '../../utils/string'; import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, CREATE_DATABASE_QUERY_SUPPORTABLE_OPTIONS, @@ -18,7 +15,6 @@ import { const _ = require('lodash'); const { SnowflakeQueryGeneratorTypeScript } = require('./query-generator-typescript'); -const util = require('node:util'); const { Op } = require('../../operators'); const JSON_FUNCTION_REGEX = /^\s*((?:[a-z]+_){0,2}jsonb?(?:_[a-z]+){0,2})\([^)]*\)/i; @@ -59,11 +55,8 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { constructor(options) { super(options); - this.OperatorMap = { - ...this.OperatorMap, - [Op.regexp]: 'REGEXP', - [Op.notRegexp]: 'NOT REGEXP', - }; + this.whereSqlBuilder.setOperatorKeyword(Op.regexp, 'REGEXP'); + this.whereSqlBuilder.setOperatorKeyword(Op.notRegexp, 'NOT REGEXP'); } createDatabaseQuery(databaseName, options) { @@ -196,7 +189,7 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { 'CREATE TABLE IF NOT EXISTS', table, `(${attributesClause})`, - options.comment && typeof options.comment === 'string' && `COMMENT ${this.escape(options.comment, undefined, options)}`, + options.comment && typeof options.comment === 'string' && `COMMENT ${this.escape(options.comment, options)}`, options.charset && `DEFAULT CHARSET=${options.charset}`, options.collate && `COLLATE ${options.collate}`, options.rowFormat && `ROW_FORMAT=${options.rowFormat}`, @@ -207,7 +200,7 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { showTablesQuery(database, options) { return joinSQLFragments([ 'SELECT TABLE_NAME FROM INFORMATION_SCHEMA.TABLES WHERE TABLE_TYPE = \'BASE TABLE\'', - database ? `AND TABLE_SCHEMA = ${this.escape(database, undefined, options)}` : 'AND TABLE_SCHEMA NOT IN ( \'INFORMATION_SCHEMA\', \'PERFORMANCE_SCHEMA\', \'SYS\')', + database ? `AND TABLE_SCHEMA = ${this.escape(database, options)}` : 'AND TABLE_SCHEMA NOT IN ( \'INFORMATION_SCHEMA\', \'PERFORMANCE_SCHEMA\', \'SYS\')', ';', ]); } @@ -333,50 +326,6 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { ]); } - handleSequelizeMethod(attr, tableName, factory, options, prepend) { - if (attr instanceof Json) { - // Parse nested object - if (attr.conditions) { - const conditions = this.parseConditionObject(attr.conditions).map(condition => `${this.jsonPathExtractionQuery(condition.path[0], _.tail(condition.path))} = '${condition.value}'`); - - return conditions.join(' AND '); - } - - if (attr.path) { - let str; - - // Allow specifying conditions using the sqlite json functions - if (this._checkValidJsonStatement(attr.path)) { - str = attr.path; - } else { - // Also support json property accessors - const paths = _.toPath(attr.path); - const column = paths.shift(); - str = this.jsonPathExtractionQuery(column, paths); - } - - if (attr.value) { - str += util.format(' = %s', this.escape(attr.value, undefined, options)); - } - - return str; - } - } else if (attr instanceof Cast) { - if (/timestamp/i.test(attr.type)) { - attr.type = 'datetime'; - } else if (attr.json && /boolean/i.test(attr.type)) { - // true or false cannot be casted as booleans within a JSON structure - attr.type = 'char'; - } else if (/double precision/i.test(attr.type) || /boolean/i.test(attr.type) || /integer/i.test(attr.type)) { - attr.type = 'decimal'; - } else if (/text/i.test(attr.type)) { - attr.type = 'char'; - } - } - - return super.handleSequelizeMethod(attr, tableName, factory, options, prepend); - } - truncateTableQuery(tableName) { return joinSQLFragments([ 'TRUNCATE', @@ -385,14 +334,16 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { } deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { + const escapeOptions = { ...options, model }; + const table = this.quoteTable(tableName); - let whereClause = this.getWhereConditions(where, null, model, options); - const limit = options.limit && ` LIMIT ${this.escape(options.limit, undefined, options)}`; + const limit = options.limit && ` LIMIT ${this.escape(options.limit, escapeOptions)}`; let primaryKeys = ''; let primaryKeysSelection = ''; + let whereClause = this.whereQuery(where, escapeOptions); if (whereClause) { - whereClause = `WHERE ${whereClause}`; + whereClause = ` ${whereClause}`; } if (limit) { @@ -468,7 +419,7 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { if (!typeWithoutDefault.has(attributeString) && attribute.type._binary !== true && defaultValueSchemable(attribute.defaultValue)) { - template += ` DEFAULT ${this.escape(attribute.defaultValue, undefined, options)}`; + template += ` DEFAULT ${this.escape(attribute.defaultValue, { ...options, type: attribute.type })}`; } if (attribute.unique === true) { @@ -480,7 +431,7 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { } if (attribute.comment) { - template += ` COMMENT ${this.escape(attribute.comment, undefined, options)}`; + template += ` COMMENT ${this.escape(attribute.comment, options)}`; } if (attribute.first) { @@ -530,69 +481,6 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { return result; } - /** - * Check whether the statmement is json function or simple path - * - * @param {string} stmt The statement to validate - * @returns {boolean} true if the given statement is json function - * @throws {Error} throw if the statement looks like json function but has invalid token - * @private - */ - _checkValidJsonStatement(stmt) { - if (typeof stmt !== 'string') { - return false; - } - - let currentIndex = 0; - let openingBrackets = 0; - let closingBrackets = 0; - let hasJsonFunction = false; - let hasInvalidToken = false; - - while (currentIndex < stmt.length) { - const string = stmt.slice(currentIndex); - const functionMatches = JSON_FUNCTION_REGEX.exec(string); - if (functionMatches) { - currentIndex += functionMatches[0].indexOf('('); - hasJsonFunction = true; - continue; - } - - const operatorMatches = JSON_OPERATOR_REGEX.exec(string); - if (operatorMatches) { - currentIndex += operatorMatches[0].length; - hasJsonFunction = true; - continue; - } - - const tokenMatches = TOKEN_CAPTURE_REGEX.exec(string); - if (tokenMatches) { - const capturedToken = tokenMatches[1]; - if (capturedToken === '(') { - openingBrackets++; - } else if (capturedToken === ')') { - closingBrackets++; - } else if (capturedToken === ';') { - hasInvalidToken = true; - break; - } - - currentIndex += tokenMatches[0].length; - continue; - } - - break; - } - - // Check invalid json statement - if (hasJsonFunction && (hasInvalidToken || openingBrackets !== closingBrackets)) { - throw new Error(`Invalid json statement: ${stmt}`); - } - - // return true if the statement has valid json function - return hasJsonFunction; - } - dataTypeMapping(tableName, attr, dataType) { if (dataType.includes('PRIMARY KEY')) { dataType = dataType.replace('PRIMARY KEY', ''); @@ -690,11 +578,11 @@ export class SnowflakeQueryGenerator extends SnowflakeQueryGeneratorTypeScript { addLimitAndOffset(options) { if (options.offset) { - return ` LIMIT ${this.escape(options.limit ?? null, undefined, options)} OFFSET ${this.escape(options.offset, undefined, options)}`; + return ` LIMIT ${this.escape(options.limit ?? null, options)} OFFSET ${this.escape(options.offset, options)}`; } if (options.limit != null) { - return ` LIMIT ${this.escape(options.limit, undefined, options)}`; + return ` LIMIT ${this.escape(options.limit, options)}`; } return ''; diff --git a/packages/core/src/dialects/sqlite/query-generator.js b/packages/core/src/dialects/sqlite/query-generator.js index 769485745d8a..3760046ea223 100644 --- a/packages/core/src/dialects/sqlite/query-generator.js +++ b/packages/core/src/dialects/sqlite/query-generator.js @@ -1,20 +1,15 @@ 'use strict'; -import { BaseSqlExpression } from '../../expression-builders/base-sql-expression.js'; -import { Cast } from '../../expression-builders/cast.js'; -import { Json } from '../../expression-builders/json.js'; import { addTicks, removeTicks } from '../../utils/dialect'; import { removeNullishValuesFromHash } from '../../utils/format'; import { EMPTY_OBJECT } from '../../utils/object.js'; import { defaultValueSchemable } from '../../utils/query-builder-utils'; import { rejectInvalidOptions } from '../../utils/check'; -import { underscore } from '../../utils/string'; import { ADD_COLUMN_QUERY_SUPPORTABLE_OPTIONS, REMOVE_COLUMN_QUERY_SUPPORTABLE_OPTIONS } from '../abstract/query-generator'; const { Transaction } = require('../../transaction'); const _ = require('lodash'); const { SqliteQueryGeneratorTypeScript } = require('./query-generator-typescript'); -const { AbstractQueryGenerator } = require('../abstract/query-generator'); const ADD_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set(); const REMOVE_COLUMN_QUERY_SUPPORTED_OPTIONS = new Set(); @@ -110,108 +105,19 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { addLimitAndOffset(options, model) { let fragment = ''; if (options.limit != null) { - fragment += ` LIMIT ${this.escape(options.limit, undefined, options)}`; + fragment += ` LIMIT ${this.escape(options.limit, options)}`; } else if (options.offset) { // limit must be specified if offset is specified. fragment += ` LIMIT -1`; } if (options.offset) { - fragment += ` OFFSET ${this.escape(options.offset, undefined, options)}`; + fragment += ` OFFSET ${this.escape(options.offset, options)}`; } return fragment; } - booleanValue(value) { - return value ? 1 : 0; - } - - /** - * Check whether the statmement is json function or simple path - * - * @param {string} stmt The statement to validate - * @returns {boolean} true if the given statement is json function - * @throws {Error} throw if the statement looks like json function but has invalid token - */ - _checkValidJsonStatement(stmt) { - if (typeof stmt !== 'string') { - return false; - } - - // https://sqlite.org/json1.html - const jsonFunctionRegex = /^\s*(json(?:_[a-z]+){0,2})\([^)]*\)/i; - const tokenCaptureRegex = /^\s*((?:(["'`])(?:(?!\2).|\2{2})*\2)|[\s\w]+|[()+,.;-])/i; - - let currentIndex = 0; - let openingBrackets = 0; - let closingBrackets = 0; - let hasJsonFunction = false; - let hasInvalidToken = false; - - while (currentIndex < stmt.length) { - const string = stmt.slice(currentIndex); - const functionMatches = jsonFunctionRegex.exec(string); - if (functionMatches) { - currentIndex += functionMatches[0].indexOf('('); - hasJsonFunction = true; - continue; - } - - const tokenMatches = tokenCaptureRegex.exec(string); - if (tokenMatches) { - const capturedToken = tokenMatches[1]; - if (capturedToken === '(') { - openingBrackets++; - } else if (capturedToken === ')') { - closingBrackets++; - } else if (capturedToken === ';') { - hasInvalidToken = true; - break; - } - - currentIndex += tokenMatches[0].length; - continue; - } - - break; - } - - // Check invalid json statement - hasInvalidToken |= openingBrackets !== closingBrackets; - if (hasJsonFunction && hasInvalidToken) { - throw new Error(`Invalid json statement: ${stmt}`); - } - - // return true if the statement has valid json function - return hasJsonFunction; - } - - // sqlite can't cast to datetime so we need to convert date values to their ISO strings - _toJSONValue(value) { - if (value instanceof Date) { - return value.toISOString(); - } - - if (Array.isArray(value) && value[0] instanceof Date) { - return value.map(val => val.toISOString()); - } - - return value; - } - - handleSequelizeMethod(smth, tableName, factory, options, prepend) { - if (smth instanceof Json) { - return super.handleSequelizeMethod(smth, tableName, factory, options, prepend); - } - - if (smth instanceof Cast && /timestamp/i.test(smth.type)) { - smth.type = 'datetime'; - } - - return AbstractQueryGenerator.prototype.handleSequelizeMethod.call(this, smth, tableName, factory, options, prepend); - } - addColumnQuery(table, key, dataType, options) { if (options) { rejectInvalidOptions( @@ -258,13 +164,16 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { } for (const key in attrValueHash) { - const value = attrValueHash[key]; + const value = attrValueHash[key] ?? null; - if (value instanceof BaseSqlExpression || options.bindParam === false) { - values.push(`${this.quoteIdentifier(key)}=${this.escape(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements })}`); - } else { - values.push(`${this.quoteIdentifier(key)}=${this.format(value, modelAttributeMap && modelAttributeMap[key] || undefined, { context: 'UPDATE', replacements: options.replacements }, bindParam)}`); - } + const escapedValue = this.escape(value, { + replacements: options.replacements, + bindParam, + type: modelAttributeMap[key]?.type, + // TODO: model, + }); + + values.push(`${this.quoteIdentifier(key)}=${escapedValue}`); } let query; @@ -294,14 +203,13 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { deleteQuery(tableName, where, options = EMPTY_OBJECT, model) { _.defaults(options, this.options); - let whereClause = this.getWhereConditions(where, null, model, options); - + let whereClause = this.whereQuery(where, { ...options, model }); if (whereClause) { - whereClause = `WHERE ${whereClause}`; + whereClause = ` ${whereClause}`; } if (options.limit) { - whereClause = `WHERE rowid IN (SELECT rowid FROM ${this.quoteTable(tableName)} ${whereClause} LIMIT ${this.escape(options.limit, undefined, options)})`; + whereClause = `WHERE rowid IN (SELECT rowid FROM ${this.quoteTable(tableName)} ${whereClause} LIMIT ${this.escape(options.limit, options)})`; } return `DELETE FROM ${this.quoteTable(tableName)} ${whereClause}`.trim(); @@ -324,7 +232,7 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { // TODO thoroughly check that DataTypes.NOW will properly // get populated on all databases as DEFAULT value // i.e. mysql requires: DEFAULT CURRENT_TIMESTAMP - sql += ` DEFAULT ${this.escape(attribute.defaultValue, attribute, options)}`; + sql += ` DEFAULT ${this.escape(attribute.defaultValue, { ...options, type: attribute.type })}`; } if (attribute.unique === true) { @@ -521,25 +429,4 @@ export class SqliteQueryGenerator extends SqliteQueryGeneratorTypeScript { foreignKeyCheckQuery(tableName) { return `PRAGMA foreign_key_check(${this.quoteTable(tableName)});`; } - - /** - * Generates an SQL query that extract JSON property of given path. - * - * @param {string} column The JSON column - * @param {string|Array} [path] The path to extract (optional) - * @returns {string} The generated sql query - * @private - */ - jsonPathExtractionQuery(column, path) { - const quotedColumn = this.isIdentifierQuoted(column) - ? column - : this.quoteIdentifier(column); - - const pathStr = this.escape(['$'] - .concat(_.toPath(path)) - .join('.') - .replace(/\.(\d+)(?:(?=\.)|$)/g, (__, digit) => `[${digit}]`)); - - return `json_extract(${quotedColumn},${pathStr})`; - } } diff --git a/packages/core/src/expression-builders/association-path.ts b/packages/core/src/expression-builders/association-path.ts new file mode 100644 index 000000000000..27247ff6274e --- /dev/null +++ b/packages/core/src/expression-builders/association-path.ts @@ -0,0 +1,12 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +export class AssociationPath extends BaseSqlExpression { + declare private readonly brand: 'associationPath'; + + constructor( + readonly associationPath: readonly string[], + readonly attributeName: string, + ) { + super(); + } +} diff --git a/packages/core/src/expression-builders/attribute.ts b/packages/core/src/expression-builders/attribute.ts new file mode 100644 index 000000000000..4272b94c38ba --- /dev/null +++ b/packages/core/src/expression-builders/attribute.ts @@ -0,0 +1,60 @@ +import { parseAttributeSyntax } from '../utils/attribute-syntax.js'; +import type { AssociationPath } from './association-path.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; +import type { Cast } from './cast.js'; +import type { DialectAwareFn } from './dialect-aware-fn.js'; +import type { JsonPath } from './json-path.js'; + +/** + * Use {@link @sequelize/core.attribute} instead. + */ +export class Attribute extends BaseSqlExpression { + declare private readonly brand: 'attribute'; + + constructor(readonly attributeName: string) { + super(); + } +} + +/** + * Used to represent the attribute of a model. You should use the attribute name, which will be mapped to the correct column name. + * This attribute name follows the same rules as the attribute names in POJO where options. + * As such, you can use dot notation to access nested JSON properties, and you can reference included associations. + * + * If you want to use a database name, without mapping, you can use {@link Identifier}. + * + * @example + * Let's say the class User has an attribute `firstName`, which maps to the column `first_name`. + * + * ```ts + * User.findAll({ + * where: sql`${attribute('firstName')} = 'John'` + * }); + * ``` + * + * Will generate: + * + * ```sql + * SELECT * FROM users WHERE first_name = 'John' + * ``` + * + * @example + * Let's say the class User has an attribute `data`, which is a JSON column. + * + * ```ts + * User.findAll({ + * where: sql`${attribute('data.registered')} = 'true'` + * }); + * ``` + * + * Will generate (assuming the dialect supports JSON operators): + * + * ```sql + * SELECT * FROM users WHERE data->'registered' = 'true' + * ``` + * + * @param attributeName + */ +export function attribute(attributeName: string): Cast | JsonPath | AssociationPath | Attribute | DialectAwareFn { + return parseAttributeSyntax(attributeName); +} diff --git a/packages/core/src/expression-builders/base-sql-expression.ts b/packages/core/src/expression-builders/base-sql-expression.ts index 53a9283e95c8..a4a80c07dd7c 100644 --- a/packages/core/src/expression-builders/base-sql-expression.ts +++ b/packages/core/src/expression-builders/base-sql-expression.ts @@ -1,8 +1,14 @@ +import type { AssociationPath } from './association-path.js'; +import type { Attribute } from './attribute.js'; import type { Cast } from './cast.js'; import type { Col } from './col.js'; +import type { DialectAwareFn } from './dialect-aware-fn.js'; import type { Fn } from './fn.js'; -import type { Json } from './json.js'; +import type { Identifier } from './identifier.js'; +import type { JsonPath } from './json-path.js'; +import type { List } from './list.js'; import type { Literal } from './literal.js'; +import type { Value } from './value.js'; import type { Where } from './where.js'; /** @@ -14,9 +20,15 @@ import type { Where } from './where.js'; export class BaseSqlExpression {} export type DynamicSqlExpression = + | List + | Value + | Identifier + | Attribute | Fn + | DialectAwareFn | Col | Cast | Literal | Where - | Json; + | JsonPath + | AssociationPath; diff --git a/packages/core/src/expression-builders/cast.ts b/packages/core/src/expression-builders/cast.ts index 374d1f0bd163..6cf1708dc4e7 100644 --- a/packages/core/src/expression-builders/cast.ts +++ b/packages/core/src/expression-builders/cast.ts @@ -1,27 +1,37 @@ +import type { DataType } from '../dialects/abstract/data-types.js'; +import { Op } from '../operators.js'; +import type { Expression } from '../sequelize.js'; +import { isPlainObject } from '../utils/check.js'; import { BaseSqlExpression } from './base-sql-expression.js'; +import { where } from './where.js'; /** * Do not use me directly. Use {@link cast} */ export class Cast extends BaseSqlExpression { - private readonly val: any; - private readonly type: string; - private readonly json: boolean; + declare private readonly brand: 'cast'; - constructor(val: unknown, type: string = '', json: boolean = false) { + constructor( + readonly expression: Expression, + readonly type: DataType, + ) { super(); - this.val = val; - this.type = type.trim(); - this.json = json; } } /** - * Creates a object representing a call to the cast function. + * Creates an object representing a call to the cast function. * * @param val The value to cast * @param type The type to cast it to */ -export function cast(val: unknown, type: string): Cast { +export function cast(val: unknown, type: DataType): Cast { + if (isPlainObject(val) && !(Op.col in val)) { + // Users should wrap this parameter with `where` themselves, but we do it to ensure backwards compatibility + // with https://github.com/sequelize/sequelize/issues/6666 + // @ts-expect-error -- backwards compatibility hack + val = where(val); + } + return new Cast(val, type); } diff --git a/packages/core/src/expression-builders/col.ts b/packages/core/src/expression-builders/col.ts index 68e0c8cca307..7f09ecb03df3 100644 --- a/packages/core/src/expression-builders/col.ts +++ b/packages/core/src/expression-builders/col.ts @@ -4,26 +4,28 @@ import { BaseSqlExpression } from './base-sql-expression.js'; * Do not use me directly. Use {@link col} */ export class Col extends BaseSqlExpression { - private readonly col: string[] | string; + declare private readonly brand: 'col'; - constructor(identifiers: string[] | string, ...args: string[]) { + readonly identifiers: string[]; + + constructor(...identifiers: string[]) { super(); - // TODO(ephys): this does not look right. First parameter is ignored if a second parameter is provided. - // should we change the signature to `constructor(...cols: string[])` - if (args.length > 0) { - identifiers = args; - } - this.col = identifiers; + // TODO: verify whether the "more than one identifier" case is still needed + this.identifiers = identifiers; } } /** * Creates an object which represents a column in the DB, this allows referencing another column in your query. - * This is often useful in conjunction with `sequelize.fn`, since raw string arguments to fn will be escaped. + * This is often useful in conjunction with {@link fn}, {@link where} and {@link sql} which interpret strings as values and not column names. + * + * Col works similarly to {@link Identifier}, but "*" has special meaning, for backwards compatibility. + * + * ⚠️ We recommend using {@link Identifier}, or {@link Attribute} instead. * * @param identifiers The name of the column */ -export function col(identifiers: string[] | string): Col { - return new Col(identifiers); +export function col(...identifiers: string[]): Col { + return new Col(...identifiers); } diff --git a/packages/core/src/expression-builders/dialect-aware-fn.ts b/packages/core/src/expression-builders/dialect-aware-fn.ts new file mode 100644 index 000000000000..c9fb66ee136e --- /dev/null +++ b/packages/core/src/expression-builders/dialect-aware-fn.ts @@ -0,0 +1,73 @@ +import type { Class } from 'type-fest'; +import type { AbstractDialect } from '../dialects/abstract/index.js'; +import type { EscapeOptions } from '../dialects/abstract/query-generator-typescript.js'; +import type { Expression } from '../sequelize.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; +import { JsonPath } from './json-path.js'; + +/** + * Unlike {@link Fn}, this class does not accept a function name. + * It must instead be extended by a class that implements the {@link apply} method, in which + * the function name is provided. + * + * The goal of this class is to allow dialect-specific functions to be used in a cross-dialect way. + * For instance, an extension of this class could be used to represent the `LOWER` function in a cross-dialect way, + * by generating the correct SQL function name based on which dialect is used. + */ +export abstract class DialectAwareFn extends BaseSqlExpression { + readonly args: readonly Expression[]; + + constructor(...args: DialectAwareFn['args']) { + super(); + this.args = args; + + if (this.args.length > this.maxArgCount) { + throw new Error(`Too many arguments provided to ${this.constructor.name} function. Expected ${this.maxArgCount} or less, but got ${this.args.length}.`); + } + + if (this.args.length < this.minArgCount) { + throw new Error(`Too few arguments provided to ${this.constructor.name} function. Expected ${this.minArgCount} or more, but got ${this.args.length}.`); + } + } + + get maxArgCount() { + return Number.POSITIVE_INFINITY; + } + + get minArgCount() { + return 0; + } + + abstract apply(dialect: AbstractDialect, options?: EscapeOptions): string; + + static build(this: Class, ...args: DialectAwareFn['args']): M { + return new this(...args); + } +} + +/** + * Unquotes JSON values. + */ +export class Unquote extends DialectAwareFn { + get maxArgCount() { + return 1; + } + + get minArgCount() { + return 1; + } + + apply(dialect: AbstractDialect, options?: EscapeOptions): string { + const arg = this.args[0]; + + if (arg instanceof JsonPath) { + return dialect.queryGenerator.jsonPathExtractionQuery( + dialect.queryGenerator.escape(arg.expression), + arg.path, + true, + ); + } + + return dialect.queryGenerator.formatUnquoteJson(arg, options); + } +} diff --git a/packages/core/src/expression-builders/fn.ts b/packages/core/src/expression-builders/fn.ts index 3e5fdf8bc719..ac1026e1c771 100644 --- a/packages/core/src/expression-builders/fn.ts +++ b/packages/core/src/expression-builders/fn.ts @@ -1,15 +1,17 @@ -import type { WhereAttributeHash } from '../model.js'; +import { Op } from '../operators.js'; +import type { Expression } from '../sequelize.js'; +import { isPlainObject } from '../utils/check.js'; import { BaseSqlExpression } from './base-sql-expression.js'; +import { where } from './where.js'; /** * Do not use me directly. Use {@link fn} */ export class Fn extends BaseSqlExpression { - private readonly fn: string; + declare private readonly brand: 'fn'; - // unknown already covers the other two types, but they've been added explicitly to document - // passing WhereAttributeHash generates a condition inside the function. - private readonly args: Array; + readonly fn: string; + readonly args: readonly Expression[]; constructor(fnName: string, args: Fn['args']) { super(); @@ -24,7 +26,10 @@ export class Fn extends BaseSqlExpression { /** * Creates an object representing a database function. This can be used in search queries, both in where and order parts, and as default values in column definitions. - * If you want to refer to columns in your function, you should use {@link col}, so that the columns are properly interpreted as columns and not a strings. + * If you want to refer to columns in your function, you should use {@link attribute} (recommended), {@link identifier}, or {@link col} (discouraged) + * otherwise the value will be interpreted as a string. + * + * ℹ️ This method is usually verbose and we recommend using the {@link sql} template string tag instead. * * @param fnName The SQL function you want to call * @param args All further arguments will be passed as arguments to the function @@ -37,5 +42,15 @@ export class Fn extends BaseSqlExpression { * ``` */ export function fn(fnName: string, ...args: Fn['args']): Fn { + for (let i = 0; i < args.length; i++) { + // Users should wrap this parameter with `where` themselves, but we do it to ensure backwards compatibility + // with https://github.com/sequelize/sequelize/issues/6666 + // @ts-expect-error -- backwards compatibility hack + if (isPlainObject(args[i]) && !(Op.col in args[i])) { + // @ts-expect-error -- backwards compatibility hack + args[i] = where(args[i]); + } + } + return new Fn(fnName, args); } diff --git a/packages/core/src/expression-builders/identifier.ts b/packages/core/src/expression-builders/identifier.ts new file mode 100644 index 000000000000..5abac555218d --- /dev/null +++ b/packages/core/src/expression-builders/identifier.ts @@ -0,0 +1,33 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Use {@link @sequelize/core.identifier} instead. + */ +export class Identifier extends BaseSqlExpression { + declare private readonly brand: 'identifier'; + + constructor(readonly value: string) { + super(); + } +} + +/** + * Used to represent a value that will either be escaped to a literal, or a bind parameter. + * Unlike {@link attribute} and {@link col}, this identifier will be escaped as-is, + * without mapping to a column name or any other transformation. + * + * @param value + * @example + * ```ts + * sequelize.query(sql`SELECT * FROM users WHERE ${identifier('firstName')} = 'John'`); + * ``` + * + * Will generate (identifier quoting depending on the dialect): + * + * ```sql + * SELECT * FROM users WHERE "firstName" = 'John' + * ``` + */ +export function identifier(value: string): Identifier { + return new Identifier(value); +} diff --git a/packages/core/src/expression-builders/json-path.ts b/packages/core/src/expression-builders/json-path.ts new file mode 100644 index 000000000000..27553ad283d0 --- /dev/null +++ b/packages/core/src/expression-builders/json-path.ts @@ -0,0 +1,71 @@ +import type { Expression } from '../sequelize.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Do not use me directly. Use {@link @sequelize/core.jsonPath}. + */ +export class JsonPath extends BaseSqlExpression { + declare private readonly brand: 'jsonPath'; + + constructor( + readonly expression: Expression, + readonly path: ReadonlyArray, + ) { + super(); + } +} + +/** + * Use this to access nested properties in a JSON column. + * You can also use the dot notation with {@link attribute}, but this works with any values, not just attributes. + * + * @param expression The expression to access the property on. + * @param path The path to the property. If a number is used, it will be treated as an array index, otherwise as a key. + * + * @example + * ```ts + * sql`${jsonPath('data', ['name'])} = '"John"'` + * ``` + * + * will produce + * + * ```sql + * -- postgres + * "data"->'name' = '"John"' + * -- sqlite, mysql, mariadb + * JSON_EXTRACT("data", '$.name') = '"John"' + * ``` + * + * @example + * ```ts + * // notice here that 0 is a number, not a string. It will be treated as an array index. + * sql`${jsonPath('array', [0])}` + * ``` + * + * will produce + * + * ```sql + * -- postgres + * "array"->0 + * -- sqlite, mysql, mariadb + * JSON_EXTRACT(`array`, '$[0]') + * ``` + * + * @example + * ```ts + * // notice here that 0 is a string, not a number. It will be treated as an object key. + * sql`${jsonPath('object', ['0'])}` + * ``` + * + * will produce + * + * ```sql + * -- postgres + * "object"->'0' + * -- sqlite, mysql, mariadb + * JSON_EXTRACT(`object`, '$.0') + * ``` + */ +export function jsonPath(expression: Expression, path: ReadonlyArray): JsonPath { + return new JsonPath(expression, path); +} diff --git a/packages/core/src/expression-builders/json.ts b/packages/core/src/expression-builders/json.ts index 674c5c476dbf..a00827e44c4e 100644 --- a/packages/core/src/expression-builders/json.ts +++ b/packages/core/src/expression-builders/json.ts @@ -1,40 +1,44 @@ -import isObject from 'lodash/isObject.js'; -import { BaseSqlExpression } from './base-sql-expression.js'; +import { noSqlJson } from '../utils/deprecations.js'; +import type { AssociationPath } from './association-path.js'; +import type { Attribute } from './attribute.js'; +import { attribute } from './attribute.js'; +import type { Cast } from './cast.js'; +import type { DialectAwareFn } from './dialect-aware-fn.js'; +import type { JsonPath } from './json-path.js'; +import type { Where } from './where.js'; +import { where } from './where.js'; /** - * Do not use me directly. Use {@link json} + * Creates an object representing nested where conditions for postgres/sqlite/mysql json data-type. + * + * @param conditionsOrPath A hash containing strings/numbers or other nested hash, a string using dot notation or a string using postgres/sqlite/mysql json syntax. + * @param value An optional value to compare against. Produces a string of the form " = ''". + * + * @deprecated use {@link where}, {@link @sequelize/core.attribute}, and/or {@link @sequelize/core.jsonPath} instead. */ -export class Json extends BaseSqlExpression { - private readonly conditions?: { [key: string]: any }; - private readonly path?: string; - private readonly value?: string | number | boolean | null; - - constructor( - conditionsOrPath: { [key: string]: any } | string, - value?: string | number | boolean | null, - ) { - super(); +export function json( + conditionsOrPath: { [key: string]: any } | string, + value?: string | number | boolean | null, +): Cast | JsonPath | AssociationPath | Attribute | DialectAwareFn | Where { + noSqlJson(); - if (typeof conditionsOrPath === 'string') { - this.path = conditionsOrPath; + if (typeof conditionsOrPath === 'string') { + const attr = attribute(conditionsOrPath); - if (value) { - this.value = value; - } - } else if (isObject(conditionsOrPath)) { - this.conditions = conditionsOrPath; + // json('profile.id') is identical to attribute('profile.id') + if (value === undefined) { + return attr; } + + // json('profile.id', value) is identical to where(attribute('profile.id'), value) + return where(attr, value); } -} -/** - * Creates an object representing nested where conditions for postgres's json data-type. - * - * @param conditionsOrPath A hash containing strings/numbers or other nested hash, a string using dot - * notation or a string using postgres json syntax. - * @param value An optional value to compare against. - * Produces a string of the form "<json path> = '<value>'". - */ -export function json(conditionsOrPath: string | object, value?: string | number | boolean): Json { - return new Json(conditionsOrPath, value); + if (value === undefined && typeof conditionsOrPath === 'string') { + return attribute(conditionsOrPath); + } + + // json({ key: value }) is identical to where({ key: value }) + + return where(conditionsOrPath); } diff --git a/packages/core/src/expression-builders/list.ts b/packages/core/src/expression-builders/list.ts new file mode 100644 index 000000000000..566ca2ed3c53 --- /dev/null +++ b/packages/core/src/expression-builders/list.ts @@ -0,0 +1,33 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Use {@link @sequelize/core.list} instead. + */ +export class List extends BaseSqlExpression { + declare private readonly brand: 'list'; + + constructor(readonly values: unknown[]) { + super(); + } +} + +/** + * Used to represent an SQL list of values, e.g. `WHERE id IN (1, 2, 3)`. This ensure that the array is interpreted + * as an SQL list, and not as an SQL Array. + * + * @example + * ```ts + * sequelize.query(sql`SELECT * FROM users WHERE id IN ${list([1, 2, 3])}`); + * ``` + * + * Will generate: + * + * ```sql + * SELECT * FROM users WHERE id IN (1, 2, 3) + * ``` + * + * @param values The members of the list. + */ +export function list(values: unknown[]): List { + return new List(values); +} diff --git a/packages/core/src/expression-builders/literal.ts b/packages/core/src/expression-builders/literal.ts index b25c577812b2..8ed5755f9228 100644 --- a/packages/core/src/expression-builders/literal.ts +++ b/packages/core/src/expression-builders/literal.ts @@ -7,19 +7,22 @@ export class Literal extends BaseSqlExpression { /** this (type-only) brand prevents TypeScript from thinking Cast is assignable to Literal because they share the same shape */ declare private readonly brand: 'literal'; - private readonly val: unknown; + readonly val: ReadonlyArray; - constructor(val: unknown) { + constructor(val: string | Array) { super(); - this.val = val; + + this.val = Array.isArray(val) ? val : [val]; } } /** * Creates an object representing a literal, i.e. something that will not be escaped. + * We recommend using {@link sql} for a better DX. * * @param val literal value */ -export function literal(val: string) { +export function literal(val: string | Array): Literal { return new Literal(val); } + diff --git a/packages/core/src/expression-builders/sql.ts b/packages/core/src/expression-builders/sql.ts new file mode 100644 index 000000000000..84ed403c785c --- /dev/null +++ b/packages/core/src/expression-builders/sql.ts @@ -0,0 +1,54 @@ +import { attribute } from './attribute.js'; +import { BaseSqlExpression } from './base-sql-expression.js'; +import { cast } from './cast.js'; +import { col } from './col.js'; +import { Unquote } from './dialect-aware-fn.js'; +import { fn } from './fn.js'; +import { identifier } from './identifier.js'; +import { jsonPath } from './json-path.js'; +import { list } from './list.js'; +import { literal, Literal } from './literal.js'; +import { Value } from './value.js'; +import { where } from './where.js'; + +/** + * The template tag function used to easily create {@link literal}. + * + * @param rawSql + * @param values + * @example + * ```ts + * sql`SELECT * FROM ${sql.identifier(table)} WHERE ${sql.identifier(column)} = ${value}` + * ``` + */ +export function sql(rawSql: TemplateStringsArray, ...values: unknown[]): Literal { + const arg: Array = []; + + for (const [i, element] of rawSql.entries()) { + arg.push(element); + + if (i < values.length) { + const value = values[i]; + + arg.push(value instanceof BaseSqlExpression ? value : new Value(value)); + } + } + + return new Literal(arg); +} + +// The following builders are not listed here for the following reasons: +// - json(): deprecated & redundant with other builders +// - value(): internal detail of the `sql` template tag function +// - associationPath(): internal detail of attribute() +sql.attribute = attribute; +sql.cast = cast; +sql.col = col; +sql.fn = fn; +sql.identifier = identifier; +sql.jsonPath = jsonPath; +sql.list = list; +sql.literal = literal; +sql.where = where; + +sql.unquote = Unquote.build.bind(Unquote); diff --git a/packages/core/src/expression-builders/value.ts b/packages/core/src/expression-builders/value.ts new file mode 100644 index 000000000000..2191e1615e2f --- /dev/null +++ b/packages/core/src/expression-builders/value.ts @@ -0,0 +1,14 @@ +import { BaseSqlExpression } from './base-sql-expression.js'; + +/** + * Used to represent a value that will either be escaped to a literal, or a bind parameter. + * You do not need to use this function directly, it will be used automatically when you interpolate parameters + * in a template string tagged with {@link sql}. + */ +export class Value extends BaseSqlExpression { + declare private readonly brand: 'value'; + + constructor(readonly value: unknown) { + super(); + } +} diff --git a/packages/core/src/expression-builders/where.ts b/packages/core/src/expression-builders/where.ts index c502d91cfa51..efb6f750ee0b 100644 --- a/packages/core/src/expression-builders/where.ts +++ b/packages/core/src/expression-builders/where.ts @@ -1,64 +1,126 @@ -import type { WhereAttributeHashValue, WhereOperators, AttributeOptions, ColumnReference } from '../model.js'; +import type { WhereAttributeHashValue, WhereOptions } from '../dialects/abstract/where-sql-builder-types.js'; +import { PojoWhere } from '../dialects/abstract/where-sql-builder.js'; +import type { WhereOperators } from '../model.js'; import type { Op } from '../operators.js'; +import type { Expression } from '../sequelize.js'; import { BaseSqlExpression } from './base-sql-expression.js'; -import type { Cast } from './cast.js'; -import type { Fn } from './fn.js'; -import type { Literal } from './literal.js'; - -export type WhereLeftOperand = Fn | ColumnReference | Literal | Cast | AttributeOptions; /** * Do not use me directly. Use {@link where} */ export class Where extends BaseSqlExpression { - // TODO [=7]: rename to leftOperand after typescript migration - private readonly attribute: WhereLeftOperand; - // TODO [=7]: rename to operator after typescript migration - private readonly comparator: string | Operator; - // TODO [=7]: rename to rightOperand after typescript migration - private readonly logic: WhereOperators[Operator] | WhereAttributeHashValue | any; + declare private readonly brand: 'where'; + + readonly where: PojoWhere | WhereOptions; + + /** + * @example + * ```ts + * where({ id: 1 }) + * ``` + * + * @param whereOptions + */ + constructor(whereOptions: WhereOptions); + + /** + * @example + * ```ts + * where(col('id'), { [Op.eq]: 1 }) + * ``` + * + * @param leftOperand + * @param whereAttributeHashValue + */ + constructor(leftOperand: Expression, whereAttributeHashValue: WhereAttributeHashValue); + + /** + * @example + * ```ts + * where(col('id'), Op.eq, 1) + * ``` + * + * @param leftOperand + * @param operator + * @param rightOperand + */ + constructor(leftOperand: Expression, operator: Operator, rightOperand: WhereOperators[Operator]); - constructor(leftOperand: WhereLeftOperand, operator: Operator, rightOperand: WhereOperators[Operator]); - constructor(leftOperand: WhereLeftOperand, operator: string, rightOperand: any); - constructor(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue); constructor( - leftOperand: WhereLeftOperand, - operatorOrRightOperand: string | Operator | WhereAttributeHashValue, - rightOperand?: WhereOperators[Operator] | any, + ...args: + | [whereOptions: WhereOptions] + | [leftOperand: Expression, whereAttributeHashValue: WhereAttributeHashValue] + | [leftOperand: Expression, operator: Operator, rightOperand: WhereOperators[Operator]] ) { super(); - this.attribute = leftOperand; - - if (rightOperand !== undefined) { - this.logic = rightOperand; - this.comparator = operatorOrRightOperand; + if (args.length === 1) { + this.where = args[0]; + } else if (args.length === 2) { + this.where = PojoWhere.create(args[0], args[1]); } else { - this.logic = operatorOrRightOperand; - this.comparator = '='; + if (typeof args[1] === 'string') { + // TODO: link to actual page + throw new TypeError(`where(left, operator, right) does not accept a string as the operator. Use one of the operators available in the Op object. +If you wish to use custom operators not provided by Sequelize, you can use the "sql" template literal tag. Refer to the documentation on custom operators on https://sequelize.org/ for more details.`); + } + + // normalize where(col, op, val) + // to where(col, { [op]: val }) + this.where = PojoWhere.create(args[0], { [args[1]]: args[2] }); } } } /** - * A way of specifying "attr = condition". - * Can be used as a replacement for the POJO syntax (e.g. `where: { name: 'Lily' }`) when you need to compare a column that the POJO syntax cannot represent. + * A way of writing an SQL binary operator, or more complex where conditions. + * + * This solution is slightly more verbose than the POJO syntax, but allows any value on the left hand side of the operator (unlike the POJO syntax which only accepts attribute names). + * For instance, either the left or right hand side of the operator can be {@link fn}, {@link col}, {@link literal} etc. + * + * If your left operand is an attribute name, using the regular POJO syntax (`{ where: { attrName: value }}`) syntax is usually more convenient. * - * @param leftOperand The left side of the comparison. - * - A value taken from YourModel.rawAttributes, to reference an attribute. - * The attribute must be defined in your model definition. - * - A Literal (using {@link literal}) - * - A SQL Function (using {@link fn}) - * - A Column name (using {@link col}) - * Note that simple strings to reference an attribute are not supported. You can use the POJO syntax instead. - * @param operator The comparison operator to use. If unspecified, defaults to {@link OpTypes.eq}. - * @param rightOperand The right side of the comparison. Its value depends on the used operator. - * See {@link WhereOperators} for information about what value is valid for each operator. + * ⚠️ Unlike the POJO syntax, if the left operand is a string, it will be treated as a _value_, not an attribute name. If you wish to refer to an attribute, use {@link attribute} instead. * * @example - * // Using an attribute as the left operand. - * // Equal to: WHERE first_name = 'Lily' - * where(User.rawAttributes.firstName, Op.eq, 'Lily'); + * ```ts + * where(attribute('id'), { [Op.eq]: 1 }); + * where(attribute('id'), { + * [Op.or]: { + * [Op.eq]: 1, + * [Op.gt]: 10, + * }, + * }); + * ``` + * + * @param leftOperand The left operand + * @param whereAttributeHashValue The POJO containing the operators and the right operands + */ +export function where(leftOperand: Expression, whereAttributeHashValue: WhereAttributeHashValue): Where; +/** + * This version of `where` is used to opt back into the POJO syntax. Useful in combination with {@link sql}. + * + * @example + * ```ts + * sequelize.query(sql` + * SELECT * FROM users WHERE ${where({ id: 1 })}; + * `) + * ``` + * + * produces + * + * ```sql + * SELECT * FROM users WHERE "id" = 1; + * ``` + * + * @param whereOptions + */ +export function where(whereOptions: WhereOptions): Where; +/** + * @example + * ```ts + * where(col('id'), Op.eq, 1) + * ``` * * @example * // Using a column name as the left operand. @@ -74,20 +136,17 @@ export class Where extends * // Using raw SQL as the left operand. * // Equal to: WHERE 'Lily' = 'Lily' * where(literal(`'Lily'`), Op.eq, 'Lily'); + * + * @param leftOperand The left operand + * @param operator The operator to use (one of the different values available in the {@link Op} object) + * @param rightOperand The right operand */ -export function where( - leftOperand: WhereLeftOperand | Where, - operator: OpSymbol, - rightOperand: WhereOperators[OpSymbol] -): Where; -export function where(leftOperand: any, operator: string, rightOperand: any): Where; -export function where(leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue): Where; - -export function where( +export function where(leftOperand: Expression, operator: keyof WhereOperators, rightOperand: Expression): Where; +export function where( ...args: - | [leftOperand: WhereLeftOperand | Where, operator: OpSymbol, rightOperand: WhereOperators[OpSymbol]] - | [leftOperand: any, operator: string, rightOperand: any] - | [leftOperand: WhereLeftOperand, rightOperand: WhereAttributeHashValue] + | [whereOptions: WhereOptions] + | [leftOperand: Expression, whereAttributeHashValue: WhereAttributeHashValue] + | [leftOperand: Expression, operator: keyof WhereOperators, rightOperand: Expression] ): Where { // @ts-expect-error -- they are the same type but this overload is internal return new Where(...args); diff --git a/packages/core/src/index.d.ts b/packages/core/src/index.d.ts index 9521998cb21d..913c684c8ea1 100644 --- a/packages/core/src/index.d.ts +++ b/packages/core/src/index.d.ts @@ -64,11 +64,21 @@ export { Deferrable } from './deferrable'; export { AbstractQueryGenerator } from './dialects/abstract/query-generator.js'; export { importModels } from './import-models.js'; export { ModelDefinition } from './model-definition.js'; +export type { WhereOptions } from './dialects/abstract/where-sql-builder-types.js'; + export { BaseSqlExpression } from './expression-builders/base-sql-expression.js'; +export { sql } from './expression-builders/sql.js'; +export { List } from './expression-builders/list.js'; +export { Value } from './expression-builders/value.js'; +export { Identifier } from './expression-builders/identifier.js'; +export { Attribute } from './expression-builders/attribute.js'; +export { JsonPath } from './expression-builders/json-path.js'; +export { AssociationPath } from './expression-builders/association-path.js'; +// All functions are available on sql.x, but these are exported for backwards compatibility export { literal, Literal } from './expression-builders/literal.js'; export { fn, Fn } from './expression-builders/fn.js'; export { col, Col } from './expression-builders/col.js'; export { cast, Cast } from './expression-builders/cast.js'; -export { json, Json } from './expression-builders/json.js'; +export { json } from './expression-builders/json.js'; export { where, Where } from './expression-builders/where.js'; diff --git a/packages/core/src/index.mjs b/packages/core/src/index.mjs index bfdd47792c0b..1c12efd05a36 100644 --- a/packages/core/src/index.mjs +++ b/packages/core/src/index.mjs @@ -11,9 +11,15 @@ export const Cast = Pkg.Cast; export const literal = Pkg.literal; export const Literal = Pkg.Literal; export const json = Pkg.json; -export const Json = Pkg.Json; export const where = Pkg.where; export const Where = Pkg.Where; +export const List = Pkg.List; +export const Identifier = Pkg.Identifier; +export const JsonPath = Pkg.JsonPath; +export const AssociationPath = Pkg.AssociationPath; +export const Attribute = Pkg.Attribute; +export const Value = Pkg.Value; +export const sql = Pkg.sql; export const and = Pkg.and; export const or = Pkg.or; diff --git a/packages/core/src/model-definition.ts b/packages/core/src/model-definition.ts index 71a89daa702a..b3bc08aa5b41 100644 --- a/packages/core/src/model-definition.ts +++ b/packages/core/src/model-definition.ts @@ -754,6 +754,42 @@ Specify a different name for either index to resolve this issue.`); return attribute?.columnName ?? attributeName; } + + /** + * Follows the association path and returns the association at the end of the path. + * For instance, say we have a model User, associated to a model Profile, associated to a model Address. + * + * If we call `User.modelDefinition.getAssociation(['profile', 'address'])`, we will get the association named `address` in the model Profile. + * If we call `User.modelDefinition.getAssociation(['profile'])`, we will get the association named `profile` in the model User. + * + * @param associationPath + */ + getAssociation(associationPath: readonly string[] | string): Association | undefined { + if (typeof associationPath === 'string') { + return this.associations[associationPath]; + } + + return this.#getAssociationFromPathMut([...associationPath]); + } + + #getAssociationFromPathMut(associationPath: string[]): Association | undefined { + if (associationPath.length === 0) { + return undefined; + } + + const associationName = associationPath.shift()!; + const association = this.associations[associationName]; + + if (association == null) { + return undefined; + } + + if (associationPath.length === 0) { + return association; + } + + return association.target.modelDefinition.#getAssociationFromPathMut(associationPath); + } } const modelDefinitions = new WeakMap(); diff --git a/packages/core/src/model.d.ts b/packages/core/src/model.d.ts index 76bd455fe0ba..5bb659f9f6ea 100644 --- a/packages/core/src/model.d.ts +++ b/packages/core/src/model.d.ts @@ -18,10 +18,12 @@ import type { TableNameWithSchema, IndexField, } from './dialects/abstract/query-interface'; +import type { + DynamicSqlExpression, +} from './expression-builders/base-sql-expression.js'; import type { Cast } from './expression-builders/cast.js'; import type { Col } from './expression-builders/col.js'; import type { Fn } from './expression-builders/fn.js'; -import type { Json } from './expression-builders/json.js'; import type { Literal } from './expression-builders/literal.js'; import type { Where } from './expression-builders/where.js'; import type { IndexHints } from './index-hints'; @@ -37,7 +39,7 @@ import type { Nullish, OmitConstructors, RequiredBy, } from './utils/types.js'; -import type { LOCK, Op, Transaction, TableHints } from './index'; +import type { LOCK, Op, Transaction, TableHints, WhereOptions } from './index'; export interface Logging { /** @@ -166,42 +168,6 @@ export interface ScopeOptions { type InvalidInSqlArray = ColumnReference | Fn | Cast | null | Literal; -/** - * This type allows using `Op.or`, `Op.and`, and `Op.not` recursively around another type. - * It also supports using a plain Array as an alias for `Op.and`. (unlike {@link AllowNotOrAndRecursive}). - * - * Example of plain-array treated as `Op.and`: - * ```ts - * User.findAll({ where: [{ id: 1 }, { id: 2 }] }); - * ``` - * - * Meant to be used by {@link WhereOptions}. - */ -type AllowNotOrAndWithImplicitAndArrayRecursive = AllowArray< - // this is the equivalent of Op.and - | T - | { [Op.or]: AllowArray> } - | { [Op.and]: AllowArray> } - | { [Op.not]: AllowNotOrAndWithImplicitAndArrayRecursive } - >; - -/** - * This type allows using `Op.or`, `Op.and`, and `Op.not` recursively around another type. - * Unlike {@link AllowNotOrAndWithImplicitAndArrayRecursive}, it does not allow the 'implicit AND Array'. - * - * Example of plain-array NOT treated as Op.and: - * ```ts - * User.findAll({ where: { id: [1, 2] } }); - * ``` - * - * Meant to be used by {@link WhereAttributeHashValue}. - */ -type AllowNotOrAndRecursive = - | T - | { [Op.or]: AllowArray> } - | { [Op.and]: AllowArray> } - | { [Op.not]: AllowNotOrAndRecursive }; - type AllowAnyAll = | T // Op.all: [x, z] results in ALL (ARRAY[x, z]) @@ -209,17 +175,6 @@ type AllowAnyAll = | { [Op.all]: Array> | Literal | { [Op.values]: Array> } } | { [Op.any]: Array> | Literal | { [Op.values]: Array> } }; -/** - * The type accepted by every `where` option - */ -export type WhereOptions = AllowNotOrAndWithImplicitAndArrayRecursive< - | WhereAttributeHash - | Literal - | Fn - | Where - | Json ->; - // number is always allowed because -Infinity & +Infinity are valid /** * This type represents a valid input when describing a {@link ~RANGE}. @@ -328,11 +283,8 @@ export interface WhereOperators { */ [Op.is]?: Extract | Literal; - /** - * @example `[Op.not]: true` becomes `IS NOT TRUE` - * @example `{ col: { [Op.not]: { [Op.gt]: 5 } } }` becomes `NOT (col > 5)` - */ - [Op.not]?: WhereOperators[typeof Op.eq]; // accepts the same types as Op.eq ('Op.not' is not strictly the opposite of 'Op.is' due to legacy reasons) + /** Example: `[Op.isNot]: null,` becomes `IS NOT NULL` */ + [Op.isNot]?: WhereOperators[typeof Op.is]; // accepts the same types as Op.is /** @example `[Op.gte]: 6` becomes `>= 6` */ [Op.gte]?: AllowAnyAll>>; @@ -625,52 +577,6 @@ export interface WhereGeometryOptions { coordinates: ReadonlyArray; } -/** - * A hash of attributes to describe your search. - * - * Possible key values: - * - * - An attribute name: `{ id: 1 }` - * - A nested attribute: `{ '$projects.id$': 1 }` - * - A JSON key: `{ 'object.key': 1 }` - * - A cast: `{ 'id::integer': 1 }` - * - * - A combination of the above: `{ '$join.attribute$.json.path::integer': 1 }` - */ -export type WhereAttributeHash = { - // support 'attribute' & '$attribute$' - [AttributeName in keyof TAttributes as AttributeName extends string ? AttributeName | `$${AttributeName}$` : never]?: WhereAttributeHashValue; -} & { - [AttributeName in keyof TAttributes as AttributeName extends string ? - // support 'json.path', '$json$.path' - | `${AttributeName}.${string}` | `$${AttributeName}$.${string}` - // support 'attribute::cast', '$attribute$::cast', 'json.path::cast' & '$json$.path::cast' - | `${AttributeName | `$${AttributeName}$` | `${AttributeName}.${string}` | `$${AttributeName}$.${string}`}::${string}` - : never]?: WhereAttributeHashValue; -} & { - // support '$nested.attribute$', '$nested.attribute$::cast', '$nested.attribute$.json.path', & '$nested.attribute$.json.path::cast' - [attribute: `$${string}.${string}$` | `$${string}.${string}$::${string}` | `$${string}.${string}$.${string}` | `$${string}.${string}$.${string}::${string}`]: WhereAttributeHashValue, -}; - -/** - * Types that can be compared to an attribute in a WHERE context. - */ -export type WhereAttributeHashValue = - | AllowNotOrAndRecursive< - // if the right-hand side is an array, it will be equal to Op.in - // otherwise it will be equal to Op.eq - // Exception: array attribtues always use Op.eq, never Op.in. - | AttributeType extends any[] - ? WhereOperators[typeof Op.eq] | WhereOperators - : ( - | WhereOperators[typeof Op.in] - | WhereOperators[typeof Op.eq] - | WhereOperators - ) - > - // TODO: this needs a simplified version just for JSON columns - | WhereAttributeHash; // for JSON columns - /** * Through options for Include Options */ @@ -834,7 +740,10 @@ export type Order = Fn | Col | Literal | OrderItem[]; * Please note if this is used the aliased property will not be available on the model instance * as a property but only via `instance.get('alias')`. */ -export type ProjectionAlias = readonly [string | Literal | Fn | Col | Cast, string]; +export type ProjectionAlias = readonly [ + expressionOrAttributeName: string | DynamicSqlExpression, + alias: string, +]; export type FindAttributeOptions = | Array @@ -1890,11 +1799,6 @@ export type ModelAttributes = { [name in keyof TAttributes]: DataType | AttributeOptions; }; -/** - * Possible types for primary keys - */ -export type Identifier = number | bigint | string | Buffer; - /** * Options for model definition. * @@ -2324,7 +2228,7 @@ export abstract class Model( this: ModelStatic, - scopes?: Nullish | WhereAttributeHash>, + scopes?: Nullish | WhereOptions>>, ): ModelStatic; /** @@ -2333,7 +2237,7 @@ export abstract class Model( this: ModelStatic, - scopes?: Nullish | WhereAttributeHash>, + scopes?: Nullish | WhereOptions>>, ): ModelStatic; /** diff --git a/packages/core/src/model.js b/packages/core/src/model.js index 3e809ad84a54..1adf5e8a843f 100644 --- a/packages/core/src/model.js +++ b/packages/core/src/model.js @@ -16,7 +16,6 @@ import { mapFinderOptions, mapOptionFieldNames, mapValueFieldNames, - mapWhereFieldNames, } from './utils/format'; import { every, find } from './utils/iterators'; import { cloneDeep, mergeDefaults, defaults, flattenObjectDeep, getObjectFromMap, EMPTY_OBJECT } from './utils/object'; @@ -3102,7 +3101,7 @@ Instead of specifying a Model, either: where[versionAttr] = this.get(versionAttr, { raw: true }); } - return mapWhereFieldNames(where, this.constructor); + return where; } toString() { @@ -3888,7 +3887,7 @@ Instead of specifying a Model, either: throw new Error('You attempted to update an instance that is not persisted.'); } - options = options || EMPTY_OBJECT; + options = options ?? EMPTY_OBJECT; if (Array.isArray(options)) { options = { fields: options }; } diff --git a/packages/core/src/operators.ts b/packages/core/src/operators.ts index d352cffd583d..e9900961146e 100644 --- a/packages/core/src/operators.ts +++ b/packages/core/src/operators.ts @@ -209,6 +209,19 @@ export interface OpTypes { * ``` */ readonly is: unique symbol; + + /** + * Operator IS NOT + * + * ```js + * [Op.isNot]: null + * ``` + * In SQL + * ```sql + * IS NOT null + * ``` + */ + readonly isNot: unique symbol; /** * Operator LIKE * @@ -437,14 +450,6 @@ export interface OpTypes { * ``` */ readonly overlap: unique symbol; - /** - * Internal placeholder - * - * ```js - * [Op.placeholder]: true - * ``` - */ - readonly placeholder: unique symbol; /** * Operator REGEXP (MySQL/PG only) * @@ -557,6 +562,7 @@ export const Op: OpTypes = { lt: Symbol.for('lt'), not: Symbol.for('not'), is: Symbol.for('is'), + isNot: Symbol.for('isNot'), in: Symbol.for('in'), notIn: Symbol.for('notIn'), like: Symbol.for('like'), @@ -589,8 +595,6 @@ export const Op: OpTypes = { all: Symbol.for('all'), values: Symbol.for('values'), col: Symbol.for('col'), - placeholder: Symbol.for('placeholder'), - join: Symbol.for('join'), match: Symbol.for('match'), anyKeyExists: Symbol.for('anyKeyExists'), allKeysExist: Symbol.for('allKeysExist'), diff --git a/packages/core/src/sequelize.d.ts b/packages/core/src/sequelize.d.ts index f428cd66fbb7..858e98de8d0b 100644 --- a/packages/core/src/sequelize.d.ts +++ b/packages/core/src/sequelize.d.ts @@ -4,6 +4,7 @@ import type { AbstractConnectionManager } from './dialects/abstract/connection-m import type { AbstractDataType, DataType, DataTypeClassOrInstance } from './dialects/abstract/data-types.js'; import type { AbstractQueryInterface, ColumnsDescription } from './dialects/abstract/query-interface'; import type { CreateSchemaOptions } from './dialects/abstract/query-interface.types'; +import type { DynamicSqlExpression } from './expression-builders/base-sql-expression.js'; import type { cast } from './expression-builders/cast.js'; import type { col } from './expression-builders/col.js'; import type { fn, Fn } from './expression-builders/fn.js'; @@ -21,6 +22,7 @@ import type { Hookable, ModelStatic, Attributes, + ColumnReference, Transactionable, Poolable, } from './model'; @@ -147,13 +149,6 @@ export interface NormalizedReplicationOptions { write: ConnectionOptions; } -/** - * Used to map operators to their Symbol representations - */ -export interface OperatorsAliases { - [K: string]: symbol; -} - /** * Final config options generated by sequelize. */ @@ -388,15 +383,6 @@ export interface Options extends Logging { */ noTypeValidation?: boolean; - /** - * Sets available operator aliases. - * See (https://sequelize.org/docs/v7/core-concepts/model-querying-basics/#operators) for more information. - * WARNING: Setting this to boolean value was deprecated and is no-op. - * - * @default all aliases - */ - operatorsAliases?: OperatorsAliases; - /** * The PostgreSQL `standard_conforming_strings` session parameter. Set to `false` to not set the option. * WARNING: Setting this to false may expose vulnerabilities and is not recommended! @@ -1121,3 +1107,5 @@ export function and(...args: T): { [Op.and]: T }; * @param args Each argument will be joined by OR */ export function or(...args: T): { [Op.or]: T }; + +export type Expression = ColumnReference | DynamicSqlExpression | unknown; diff --git a/packages/core/src/sequelize.js b/packages/core/src/sequelize.js index cf7e3ee32258..64daef0e98b0 100644 --- a/packages/core/src/sequelize.js +++ b/packages/core/src/sequelize.js @@ -3,10 +3,17 @@ import isPlainObject from 'lodash/isPlainObject'; import retry from 'retry-as-promised'; import { normalizeDataType } from './dialects/abstract/data-types-utils'; +import { AssociationPath } from './expression-builders/association-path'; +import { Attribute } from './expression-builders/attribute'; +import { Identifier } from './expression-builders/identifier'; +import { JsonPath } from './expression-builders/json-path'; +import { Value } from './expression-builders/value'; +import { List } from './expression-builders/list'; +import { sql } from './expression-builders/sql'; import { Cast, cast } from './expression-builders/cast.js'; import { Col, col } from './expression-builders/col.js'; import { Fn, fn } from './expression-builders/fn.js'; -import { Json, json } from './expression-builders/json.js'; +import { json } from './expression-builders/json.js'; import { Literal, literal } from './expression-builders/literal.js'; import { Where, where } from './expression-builders/where.js'; import { SequelizeTypeScript } from './sequelize-typescript'; @@ -195,7 +202,6 @@ export class Sequelize extends SequelizeTypeScript { * @param {Function} [options.retry.report] Function that is executed after each retry, called with a message and the current retry options. * @param {string} [options.retry.name='unknown'] Name used when composing error/reporting messages. * @param {boolean} [options.noTypeValidation=false] Run built-in type validators on insert and update, and select with where clause, e.g. validate that arguments passed to integer fields are integer-like. - * @param {object} [options.operatorsAliases] String based operator alias. Pass object to limit set of aliased operators. * @param {object} [options.hooks] An object of global hook functions that are called before and after certain lifecycle events. Global hooks will run after any model-specific hooks defined for the same event (See `Sequelize.Model.init()` for a list). Additionally, `beforeConnect()`, `afterConnect()`, `beforeDisconnect()`, and `afterDisconnect()` hooks may be defined here. * @param {boolean} [options.minifyAliases=false] A flag that defines if aliases should be minified (mostly useful to avoid Postgres alias character limit of 64) * @param {boolean} [options.logQueryParameters=false] A flag that defines if show bind parameters in log. @@ -407,13 +413,8 @@ export class Sequelize extends SequelizeTypeScript { throw new Error(`Setting a custom timezone is not supported by ${this.dialect.name}, dates are always returned as UTC. Please remove the custom timezone option.`); } - this.dialect.queryGenerator.noTypeValidation = options.noTypeValidation; - - if (_.isPlainObject(this.options.operatorsAliases)) { - deprecations.noStringOperators(); - this.dialect.queryGenerator.setOperatorsAliases(this.options.operatorsAliases); - } else if (typeof this.options.operatorsAliases === 'boolean') { - deprecations.noBoolOperatorAliases(); + if (this.options.operatorsAliases) { + throw new Error('String based operators have been removed. Please use Symbol operators, read more at https://sequelize.org/docs/v7/core-concepts/model-querying-basics/#deprecated-operator-aliases'); } /** @@ -1095,30 +1096,28 @@ Use Sequelize#query if you wish to use replacements.`); return fn('RAND'); } - static fn = fn; - static Fn = Fn; - - static col = col; - static Col = Col; - - static cast = cast; - static Cast = Cast; - - static literal = literal; - static Literal = Literal; + static Where = Where; + static List = List; + static Identifier = Identifier; + static Attribute = Attribute; + static Value = Value; + static AssociationPath = AssociationPath; + static JsonPath = JsonPath; - static json = json; - - static Json = Json; + static sql = sql; + // these are all available on the "sql" object, but are exposed for backwards compatibility + static fn = fn; + static col = col; + static cast = cast; + static literal = literal; + static json = json; static where = where; - static Where = Where; - static and = and; static or = or; @@ -1370,6 +1369,9 @@ export function and(...args) { * @returns {Sequelize.or} */ export function or(...args) { + if (args.length === 1) { + return { [Op.or]: args[0] }; + } + return { [Op.or]: args }; } - diff --git a/packages/core/src/sql-string.ts b/packages/core/src/sql-string.ts index 01fed8bf545a..9c6f3ea91cca 100644 --- a/packages/core/src/sql-string.ts +++ b/packages/core/src/sql-string.ts @@ -3,24 +3,6 @@ import type { AbstractDataType } from './dialects/abstract/data-types.js'; import type { AbstractDialect } from './dialects/abstract/index.js'; import { logger } from './utils/logger'; -function arrayToList(array: unknown[], dialect: AbstractDialect, format: boolean) { - // TODO: rewrite - // eslint-disable-next-line unicorn/no-array-reduce - return array.reduce((sql: string, val, i) => { - if (i !== 0) { - sql += ', '; - } - - if (Array.isArray(val)) { - sql += `(${arrayToList(val, dialect, format)})`; - } else { - sql += escape(val, dialect, format); - } - - return sql; - }, ''); -} - const textDataTypeMap = new Map>(); export function getTextDataTypeForDialect(dialect: AbstractDialect): AbstractDataType { let type = textDataTypeMap.get(dialect.name); @@ -32,7 +14,7 @@ export function getTextDataTypeForDialect(dialect: AbstractDialect): AbstractDat return type; } -function bestGuessDataTypeOfVal(val: unknown, dialect: AbstractDialect): AbstractDataType { +export function bestGuessDataTypeOfVal(val: unknown, dialect: AbstractDialect): AbstractDataType { // TODO: cache simple types switch (typeof val) { case 'bigint': @@ -81,29 +63,3 @@ function bestGuessDataTypeOfVal(val: unknown, dialect: AbstractDialect): Abstrac throw new TypeError(`Could not guess type of value ${logger.inspect(val)}`); } - -export function escape( - val: unknown, - dialect: AbstractDialect, - format: boolean = false, -): string { - const dialectName = dialect.name; - - if (val == null) { - // There are cases in Db2 for i where 'NULL' isn't accepted, such as - // comparison with a WHERE() statement. In those cases, we have to cast. - if (dialectName === 'ibmi' && format) { - return 'cast(NULL as int)'; - } - - return 'NULL'; - } - - if (Array.isArray(val) && (dialectName !== 'postgres' || format)) { - return arrayToList(val, dialect, format); - } - - const dataType = bestGuessDataTypeOfVal(val, dialect); - - return dataType.escape(val); -} diff --git a/packages/core/src/utils/attribute-syntax.ts b/packages/core/src/utils/attribute-syntax.ts new file mode 100644 index 000000000000..2094c972c696 --- /dev/null +++ b/packages/core/src/utils/attribute-syntax.ts @@ -0,0 +1,270 @@ +import type { SyntaxNode } from 'bnf-parser'; +import { BNF, Compile, ParseError } from 'bnf-parser'; +import memoize from 'lodash/memoize.js'; +import type { Class } from 'type-fest'; +import { AssociationPath } from '../expression-builders/association-path.js'; +import { Attribute } from '../expression-builders/attribute.js'; +import { Cast } from '../expression-builders/cast.js'; +import type { DialectAwareFn } from '../expression-builders/dialect-aware-fn.js'; +import { Unquote } from '../expression-builders/dialect-aware-fn.js'; +import { JsonPath } from '../expression-builders/json-path.js'; +import { noPrototype } from './object.js'; + +/** + * Parses the attribute syntax (the syntax of keys in WHERE POJOs) into its "BaseExpression" representation. + * + * @example + * ```ts + * parseAttribute('id') // => attribute('id') + * parseAttribute('$user.id$') // => association(['user'], 'id') + * parseAttribute('json.key') // => jsonPath(attribute('json'), ['key']) + * parseAttribute('name::number') // => cast(attribute('name'), 'number') + * parseAttribute('json.key::number') // => cast(jsonPath(attribute('json'), ['key']), 'number') + * ``` + * + * @param attribute The syntax to parse + */ +export const parseAttributeSyntax = memoize(parseAttributeSyntaxInternal); + +/** + * Parses the syntax supported by nested JSON properties. + * This is a subset of {@link parseAttributeSyntax}, which does not parse associations, and returns raw data + * instead of a BaseExpression. + */ +export const parseNestedJsonKeySyntax = memoize(parseJsonPropertyKeyInternal); + +/** + * List of supported attribute modifiers. + * They can be specified in the attribute syntax, e.g. `foo:upper` will call the `upper` modifier on the `foo` attribute. + * + * All names should be lowercase, as they are case-insensitive. + */ +const builtInModifiers: Record> = noPrototype({ + unquote: Unquote, +}); + +function getModifier(name: string): Class { + const ModifierClass = builtInModifiers[name.toLowerCase()]; + if (!ModifierClass) { + throw new Error(`${name} is not a recognized built-in modifier. Here is the list of supported modifiers: ${Object.keys(builtInModifiers).join(', ')}`); + } + + return ModifierClass; +} + +const attributeParser = (() => { + const advancedAttributeBnf = ` + # Entry points + + ## Used when parsing the attribute + attribute ::= ( ...association | ...identifier ) jsonPath? castOrModifiers?; + + ## Used when parsing a nested JSON path used inside of an attribute + ## Difference with "attribute" is in the first part. Instead of accepting: + ## $association.attribute$ & attribute + ## It accepts: + ## key, "quotedKey", and [0] (index access) + partialJsonPath ::= ( ...indexAccess | ...key ) jsonPath? castOrModifiers? ; + + # Internals + + identifier ::= ( "A"->"Z" | "a"->"z" | digit | "_" )+ ; + digit ::= "0"->"9" ; + number ::= ...digit+ ; + association ::= %"$" identifier ("." identifier)* %"$" ; + jsonPath ::= ( ...indexAccess | ...keyAccess )+ ; + indexAccess ::= %"[" number %"]" ; + keyAccess ::= %"." key ; + # path segments accept dashes without needing to be quoted + key ::= nonEmptyString | ( "A"->"Z" | "a"->"z" | digit | "_" | "-" )+ ; + nonEmptyString ::= ...(%"\\"" (anyExceptQuoteOrBackslash | escapedCharacter)+ %"\\"") ; + escapedCharacter ::= %"\\\\" ( "\\"" | "\\\\" ); + any ::= !"" ; + anyExceptQuoteOrBackslash ::= !("\\"" | "\\\\"); + castOrModifiers ::= (...cast | ...modifier)+; + cast ::= %"::" identifier ; + modifier ::= %":" identifier ; + `; + + const parsedAttributeBnf = BNF.parse(advancedAttributeBnf); + if (parsedAttributeBnf instanceof ParseError) { + // eslint-disable-next-line -- false positive + throw new Error(`Failed to initialize attribute syntax parser. This is a Sequelize bug: ${parsedAttributeBnf.toString()}`); + } + + return Compile(parsedAttributeBnf); +})(); + +interface UselessNode extends SyntaxNode { + type: Type; + value: WrappedValue; +} + +export interface StringNode extends SyntaxNode { + type: Type; + value: string; +} + +interface AttributeAst extends SyntaxNode { + type: 'attribute'; + value: [ + attribute: StringNode<'association' | 'identifier'>, + jsonPath: UselessNode<'jsonPath?', [ + UselessNode<'jsonPath', [ + UselessNode< + '(...)+', + Array> + >, + ]>, + ]>, + castOrModifiers: UselessNode<'castOrModifiers?', [ + UselessNode<'castOrModifiers', [ + UselessNode< + '(...)+', + Array> + >, + ]>, + ]>, + ]; +} + +function parseAttributeSyntaxInternal( + code: string, +): Cast | JsonPath | AssociationPath | Attribute | DialectAwareFn { + // This function is expensive (parsing produces a lot of objects), but we cache the final result, so it's only + // going to be slow once per attribute. + const parsed = attributeParser.parse(code, false, 'attribute') as AttributeAst | ParseError; + if (parsed instanceof ParseError) { + throw new TypeError(`Failed to parse syntax of attribute. Parse error at index ${parsed.ref.start.index}: +${code} +${' '.repeat(parsed.ref.start.index)}^`); + } + + const [attributeNode, jsonPathNodeRaw, castOrModifiersNodeRaw] = parsed.value; + + let result: Cast | JsonPath | AssociationPath | Attribute | DialectAwareFn = parseAssociationPath(attributeNode.value); + + const jsonPathNodes = jsonPathNodeRaw.value[0]?.value[0].value; + if (jsonPathNodes) { + const path = jsonPathNodes.map(pathNode => { + return parseJsonPathSegment(pathNode); + }); + + result = new JsonPath(result, path); + } + + const castOrModifierNodes = castOrModifiersNodeRaw.value[0]?.value[0].value; + if (castOrModifierNodes) { + + // casts & modifiers can be chained, the last one is applied last + // foo:upper:lower needs to produce LOWER(UPPER(foo)) + for (const castOrModifierNode of castOrModifierNodes) { + + if (castOrModifierNode.type === 'cast') { + result = new Cast(result, castOrModifierNode.value); + continue; + } + + const ModifierClass = getModifier(castOrModifierNode.value); + + result = new ModifierClass(result); + } + } + + return result; +} + +function parseAssociationPath(syntax: string): AssociationPath | Attribute { + const path = syntax.split('.'); + + if (path.length > 1) { + const attr = path.pop()!; + + return new AssociationPath(path, attr); + } + + return new Attribute(syntax); +} + +/** + * Do not mutate this! It is memoized to avoid re-parsing the same path over and over. + */ +export interface ParsedJsonPropertyKey { + readonly pathSegments: ReadonlyArray; + /** + * If it's a string, it's a cast. If it's a class, it's a modifier. + */ + readonly castsAndModifiers: ReadonlyArray>; +} + +interface JsonPathAst extends SyntaxNode { + type: 'partialJsonPath'; + value: [ + firstKey: StringNode<'key' | 'indexAccess'>, + jsonPath: UselessNode<'jsonPath?', [ + UselessNode<'jsonPath', [ + UselessNode< + '(...)+', + Array> + >, + ]>, + ]>, + castOrModifiers: UselessNode<'castOrModifiers?', [ + UselessNode<'castOrModifiers', [ + UselessNode< + '(...)+', + Array> + >, + ]>, + ]>, + ]; +} + +function parseJsonPropertyKeyInternal(code: string): ParsedJsonPropertyKey { + const parsed = attributeParser.parse(code, false, 'partialJsonPath') as JsonPathAst | ParseError; + if (parsed instanceof ParseError) { + throw new TypeError(`Failed to parse syntax of json path. Parse error at index ${parsed.ref.start.index}: +${code} +${' '.repeat(parsed.ref.start.index)}^`); + } + + const [firstKey, jsonPathNodeRaw, castOrModifiersNodeRaw] = parsed.value; + + const pathSegments: Array = [parseJsonPathSegment(firstKey)]; + + const jsonPathNodes = jsonPathNodeRaw.value[0]?.value[0].value; + if (jsonPathNodes) { + for (const pathNode of jsonPathNodes) { + pathSegments.push(parseJsonPathSegment(pathNode)); + } + } + + const castOrModifierNodes = castOrModifiersNodeRaw.value[0]?.value[0].value; + const castsAndModifiers: Array> = []; + + if (castOrModifierNodes) { + // casts & modifiers can be chained, the last one is applied last + // foo:upper:lower needs to produce LOWER(UPPER(foo)) + for (const castOrModifierNode of castOrModifierNodes) { + + if (castOrModifierNode.type === 'cast') { + castsAndModifiers.push(castOrModifierNode.value); + continue; + } + + const ModifierClass = getModifier(castOrModifierNode.value); + + castsAndModifiers.push(ModifierClass); + } + } + + return { pathSegments, castsAndModifiers }; +} + +function parseJsonPathSegment(node: StringNode): string | number { + if (node.type === 'indexAccess') { + return Number(node.value); + } + + return node.value; +} diff --git a/packages/core/src/utils/deprecations.ts b/packages/core/src/utils/deprecations.ts index d4c1854bff27..2a34bc0e8b0f 100644 --- a/packages/core/src/utils/deprecations.ts +++ b/packages/core/src/utils/deprecations.ts @@ -3,8 +3,6 @@ import { deprecate } from 'node:util'; const noop = () => { /* noop */ }; export const noTrueLogging = deprecate(noop, 'The logging-option should be either a function or false. Default: console.log', 'SEQUELIZE0002'); -export const noStringOperators = deprecate(noop, 'String based operators are deprecated. Please use Symbol based operators for better security, read more at https://sequelize.org/docs/v7/core-concepts/model-querying-basics/#deprecated-operator-aliases', 'SEQUELIZE0003'); -export const noBoolOperatorAliases = deprecate(noop, 'A boolean value was passed to options.operatorsAliases. This is a no-op with v5 and should be removed.', 'SEQUELIZE0004'); export const noDoubleNestedGroup = deprecate(noop, 'Passing a double nested nested array to `group` is unsupported and will be removed in v6.', 'SEQUELIZE0005'); export const unsupportedEngine = deprecate(noop, 'This database engine version is not supported, please update your database server. More information https://github.com/sequelize/sequelize/blob/main/ENGINE.md', 'SEQUELIZE0006'); export const useErrorCause = deprecate(noop, 'The "parent" and "original" properties in Sequelize errors have been replaced with the native "cause" property. Use that one instead.', 'SEQUELIZE0007'); @@ -22,3 +20,5 @@ export const columnToAttribute = deprecate(noop, 'The @Column decorator has been export const fieldToColumn = deprecate(noop, 'The "field" option in attribute definitions has been renamed to "columnName".', 'SEQUELIZE0018'); export const noModelTableName = deprecate(noop, 'Model.tableName has been replaced with the more complete Model.modelDefinition.table, or Model.table', 'SEQUELIZE0019'); export const noNewModel = deprecate(noop, `Do not use "new YourModel()" to instantiate a model. Use "YourModel.build()" instead. The previous option is being removed to resolve a conflict with class properties. See https://github.com/sequelize/sequelize/issues/14300#issuecomment-1355188077 for more information.`, 'SEQUELIZE0020'); +export const noOpCol = deprecate(noop, 'Do not use Op.col, use col(), attribute(), or identifier() instead. Read more about these in the Raw Queries guide in the sequelize docs.', 'SEQUELIZE0021'); +export const noSqlJson = deprecate(noop, 'The json() function used to generate JSON queries is deprecated. All of its features are available through where(), attribute() or jsonPath(). Some of its features have been removed but can be replicated using the "sql" tag. See our Sequelize 7 upgrade guide.', 'SEQUELIZE0022'); diff --git a/packages/core/src/utils/format.ts b/packages/core/src/utils/format.ts index 58ce5532816b..87dc61892fd4 100644 --- a/packages/core/src/utils/format.ts +++ b/packages/core/src/utils/format.ts @@ -1,10 +1,6 @@ import assert from 'node:assert'; import forIn from 'lodash/forIn'; -import isPlainObject from 'lodash/isPlainObject'; import type { Attributes, Model, ModelStatic, NormalizedAttributeOptions, WhereOptions } from '..'; -import * as DataTypes from '../data-types'; -import { isString } from './check.js'; -import { getComplexKeys } from './where.js'; export type FinderOptions = { attributes?: string[], @@ -44,10 +40,12 @@ export function mapFinderOptions( }); } - if (options.where != null && isPlainObject(options.where)) { - // eslint-disable-next-line @typescript-eslint/prefer-ts-expect-error -- this fails in TS 4.4 and up, but not before - // @ts-ignore the work necessary to type the return type of mapWhereFieldNames is not worth it - out.where = mapWhereFieldNames(options.where, Model); - } - return out; } -export function mapWhereFieldNames(where: Record, Model: ModelStatic): object { - if (!where) { - return where; - } - - const modelDefinition = Model.modelDefinition; - - const newWhere: Record = Object.create(null); - for (const attributeNameOrOperator of getComplexKeys(where)) { - const rawAttribute: NormalizedAttributeOptions | undefined = isString(attributeNameOrOperator) - ? modelDefinition.attributes.get(attributeNameOrOperator) - : undefined; - - const columnNameOrOperator: PropertyKey = rawAttribute?.field ?? attributeNameOrOperator; - - if ( - isPlainObject(where[attributeNameOrOperator]) - && !( - rawAttribute - && (rawAttribute.type instanceof DataTypes.HSTORE - || rawAttribute.type instanceof DataTypes.JSON) - ) - ) { - // Prevent renaming of HSTORE & JSON fields - newWhere[columnNameOrOperator] = mapOptionFieldNames( - { - where: where[attributeNameOrOperator], - }, - Model, - ).where; - - continue; - } - - if (Array.isArray(where[attributeNameOrOperator])) { - newWhere[columnNameOrOperator] = [...where[attributeNameOrOperator]]; - - for (const [index, wherePart] of where[attributeNameOrOperator].entries()) { - if (isPlainObject(wherePart)) { - newWhere[columnNameOrOperator][index] = mapWhereFieldNames(wherePart, Model); - } - } - - continue; - } - - newWhere[columnNameOrOperator] = where[attributeNameOrOperator]; - } - - return newWhere; -} - -export function combineTableNames(tableName1: string, tableName2: string): string { - return tableName1.toLowerCase() < tableName2.toLowerCase() - ? tableName1 + tableName2 - : tableName2 + tableName1; -} - /** * Used to map field names in values * diff --git a/packages/core/src/utils/sql.ts b/packages/core/src/utils/sql.ts index 64f55686c774..7a30e2b1f9ea 100644 --- a/packages/core/src/utils/sql.ts +++ b/packages/core/src/utils/sql.ts @@ -1,7 +1,8 @@ import isPlainObject from 'lodash/isPlainObject'; import type { AbstractDialect, BindCollector } from '../dialects/abstract/index.js'; +import type { EscapeOptions } from '../dialects/abstract/query-generator-typescript.js'; +import { BaseSqlExpression } from '../expression-builders/base-sql-expression.js'; import type { BindOrReplacements } from '../sequelize.js'; -import { escape as escapeSqlValue } from '../sql-string'; type OnBind = (oldName: string) => string; @@ -30,6 +31,8 @@ function mapBindParametersAndReplacements( ): string { const isNamedReplacements = isPlainObject(replacements); const isPositionalReplacements = Array.isArray(replacements); + const escapeOptions: EscapeOptions = { replacements }; + let lastConsumedPositionalReplacementIndex = -1; let output: string = ''; @@ -203,7 +206,7 @@ function mapBindParametersAndReplacements( throw new Error(`Named replacement ":${replacementName}" has no entry in the replacement map.`); } - const escapedReplacement = escapeSqlValue(replacementValue, dialect, true); + const escapedReplacement = escapeValueWithBackCompat(replacementValue, dialect, escapeOptions); // add everything before the bind parameter name output += sqlString.slice(previousSliceEnd, i); @@ -244,7 +247,7 @@ function mapBindParametersAndReplacements( throw new Error(`Positional replacement (?) ${replacementIndex} has no entry in the replacement map (replacements[${replacementIndex}] is undefined).`); } - const escapedReplacement = escapeSqlValue(replacementValue, dialect, true); + const escapedReplacement = escapeValueWithBackCompat(replacementValue, dialect, escapeOptions); // add everything before the bind parameter name output += sqlString.slice(previousSliceEnd, i); @@ -264,6 +267,19 @@ function mapBindParametersAndReplacements( return output; } +function escapeValueWithBackCompat(value: unknown, dialect: AbstractDialect, escapeOptions: EscapeOptions): string { + // Arrays used to be escaped as sql lists, not sql arrays + // now they must be escaped as sql arrays, and the old behavior has been moved to the list() function. + // The problem is that if we receive a list of list, there are cases where we don't want the extra parentheses around the list, + // such as in the case of a bulk insert. + // As a workaround, non-list arrays that contain dynamic values are joined with commas. + if (Array.isArray(value) && value.some(item => item instanceof BaseSqlExpression)) { + return value.map(item => dialect.queryGenerator.escape(item, escapeOptions)).join(', '); + } + + return dialect.queryGenerator.escape(value, escapeOptions); +} + function canPrecedeNewToken(char: string | undefined): boolean { return char === undefined || /[\s(>,=]/.test(char); } diff --git a/packages/core/test/integration/data-types/data-types.test.ts b/packages/core/test/integration/data-types/data-types.test.ts index 180686af00d6..159cc89ab84a 100644 --- a/packages/core/test/integration/data-types/data-types.test.ts +++ b/packages/core/test/integration/data-types/data-types.test.ts @@ -1343,6 +1343,25 @@ describe('DataTypes', () => { return { User }; }); + it('produces the right DataType in the database', async () => { + const table = await sequelize.queryInterface.describeTable(vars.User.table); + switch (dialect.name) { + // mssql & sqlite use text columns with CHECK constraints + case 'mssql': + expect(table.jsonStr.type).to.equal('NVARCHAR(MAX)'); + break; + case 'sqlite': + expect(table.jsonStr.type).to.equal('TEXT'); + break; + case 'mariadb': + // TODO: expected for mariadb 10.4 : https://jira.mariadb.org/browse/MDEV-15558 + expect(table.jsonStr.type).to.equal('LONGTEXT'); + break; + default: + expect(table.jsonStr.type).to.equal(jsonTypeName); + } + }); + it('properly serializes default values', async () => { const createdUser = await vars.User.create(); await createdUser.reload(); @@ -1383,8 +1402,7 @@ describe('DataTypes', () => { await testSimpleInOutRaw(vars.User, 'jsonNumber', 123.4, '123.4'); await testSimpleInOutRaw(vars.User, 'jsonArray', [1, 2], '[1,2]'); await testSimpleInOutRaw(vars.User, 'jsonObject', { a: 1 }, '{"a":1}'); - // this isn't the JSON null value, but a SQL null value - await testSimpleInOutRaw(vars.User, 'jsonNull', null, null); + await testSimpleInOutRaw(vars.User, 'jsonNull', null, 'null'); }); } else { it(`is deserialized as a parsed JSON value when DataType is not specified`, async () => { diff --git a/packages/core/test/integration/dialects/postgres/dao.test.js b/packages/core/test/integration/dialects/postgres/dao.test.js index 81ee826a7cbc..1dc99a046627 100644 --- a/packages/core/test/integration/dialects/postgres/dao.test.js +++ b/packages/core/test/integration/dialects/postgres/dao.test.js @@ -8,246 +8,267 @@ const Support = require('../../support'); const dialect = Support.getTestDialect(); const { DataTypes, Op, json } = require('@sequelize/core'); -if (dialect.startsWith('postgres')) { - describe('[POSTGRES Specific] DAO', () => { - beforeEach(async function () { - this.sequelize.options.quoteIdentifiers = true; - this.User = this.sequelize.define('User', { - username: DataTypes.STRING, - email: { type: DataTypes.ARRAY(DataTypes.TEXT) }, - settings: DataTypes.HSTORE, - document: { type: DataTypes.HSTORE, defaultValue: { default: '\'value\'' } }, - phones: DataTypes.ARRAY(DataTypes.HSTORE), - emergency_contact: DataTypes.JSON, - emergencyContact: DataTypes.JSON, - friends: { - type: DataTypes.ARRAY(DataTypes.JSON), - defaultValue: [], - }, - magic_numbers: { - type: DataTypes.ARRAY(DataTypes.INTEGER), - defaultValue: [], - }, - course_period: DataTypes.RANGE(DataTypes.DATE), - acceptable_marks: { type: DataTypes.RANGE(DataTypes.DECIMAL), defaultValue: [0.65, 1] }, - available_amount: DataTypes.RANGE, - holidays: DataTypes.ARRAY(DataTypes.RANGE(DataTypes.DATE)), - location: DataTypes.GEOMETRY(), - }); - await this.User.sync({ force: true }); +describe('[POSTGRES Specific] DAO', () => { + if (dialect !== 'postgres') { + return; + } + + beforeEach(async function () { + this.sequelize.options.quoteIdentifiers = true; + this.User = this.sequelize.define('User', { + username: DataTypes.STRING, + email: { type: DataTypes.ARRAY(DataTypes.TEXT) }, + settings: DataTypes.HSTORE, + document: { type: DataTypes.HSTORE, defaultValue: { default: '\'value\'' } }, + phones: DataTypes.ARRAY(DataTypes.HSTORE), + friends: { + type: DataTypes.ARRAY(DataTypes.JSON), + defaultValue: [], + }, + magic_numbers: { + type: DataTypes.ARRAY(DataTypes.INTEGER), + defaultValue: [], + }, + course_period: DataTypes.RANGE(DataTypes.DATE), + acceptable_marks: { type: DataTypes.RANGE(DataTypes.DECIMAL), defaultValue: [0.65, 1] }, + available_amount: DataTypes.RANGE, + holidays: DataTypes.ARRAY(DataTypes.RANGE(DataTypes.DATE)), + location: DataTypes.GEOMETRY(), }); + await this.User.sync({ force: true }); + }); - afterEach(function () { - this.sequelize.options.quoteIdentifiers = true; + afterEach(function () { + this.sequelize.options.quoteIdentifiers = true; + }); + + it('should be able to search within an array', async function () { + await this.User.findAll({ + where: { + email: ['hello', 'world'], + }, + attributes: ['id', 'username', 'email', 'settings', 'document', 'phones', 'friends'], + logging(sql) { + expect(sql).to.equal('Executing (default): SELECT "id", "username", "email", "settings", "document", "phones", "friends" FROM "Users" AS "User" WHERE "User"."email" = ARRAY[\'hello\',\'world\'];'); + }, }); + }); - it('should be able to search within an array', async function () { - await this.User.findAll({ - where: { - email: ['hello', 'world'], - }, - attributes: ['id', 'username', 'email', 'settings', 'document', 'phones', 'emergency_contact', 'friends'], - logging(sql) { - expect(sql).to.equal('Executing (default): SELECT "id", "username", "email", "settings", "document", "phones", "emergency_contact", "friends" FROM "Users" AS "User" WHERE "User"."email" = ARRAY[\'hello\',\'world\']::TEXT[];'); - }, - }); + it('should be able to update a field with type ARRAY(JSON)', async function () { + const userInstance = await this.User.create({ + username: 'bob', + email: ['myemail@email.com'], + friends: [{ + name: 'John Smith', + }], }); - it('should be able to update a field with type ARRAY(JSON)', async function () { - const userInstance = await this.User.create({ - username: 'bob', - email: ['myemail@email.com'], - friends: [{ - name: 'John Smith', - }], - }); + expect(userInstance.friends).to.have.length(1); + expect(userInstance.friends[0].name).to.equal('John Smith'); - expect(userInstance.friends).to.have.length(1); - expect(userInstance.friends[0].name).to.equal('John Smith'); + const obj = await userInstance.update({ + friends: [{ + name: 'John Smythe', + }], + }); - const obj = await userInstance.update({ - friends: [{ - name: 'John Smythe', - }], - }); + const friends = obj.friends; + expect(friends).to.have.length(1); + expect(friends[0].name).to.equal('John Smythe'); + }); - const friends = await obj.friends; - expect(friends).to.have.length(1); - expect(friends[0].name).to.equal('John Smythe'); - await friends; - }); + it('should be able to find a record while searching in an array', async function () { + await this.User.bulkCreate([ + { username: 'bob', email: ['myemail@email.com'] }, + { username: 'tony', email: ['wrongemail@email.com'] }, + ]); - it('should be able to find a record while searching in an array', async function () { - await this.User.bulkCreate([ - { username: 'bob', email: ['myemail@email.com'] }, - { username: 'tony', email: ['wrongemail@email.com'] }, - ]); + const user = await this.User.findAll({ where: { email: ['myemail@email.com'] } }); + expect(user).to.be.instanceof(Array); + expect(user).to.have.length(1); + expect(user[0].username).to.equal('bob'); + }); - const user = await this.User.findAll({ where: { email: ['myemail@email.com'] } }); - expect(user).to.be.instanceof(Array); - expect(user).to.have.length(1); - expect(user[0].username).to.equal('bob'); + describe('hstore', () => { + it('should tell me that a column is hstore and not USER-DEFINED', async function () { + const table = await this.sequelize.queryInterface.describeTable('Users'); + expect(table.settings.type).to.equal('HSTORE'); + expect(table.document.type).to.equal('HSTORE'); }); - describe('json', () => { - it('should be able to retrieve a row with ->> operator', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } })]); - - const user = await this.User.findOne({ where: json('emergency_contact->>\'name\'', 'kate'), attributes: ['username', 'emergency_contact'] }); - expect(user.emergency_contact.name).to.equal('kate'); + // TODO: move to select QueryGenerator unit tests + it('should NOT stringify hstore with insert', async function () { + await this.User.create({ + username: 'bob', + email: ['myemail@email.com'], + settings: { mailing: 'false', push: 'facebook', frequency: '3' }, + }, { + logging(sql) { + const unexpected = '\'"mailing"=>"false","push"=>"facebook","frequency"=>"3"\',\'"default"=>"\'\'value\'\'"\''; + expect(sql).not.to.include(unexpected); + }, }); + }); - it('should be able to query using the nested query language', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } })]); - - const user = await this.User.findOne({ - where: json({ emergency_contact: { name: 'kate' } }), - }); - - expect(user.emergency_contact.name).to.equal('kate'); + // TODO: move to select QueryGenerator unit tests + it('should not rename hstore fields', async function () { + const Equipment = this.sequelize.define('Equipment', { + grapplingHook: { + type: DataTypes.STRING, + field: 'grappling_hook', + }, + utilityBelt: { + type: DataTypes.HSTORE, + }, }); - it('should be able to query using dot syntax', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } })]); + await Equipment.sync({ force: true }); - const user = await this.User.findOne({ where: json('emergency_contact.name', 'joe') }); - expect(user.emergency_contact.name).to.equal('joe'); + await Equipment.findAll({ + where: { + utilityBelt: { + grapplingHook: 'true', + }, + }, + logging(sql) { + expect(sql).to.contains(' WHERE "Equipment"."utilityBelt" = \'"grapplingHook"=>"true"\';'); + }, }); + }); + }); - it('should be able to query using dot syntax with uppercase name', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergencyContact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergencyContact: { name: 'joe' } })]); - - const user = await this.User.findOne({ - attributes: [[json('emergencyContact.name'), 'contactName']], - where: json('emergencyContact.name', 'joe'), - }); + describe('range', () => { + it('should tell me that a column is range and not USER-DEFINED', async function () { + const table = await this.sequelize.queryInterface.describeTable('Users'); + expect(table.course_period.type).to.equal('TSTZRANGE'); + expect(table.available_amount.type).to.equal('INT4RANGE'); + }); + }); - expect(user.get('contactName')).to.equal('joe'); + describe('enums', () => { + it('should be able to create enums with escape values', async function () { + const User = this.sequelize.define('UserEnums', { + mood: DataTypes.ENUM('happy', 'sad', '1970\'s'), }); - it('should be able to store values that require JSON escaping', async function () { - const text = 'Multi-line \'$string\' needing "escaping" for $$ and $1 type values'; + await User.sync({ force: true }); + }); - const user0 = await this.User.create({ username: 'swen', emergency_contact: { value: text } }); - expect(user0.isNewRecord).to.equal(false); - await this.User.findOne({ where: { username: 'swen' } }); - const user = await this.User.findOne({ where: json('emergency_contact.value', text) }); - expect(user.username).to.equal('swen'); + it('should be able to ignore enum types that already exist', async function () { + const User = this.sequelize.define('UserEnums', { + mood: DataTypes.ENUM('happy', 'sad', 'meh'), }); - it('should be able to findOrCreate with values that require JSON escaping', async function () { - const text = 'Multi-line \'$string\' needing "escaping" for $$ and $1 type values'; + await User.sync({ force: true }); - const user0 = await this.User.findOrCreate({ where: { username: 'swen' }, defaults: { emergency_contact: { value: text } } }); - expect(!user0.isNewRecord).to.equal(true); - await this.User.findOne({ where: { username: 'swen' } }); - const user = await this.User.findOne({ where: json('emergency_contact.value', text) }); - expect(user.username).to.equal('swen'); - }); + await User.sync(); }); - describe('hstore', () => { - it('should tell me that a column is hstore and not USER-DEFINED', async function () { - const table = await this.sequelize.queryInterface.describeTable('Users'); - expect(table.settings.type).to.equal('HSTORE'); - expect(table.document.type).to.equal('HSTORE'); - }); - - // TODO: move to select QueryGenerator unit tests - it('should NOT stringify hstore with insert', async function () { - await this.User.create({ - username: 'bob', - email: ['myemail@email.com'], - settings: { mailing: 'false', push: 'facebook', frequency: '3' }, - }, { - logging(sql) { - const unexpected = '\'"mailing"=>"false","push"=>"facebook","frequency"=>"3"\',\'"default"=>"\'\'value\'\'"\''; - expect(sql).not.to.include(unexpected); - }, - }); + it('should be able to create/drop enums multiple times', async function () { + const User = this.sequelize.define('UserEnums', { + mood: DataTypes.ENUM('happy', 'sad', 'meh'), }); - // TODO: move to select QueryGenerator unit tests - it('should not rename hstore fields', async function () { - const Equipment = this.sequelize.define('Equipment', { - grapplingHook: { - type: DataTypes.STRING, - field: 'grappling_hook', - }, - utilityBelt: { - type: DataTypes.HSTORE, - }, - }); + await User.sync({ force: true }); - await Equipment.sync({ force: true }); + await User.sync({ force: true }); + }); - await Equipment.findAll({ - where: { - utilityBelt: { - grapplingHook: 'true', - }, - }, - logging(sql) { - expect(sql).to.contains(' WHERE "Equipment"."utilityBelt" = \'"grapplingHook"=>"true"\';'); - }, - }); + it('should be able to create/drop multiple enums multiple times', async function () { + const DummyModel = this.sequelize.define('Dummy-pg', { + username: DataTypes.STRING, + theEnumOne: { + type: DataTypes.ENUM([ + 'one', + 'two', + 'three', + ]), + }, + theEnumTwo: { + type: DataTypes.ENUM([ + 'four', + 'five', + 'six', + ]), + }, }); - // TODO: move to select QueryGenerator unit tests - it('should not rename json fields', async function () { - const Equipment = this.sequelize.define('Equipment', { - grapplingHook: { - type: DataTypes.STRING, - field: 'grappling_hook', - }, - utilityBelt: { - type: DataTypes.JSON, - }, - }); - - await Equipment.sync({ force: true }); + await DummyModel.sync({ force: true }); + // now sync one more time: + await DummyModel.sync({ force: true }); + // sync without dropping + await DummyModel.sync(); + }); - await Equipment.findAll({ - where: { - utilityBelt: { - grapplingHook: true, - }, - }, - logging(sql) { - expect(sql).to.contains(' WHERE CAST(("Equipment"."utilityBelt"#>>\'{grapplingHook}\') AS BOOLEAN) = true;'); - }, - }); + it('should be able to create/drop multiple enums multiple times with field name (#7812)', async function () { + const DummyModel = this.sequelize.define('Dummy-pg', { + username: DataTypes.STRING, + theEnumOne: { + field: 'oh_my_this_enum_one', + type: DataTypes.ENUM([ + 'one', + 'two', + 'three', + ]), + }, + theEnumTwo: { + field: 'oh_my_this_enum_two', + type: DataTypes.ENUM([ + 'four', + 'five', + 'six', + ]), + }, }); + + await DummyModel.sync({ force: true }); + // now sync one more time: + await DummyModel.sync({ force: true }); + // sync without dropping + await DummyModel.sync(); }); - describe('range', () => { - it('should tell me that a column is range and not USER-DEFINED', async function () { - const table = await this.sequelize.queryInterface.describeTable('Users'); - expect(table.course_period.type).to.equal('TSTZRANGE'); - expect(table.available_amount.type).to.equal('INT4RANGE'); + it('should be able to add values to enum types', async function () { + let User = this.sequelize.define('UserEnums', { + mood: DataTypes.ENUM('happy', 'sad', 'meh'), }); + + await User.sync({ force: true }); + User = this.sequelize.define('UserEnums', { + mood: DataTypes.ENUM('neutral', 'happy', 'sad', 'ecstatic', 'meh', 'joyful'), + }); + + await User.sync(); + const enums = await this.sequelize.getQueryInterface().pgListEnums(User.getTableName()); + expect(enums).to.have.length(1); + expect(enums[0].enum_value).to.deep.equal(['neutral', 'happy', 'sad', 'ecstatic', 'meh', 'joyful']); }); - describe('enums', () => { - it('should be able to create enums with escape values', async function () { - const User = this.sequelize.define('UserEnums', { - mood: DataTypes.ENUM('happy', 'sad', '1970\'s'), - }); + it('should be able to add multiple values with different order', async function () { + let User = this.sequelize.define('UserEnums', { + priority: DataTypes.ENUM('1', '2', '6'), + }); - await User.sync({ force: true }); + await User.sync({ force: true }); + User = this.sequelize.define('UserEnums', { + priority: DataTypes.ENUM('0', '1', '2', '3', '4', '5', '6', '7'), }); + await User.sync(); + const enums = await this.sequelize.getQueryInterface().pgListEnums(User.getTableName()); + expect(enums).to.have.length(1); + expect(enums[0].enum_value).to.deep.equal(['0', '1', '2', '3', '4', '5', '6', '7']); + }); + + describe('ARRAY(ENUM)', () => { it('should be able to ignore enum types that already exist', async function () { const User = this.sequelize.define('UserEnums', { - mood: DataTypes.ENUM('happy', 'sad', 'meh'), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); await User.sync({ force: true }); @@ -257,7 +278,12 @@ if (dialect.startsWith('postgres')) { it('should be able to create/drop enums multiple times', async function () { const User = this.sequelize.define('UserEnums', { - mood: DataTypes.ENUM('happy', 'sad', 'meh'), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); await User.sync({ force: true }); @@ -265,895 +291,777 @@ if (dialect.startsWith('postgres')) { await User.sync({ force: true }); }); - it('should be able to create/drop multiple enums multiple times', async function () { - const DummyModel = this.sequelize.define('Dummy-pg', { - username: DataTypes.STRING, - theEnumOne: { - type: DataTypes.ENUM([ - 'one', - 'two', - 'three', - ]), - }, - theEnumTwo: { - type: DataTypes.ENUM([ - 'four', - 'five', - 'six', - ]), - }, - }); - - await DummyModel.sync({ force: true }); - // now sync one more time: - await DummyModel.sync({ force: true }); - // sync without dropping - await DummyModel.sync(); - }); - - it('should be able to create/drop multiple enums multiple times with field name (#7812)', async function () { - const DummyModel = this.sequelize.define('Dummy-pg', { - username: DataTypes.STRING, - theEnumOne: { - field: 'oh_my_this_enum_one', - type: DataTypes.ENUM([ - 'one', - 'two', - 'three', - ]), - }, - theEnumTwo: { - field: 'oh_my_this_enum_two', - type: DataTypes.ENUM([ - 'four', - 'five', - 'six', - ]), - }, - }); - - await DummyModel.sync({ force: true }); - // now sync one more time: - await DummyModel.sync({ force: true }); - // sync without dropping - await DummyModel.sync(); - }); - it('should be able to add values to enum types', async function () { let User = this.sequelize.define('UserEnums', { - mood: DataTypes.ENUM('happy', 'sad', 'meh'), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); await User.sync({ force: true }); User = this.sequelize.define('UserEnums', { - mood: DataTypes.ENUM('neutral', 'happy', 'sad', 'ecstatic', 'meh', 'joyful'), + permissions: DataTypes.ARRAY( + DataTypes.ENUM('view', 'access', 'edit', 'write', 'check', 'delete'), + ), }); await User.sync(); const enums = await this.sequelize.getQueryInterface().pgListEnums(User.getTableName()); expect(enums).to.have.length(1); - expect(enums[0].enum_value).to.deep.equal(['neutral', 'happy', 'sad', 'ecstatic', 'meh', 'joyful']); + expect(enums[0].enum_value).to.deep.equal(['view', 'access', 'edit', 'write', 'check', 'delete']); }); - it('should be able to add multiple values with different order', async function () { - let User = this.sequelize.define('UserEnums', { - priority: DataTypes.ENUM('1', '2', '6'), + it('should be able to insert new record', async function () { + const User = this.sequelize.define('UserEnums', { + name: DataTypes.STRING, + type: DataTypes.ENUM('A', 'B', 'C'), + owners: DataTypes.ARRAY(DataTypes.STRING), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); await User.sync({ force: true }); - User = this.sequelize.define('UserEnums', { - priority: DataTypes.ENUM('0', '1', '2', '3', '4', '5', '6', '7'), + + const user = await User.create({ + name: 'file.exe', + type: 'C', + owners: ['userA', 'userB'], + permissions: ['access', 'write'], }); - await User.sync(); - const enums = await this.sequelize.getQueryInterface().pgListEnums(User.getTableName()); - expect(enums).to.have.length(1); - expect(enums[0].enum_value).to.deep.equal(['0', '1', '2', '3', '4', '5', '6', '7']); + expect(user.name).to.equal('file.exe'); + expect(user.type).to.equal('C'); + expect(user.owners).to.deep.equal(['userA', 'userB']); + expect(user.permissions).to.deep.equal(['access', 'write']); }); - describe('ARRAY(ENUM)', () => { - it('should be able to ignore enum types that already exist', async function () { - const User = this.sequelize.define('UserEnums', { - permissions: DataTypes.ARRAY(DataTypes.ENUM([ + it('should be able to insert a new record even with a redefined field name', async function () { + const User = this.sequelize.define('UserEnums', { + name: DataTypes.STRING, + type: DataTypes.ENUM('A', 'B', 'C'), + owners: DataTypes.ARRAY(DataTypes.STRING), + specialPermissions: { + type: DataTypes.ARRAY(DataTypes.ENUM([ 'access', 'write', 'check', 'delete', ])), - }); - - await User.sync({ force: true }); - - await User.sync(); + field: 'special_permissions', + }, }); - it('should be able to create/drop enums multiple times', async function () { - const User = this.sequelize.define('UserEnums', { - permissions: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - }); + await User.sync({ force: true }); - await User.sync({ force: true }); + const user = await User.bulkCreate([{ + name: 'file.exe', + type: 'C', + owners: ['userA', 'userB'], + specialPermissions: ['access', 'write'], + }]); - await User.sync({ force: true }); - }); + expect(user.length).to.equal(1); + }); - it('should be able to add values to enum types', async function () { - let User = this.sequelize.define('UserEnums', { - permissions: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - }); - - await User.sync({ force: true }); - User = this.sequelize.define('UserEnums', { - permissions: DataTypes.ARRAY( - DataTypes.ENUM('view', 'access', 'edit', 'write', 'check', 'delete'), - ), - }); - - await User.sync(); - const enums = await this.sequelize.getQueryInterface().pgListEnums(User.getTableName()); - expect(enums).to.have.length(1); - expect(enums[0].enum_value).to.deep.equal(['view', 'access', 'edit', 'write', 'check', 'delete']); - }); + it('should be able to insert a new record with an array of enums in a schema', async function () { + const schema = 'special_schema'; + await this.sequelize.createSchema(schema); - it('should be able to insert new record', async function () { - const User = this.sequelize.define('UserEnums', { - name: DataTypes.STRING, - type: DataTypes.ENUM('A', 'B', 'C'), - owners: DataTypes.ARRAY(DataTypes.STRING), - permissions: DataTypes.ARRAY(DataTypes.ENUM([ + const User = this.sequelize.define('UserEnums', { + name: DataTypes.STRING, + type: DataTypes.ENUM('A', 'B', 'C'), + owners: DataTypes.ARRAY(DataTypes.STRING), + specialPermissions: { + type: DataTypes.ARRAY(DataTypes.ENUM([ 'access', 'write', 'check', 'delete', ])), - }); - - await User.sync({ force: true }); - - const user = await User.create({ - name: 'file.exe', - type: 'C', - owners: ['userA', 'userB'], - permissions: ['access', 'write'], - }); - - expect(user.name).to.equal('file.exe'); - expect(user.type).to.equal('C'); - expect(user.owners).to.deep.equal(['userA', 'userB']); - expect(user.permissions).to.deep.equal(['access', 'write']); + field: 'special_permissions', + }, + }, { + schema, }); - it('should be able to insert a new record even with a redefined field name', async function () { - const User = this.sequelize.define('UserEnums', { - name: DataTypes.STRING, - type: DataTypes.ENUM('A', 'B', 'C'), - owners: DataTypes.ARRAY(DataTypes.STRING), - specialPermissions: { - type: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - field: 'special_permissions', - }, - }); + await User.sync({ force: true }); - await User.sync({ force: true }); + const user = await User.bulkCreate([{ + name: 'file.exe', + type: 'C', + owners: ['userA', 'userB'], + specialPermissions: ['access', 'write'], + }]); - const user = await User.bulkCreate([{ - name: 'file.exe', - type: 'C', - owners: ['userA', 'userB'], - specialPermissions: ['access', 'write'], - }]); + expect(user.length).to.equal(1); + }); - expect(user.length).to.equal(1); + it('should fail when trying to insert foreign element on ARRAY(ENUM)', async function () { + const User = this.sequelize.define('UserEnums', { + name: DataTypes.STRING, + type: DataTypes.ENUM('A', 'B', 'C'), + owners: DataTypes.ARRAY(DataTypes.STRING), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); - it('should be able to insert a new record with an array of enums in a schema', async function () { - const schema = 'special_schema'; - await this.sequelize.createSchema(schema); - - const User = this.sequelize.define('UserEnums', { - name: DataTypes.STRING, - type: DataTypes.ENUM('A', 'B', 'C'), - owners: DataTypes.ARRAY(DataTypes.STRING), - specialPermissions: { - type: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - field: 'special_permissions', - }, - }, { - schema, - }); - - await User.sync({ force: true }); + await User.sync({ force: true }); - const user = await User.bulkCreate([{ - name: 'file.exe', - type: 'C', - owners: ['userA', 'userB'], - specialPermissions: ['access', 'write'], - }]); + await expect(User.create({ + name: 'file.exe', + type: 'C', + owners: ['userA', 'userB'], + permissions: ['cosmic_ray_disk_access'], + })).to.be.rejectedWith(`'cosmic_ray_disk_access' is not a valid choice for enum [ 'access', 'write', 'check', 'delete' ]`); + }); - expect(user.length).to.equal(1); + it('should be able to find records', async function () { + const User = this.sequelize.define('UserEnums', { + name: DataTypes.STRING, + type: DataTypes.ENUM('A', 'B', 'C'), + permissions: DataTypes.ARRAY(DataTypes.ENUM([ + 'access', + 'write', + 'check', + 'delete', + ])), }); - it('should fail when trying to insert foreign element on ARRAY(ENUM)', async function () { - const User = this.sequelize.define('UserEnums', { - name: DataTypes.STRING, - type: DataTypes.ENUM('A', 'B', 'C'), - owners: DataTypes.ARRAY(DataTypes.STRING), - permissions: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - }); - - await User.sync({ force: true }); + await User.sync({ force: true }); - await expect(User.create({ - name: 'file.exe', - type: 'C', - owners: ['userA', 'userB'], - permissions: ['cosmic_ray_disk_access'], - })).to.be.rejectedWith(`'cosmic_ray_disk_access' is not a valid choice for enum [ 'access', 'write', 'check', 'delete' ]`); - }); + await User.bulkCreate([{ + name: 'file1.exe', + type: 'C', + permissions: ['access', 'write'], + }, { + name: 'file2.exe', + type: 'A', + permissions: ['access', 'check'], + }, { + name: 'file3.exe', + type: 'B', + permissions: ['access', 'write', 'delete'], + }]); - it('should be able to find records', async function () { - const User = this.sequelize.define('UserEnums', { - name: DataTypes.STRING, - type: DataTypes.ENUM('A', 'B', 'C'), - permissions: DataTypes.ARRAY(DataTypes.ENUM([ - 'access', - 'write', - 'check', - 'delete', - ])), - }); - - await User.sync({ force: true }); - - await User.bulkCreate([{ - name: 'file1.exe', - type: 'C', - permissions: ['access', 'write'], - }, { - name: 'file2.exe', - type: 'A', - permissions: ['access', 'check'], - }, { - name: 'file3.exe', - type: 'B', - permissions: ['access', 'write', 'delete'], - }]); - - const users = await User.findAll({ - where: { - type: { - [Op.in]: ['A', 'C'], - }, - permissions: { - [Op.contains]: ['write'], - }, + const users = await User.findAll({ + where: { + type: { + [Op.in]: ['A', 'C'], }, - }); - - expect(users.length).to.equal(1); - expect(users[0].name).to.equal('file1.exe'); - expect(users[0].type).to.equal('C'); - expect(users[0].permissions).to.deep.equal(['access', 'write']); + permissions: { + [Op.contains]: ['write'], + }, + }, }); + + expect(users.length).to.equal(1); + expect(users[0].name).to.equal('file1.exe'); + expect(users[0].type).to.equal('C'); + expect(users[0].permissions).to.deep.equal(['access', 'write']); }); }); + }); - describe('integers', () => { - describe('integer', () => { - beforeEach(async function () { - this.User = this.sequelize.define('User', { - aNumber: DataTypes.INTEGER, - }); - - await this.User.sync({ force: true }); - }); - - it('positive', async function () { - const User = this.User; - - const user = await User.create({ aNumber: 2_147_483_647 }); - expect(user.aNumber).to.equal(2_147_483_647); - const _user = await User.findOne({ where: { aNumber: 2_147_483_647 } }); - expect(_user.aNumber).to.equal(2_147_483_647); + describe('integers', () => { + describe('integer', () => { + beforeEach(async function () { + this.User = this.sequelize.define('User', { + aNumber: DataTypes.INTEGER, }); - it('negative', async function () { - const User = this.User; - - const user = await User.create({ aNumber: -2_147_483_647 }); - expect(user.aNumber).to.equal(-2_147_483_647); - const _user = await User.findOne({ where: { aNumber: -2_147_483_647 } }); - expect(_user.aNumber).to.equal(-2_147_483_647); - }); + await this.User.sync({ force: true }); }); - describe('bigint', () => { - beforeEach(async function () { - this.User = this.sequelize.define('User', { - aNumber: DataTypes.BIGINT, - }); - - await this.User.sync({ force: true }); - }); - - it('positive', async function () { - const User = this.User; + it('positive', async function () { + const User = this.User; - const user = await User.create({ aNumber: '9223372036854775807' }); - expect(user.aNumber).to.equal('9223372036854775807'); - const _user = await User.findOne({ where: { aNumber: '9223372036854775807' } }); - expect(_user.aNumber).to.equal('9223372036854775807'); - }); + const user = await User.create({ aNumber: 2_147_483_647 }); + expect(user.aNumber).to.equal(2_147_483_647); + const _user = await User.findOne({ where: { aNumber: 2_147_483_647 } }); + expect(_user.aNumber).to.equal(2_147_483_647); + }); - it('negative', async function () { - const User = this.User; + it('negative', async function () { + const User = this.User; - const user = await User.create({ aNumber: '-9223372036854775807' }); - expect(user.aNumber).to.equal('-9223372036854775807'); - const _user = await User.findOne({ where: { aNumber: '-9223372036854775807' } }); - expect(_user.aNumber).to.equal('-9223372036854775807'); - }); + const user = await User.create({ aNumber: -2_147_483_647 }); + expect(user.aNumber).to.equal(-2_147_483_647); + const _user = await User.findOne({ where: { aNumber: -2_147_483_647 } }); + expect(_user.aNumber).to.equal(-2_147_483_647); }); }); - describe('timestamps', () => { + describe('bigint', () => { beforeEach(async function () { this.User = this.sequelize.define('User', { - dates: DataTypes.ARRAY(DataTypes.DATE), + aNumber: DataTypes.BIGINT, }); + await this.User.sync({ force: true }); }); - it('should use bind params instead of "TIMESTAMP WITH TIME ZONE"', async function () { - await this.User.create({ - dates: [], - }, { - logging(sql) { - expect(sql).not.to.contain('TIMESTAMP WITH TIME ZONE'); - expect(sql).not.to.contain('DATETIME'); - }, - }); - }); - }); - - describe('model', () => { - it('create handles array correctly', async function () { - const oldUser = await this.User - .create({ username: 'user', email: ['foo@bar.com', 'bar@baz.com'] }); + it('positive', async function () { + const User = this.User; - expect(oldUser.email).to.contain.members(['foo@bar.com', 'bar@baz.com']); + const user = await User.create({ aNumber: '9223372036854775807' }); + expect(user.aNumber).to.equal('9223372036854775807'); + const _user = await User.findOne({ where: { aNumber: '9223372036854775807' } }); + expect(_user.aNumber).to.equal('9223372036854775807'); }); - it('should save hstore correctly', async function () { - const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { created: '"value"' } }); - // Check to see if the default value for an hstore field works - expect(newUser.document).to.deep.equal({ default: '\'value\'' }); - expect(newUser.settings).to.deep.equal({ created: '"value"' }); + it('negative', async function () { + const User = this.User; - // Check to see if updating an hstore field works - const oldUser = await newUser.update({ settings: { should: 'update', to: 'this', first: 'place' } }); - // Postgres always returns keys in alphabetical order (ascending) - expect(oldUser.settings).to.deep.equal({ first: 'place', should: 'update', to: 'this' }); + const user = await User.create({ aNumber: '-9223372036854775807' }); + expect(user.aNumber).to.equal('-9223372036854775807'); + const _user = await User.findOne({ where: { aNumber: '-9223372036854775807' } }); + expect(_user.aNumber).to.equal('-9223372036854775807'); }); + }); + }); - it('should save hstore array correctly', async function () { - const User = this.User; - - await this.User.create({ - username: 'bob', - email: ['myemail@email.com'], - phones: [{ number: '123456789', type: 'mobile' }, { number: '987654321', type: 'landline' }, { number: '8675309', type: 'Jenny\'s' }, { number: '5555554321', type: '"home\n"' }], - }); + describe('timestamps', () => { + beforeEach(async function () { + this.User = this.sequelize.define('User', { + dates: DataTypes.ARRAY(DataTypes.DATE), + }); + await this.User.sync({ force: true }); + }); - const user = await User.findByPk(1); - expect(user.phones.length).to.equal(4); - expect(user.phones[1].number).to.equal('987654321'); - expect(user.phones[2].type).to.equal('Jenny\'s'); - expect(user.phones[3].type).to.equal('"home\n"'); + it('should use bind params instead of "TIMESTAMP WITH TIME ZONE"', async function () { + await this.User.create({ + dates: [], + }, { + logging(sql) { + expect(sql).not.to.contain('TIMESTAMP WITH TIME ZONE'); + expect(sql).not.to.contain('DATETIME'); + }, }); + }); + }); - it('should bulkCreate with hstore property', async function () { - const User = this.User; + describe('model', () => { + it('create handles array correctly', async function () { + const oldUser = await this.User + .create({ username: 'user', email: ['foo@bar.com', 'bar@baz.com'] }); - await this.User.bulkCreate([{ - username: 'bob', - email: ['myemail@email.com'], - settings: { mailing: 'true', push: 'facebook', frequency: '3' }, - }]); + expect(oldUser.email).to.contain.members(['foo@bar.com', 'bar@baz.com']); + }); - const user = await User.findByPk(1); - expect(user.settings.mailing).to.equal('true'); - }); + it('should save hstore correctly', async function () { + const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { created: '"value"' } }); + // Check to see if the default value for an hstore field works + expect(newUser.document).to.deep.equal({ default: '\'value\'' }); + expect(newUser.settings).to.deep.equal({ created: '"value"' }); - it('should update hstore correctly', async function () { - const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }); - // Check to see if the default value for an hstore field works - expect(newUser.document).to.deep.equal({ default: '\'value\'' }); - expect(newUser.settings).to.deep.equal({ test: '"value"' }); - - // Check to see if updating an hstore field works - await this.User.update({ settings: { should: 'update', to: 'this', first: 'place' } }, { where: newUser.where() }); - await newUser.reload(); - // Postgres always returns keys in alphabetical order (ascending) - expect(newUser.settings).to.deep.equal({ first: 'place', should: 'update', to: 'this' }); - }); + // Check to see if updating an hstore field works + const oldUser = await newUser.update({ settings: { should: 'update', to: 'this', first: 'place' } }); + // Postgres always returns keys in alphabetical order (ascending) + expect(oldUser.settings).to.deep.equal({ first: 'place', should: 'update', to: 'this' }); + }); - it('should update hstore correctly and return the affected rows', async function () { - const oldUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }); - // Update the user and check that the returned object's fields have been parsed by the hstore library - const [count, users] = await this.User.update({ settings: { should: 'update', to: 'this', first: 'place' } }, { where: oldUser.where(), returning: true }); - expect(count).to.equal(1); - expect(users[0].settings).to.deep.equal({ should: 'update', to: 'this', first: 'place' }); + it('should save hstore array correctly', async function () { + const User = this.User; + + await this.User.create({ + username: 'bob', + email: ['myemail@email.com'], + phones: [{ number: '123456789', type: 'mobile' }, { number: '987654321', type: 'landline' }, { number: '8675309', type: 'Jenny\'s' }, { number: '5555554321', type: '"home\n"' }], }); - it('should read hstore correctly', async function () { - const data = { username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }; + const user = await User.findByPk(1); + expect(user.phones.length).to.equal(4); + expect(user.phones[1].number).to.equal('987654321'); + expect(user.phones[2].type).to.equal('Jenny\'s'); + expect(user.phones[3].type).to.equal('"home\n"'); + }); - await this.User.create(data); - const user = await this.User.findOne({ where: { username: 'user' } }); - // Check that the hstore fields are the same when retrieving the user - expect(user.settings).to.deep.equal(data.settings); - }); + it('should bulkCreate with hstore property', async function () { + const User = this.User; - it('should read an hstore array correctly', async function () { - const data = { username: 'user', email: ['foo@bar.com'], phones: [{ number: '123456789', type: 'mobile' }, { number: '987654321', type: 'landline' }] }; + await this.User.bulkCreate([{ + username: 'bob', + email: ['myemail@email.com'], + settings: { mailing: 'true', push: 'facebook', frequency: '3' }, + }]); - await this.User.create(data); - // Check that the hstore fields are the same when retrieving the user - const user = await this.User.findOne({ where: { username: 'user' } }); - expect(user.phones).to.deep.equal(data.phones); - }); + const user = await User.findByPk(1); + expect(user.settings.mailing).to.equal('true'); + }); - it('should read hstore correctly from multiple rows', async function () { - await this.User - .create({ username: 'user1', email: ['foo@bar.com'], settings: { test: '"value"' } }); + it('should update hstore correctly', async function () { + const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }); + // Check to see if the default value for an hstore field works + expect(newUser.document).to.deep.equal({ default: '\'value\'' }); + expect(newUser.settings).to.deep.equal({ test: '"value"' }); + + // Check to see if updating an hstore field works + await this.User.update({ settings: { should: 'update', to: 'this', first: 'place' } }, { where: newUser.where() }); + await newUser.reload(); + // Postgres always returns keys in alphabetical order (ascending) + expect(newUser.settings).to.deep.equal({ first: 'place', should: 'update', to: 'this' }); + }); - await this.User.create({ username: 'user2', email: ['foo2@bar.com'], settings: { another: '"example"' } }); - // Check that the hstore fields are the same when retrieving the user - const users = await this.User.findAll({ order: ['username'] }); - expect(users[0].settings).to.deep.equal({ test: '"value"' }); - expect(users[1].settings).to.deep.equal({ another: '"example"' }); - }); + it('should update hstore correctly and return the affected rows', async function () { + const oldUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }); + // Update the user and check that the returned object's fields have been parsed by the hstore library + const [count, users] = await this.User.update({ settings: { should: 'update', to: 'this', first: 'place' } }, { where: oldUser.where(), returning: true }); + expect(count).to.equal(1); + expect(users[0].settings).to.deep.equal({ should: 'update', to: 'this', first: 'place' }); + }); - it('should read hstore correctly from included models as well', async function () { - const HstoreSubmodel = this.sequelize.define('hstoreSubmodel', { - someValue: DataTypes.HSTORE, - }); - const submodelValue = { testing: '"hstore"' }; + it('should read hstore correctly', async function () { + const data = { username: 'user', email: ['foo@bar.com'], settings: { test: '"value"' } }; - this.User.hasMany(HstoreSubmodel); + await this.User.create(data); + const user = await this.User.findOne({ where: { username: 'user' } }); + // Check that the hstore fields are the same when retrieving the user + expect(user.settings).to.deep.equal(data.settings); + }); - await this.sequelize - .sync({ force: true }); + it('should read an hstore array correctly', async function () { + const data = { username: 'user', email: ['foo@bar.com'], phones: [{ number: '123456789', type: 'mobile' }, { number: '987654321', type: 'landline' }] }; - const user0 = await this.User.create({ username: 'user1' }); - const submodel = await HstoreSubmodel.create({ someValue: submodelValue }); - await user0.setHstoreSubmodels([submodel]); - const user = await this.User.findOne({ where: { username: 'user1' }, include: [HstoreSubmodel] }); - expect(user.hasOwnProperty('hstoreSubmodels')).to.be.ok; - expect(user.hstoreSubmodels.length).to.equal(1); - expect(user.hstoreSubmodels[0].someValue).to.deep.equal(submodelValue); - }); + await this.User.create(data); + // Check that the hstore fields are the same when retrieving the user + const user = await this.User.findOne({ where: { username: 'user' } }); + expect(user.phones).to.deep.equal(data.phones); + }); - it('should save range correctly', async function () { - const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; - const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], course_period: period }); - // Check to see if the default value for a range field works - - expect(newUser.acceptable_marks.length).to.equal(2); - expect(newUser.acceptable_marks[0].value).to.equal('0.65'); // lower bound - expect(newUser.acceptable_marks[1].value).to.equal('1'); // upper bound - expect(newUser.acceptable_marks[0].inclusive).to.deep.equal(true); // inclusive - expect(newUser.acceptable_marks[1].inclusive).to.deep.equal(false); // exclusive - expect(newUser.course_period[0].value instanceof Date).to.be.ok; // lower bound - expect(newUser.course_period[1].value instanceof Date).to.be.ok; // upper bound - expect(newUser.course_period[0].value).to.equalTime(period[0]); // lower bound - expect(newUser.course_period[1].value).to.equalTime(period[1]); // upper bound - expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive - - // Check to see if updating a range field works - await newUser.update({ acceptable_marks: [0.8, 0.9] }); - await newUser.reload(); // Ensure the acceptable_marks array is loaded with the complete range definition - expect(newUser.acceptable_marks.length).to.equal(2); - expect(newUser.acceptable_marks[0].value).to.equal('0.8'); // lower bound - expect(newUser.acceptable_marks[1].value).to.equal('0.9'); // upper bound - }); + it('should read hstore correctly from multiple rows', async function () { + await this.User + .create({ username: 'user1', email: ['foo@bar.com'], settings: { test: '"value"' } }); - it('should save range array correctly', async function () { - const User = this.User; - const holidays = [ - [new Date(2015, 3, 1), new Date(2015, 3, 15)], - [new Date(2015, 8, 1), new Date(2015, 9, 15)], - ]; - - await User.create({ - username: 'bob', - email: ['myemail@email.com'], - holidays, - }); + await this.User.create({ username: 'user2', email: ['foo2@bar.com'], settings: { another: '"example"' } }); + // Check that the hstore fields are the same when retrieving the user + const users = await this.User.findAll({ order: ['username'] }); + expect(users[0].settings).to.deep.equal({ test: '"value"' }); + expect(users[1].settings).to.deep.equal({ another: '"example"' }); + }); - const user = await User.findByPk(1); - expect(user.holidays.length).to.equal(2); - expect(user.holidays[0].length).to.equal(2); - expect(user.holidays[0][0].value instanceof Date).to.be.ok; - expect(user.holidays[0][1].value instanceof Date).to.be.ok; - expect(user.holidays[0][0].value).to.equalTime(holidays[0][0]); - expect(user.holidays[0][1].value).to.equalTime(holidays[0][1]); - expect(user.holidays[1].length).to.equal(2); - expect(user.holidays[1][0].value instanceof Date).to.be.ok; - expect(user.holidays[1][1].value instanceof Date).to.be.ok; - expect(user.holidays[1][0].value).to.equalTime(holidays[1][0]); - expect(user.holidays[1][1].value).to.equalTime(holidays[1][1]); + it('should read hstore correctly from included models as well', async function () { + const HstoreSubmodel = this.sequelize.define('hstoreSubmodel', { + someValue: DataTypes.HSTORE, }); + const submodelValue = { testing: '"hstore"' }; - it('should bulkCreate with range property', async function () { - const User = this.User; - const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; + this.User.hasMany(HstoreSubmodel); - await User.bulkCreate([{ - username: 'bob', - email: ['myemail@email.com'], - course_period: period, - }]); + await this.sequelize + .sync({ force: true }); - const user = await User.findByPk(1); - expect(user.course_period[0].value instanceof Date).to.be.ok; - expect(user.course_period[1].value instanceof Date).to.be.ok; - expect(user.course_period[0].value).to.equalTime(period[0]); // lower bound - expect(user.course_period[1].value).to.equalTime(period[1]); // upper bound - expect(user.course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(user.course_period[1].inclusive).to.deep.equal(false); // exclusive - }); - - it('should update range correctly', async function () { - const User = this.User; - const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; - - const newUser = await User.create({ username: 'user', email: ['foo@bar.com'], course_period: period }); - // Check to see if the default value for a range field works - expect(newUser.acceptable_marks.length).to.equal(2); - expect(newUser.acceptable_marks[0].value).to.equal('0.65'); // lower bound - expect(newUser.acceptable_marks[1].value).to.equal('1'); // upper bound - expect(newUser.acceptable_marks[0].inclusive).to.deep.equal(true); // inclusive - expect(newUser.acceptable_marks[1].inclusive).to.deep.equal(false); // exclusive - expect(newUser.course_period[0].value instanceof Date).to.be.ok; - expect(newUser.course_period[1].value instanceof Date).to.be.ok; - expect(newUser.course_period[0].value).to.equalTime(period[0]); // lower bound - expect(newUser.course_period[1].value).to.equalTime(period[1]); // upper bound - expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive - - const period2 = [new Date(2015, 1, 1), new Date(2015, 10, 30)]; - - // Check to see if updating a range field works - await User.update({ course_period: period2 }, { where: newUser.where() }); - await newUser.reload(); - expect(newUser.course_period[0].value instanceof Date).to.be.ok; - expect(newUser.course_period[1].value instanceof Date).to.be.ok; - expect(newUser.course_period[0].value).to.equalTime(period2[0]); // lower bound - expect(newUser.course_period[1].value).to.equalTime(period2[1]); // upper bound - expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive - }); + const user0 = await this.User.create({ username: 'user1' }); + const submodel = await HstoreSubmodel.create({ someValue: submodelValue }); + await user0.setHstoreSubmodels([submodel]); + const user = await this.User.findOne({ where: { username: 'user1' }, include: [HstoreSubmodel] }); + expect(user.hasOwnProperty('hstoreSubmodels')).to.be.ok; + expect(user.hstoreSubmodels.length).to.equal(1); + expect(user.hstoreSubmodels[0].someValue).to.deep.equal(submodelValue); + }); - it('should update range correctly and return the affected rows', async function () { - const User = this.User; - const period = [new Date(2015, 1, 1), new Date(2015, 10, 30)]; + it('should save range correctly', async function () { + const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; + const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], course_period: period }); + // Check to see if the default value for a range field works + + expect(newUser.acceptable_marks.length).to.equal(2); + expect(newUser.acceptable_marks[0].value).to.equal('0.65'); // lower bound + expect(newUser.acceptable_marks[1].value).to.equal('1'); // upper bound + expect(newUser.acceptable_marks[0].inclusive).to.deep.equal(true); // inclusive + expect(newUser.acceptable_marks[1].inclusive).to.deep.equal(false); // exclusive + expect(newUser.course_period[0].value instanceof Date).to.be.ok; // lower bound + expect(newUser.course_period[1].value instanceof Date).to.be.ok; // upper bound + expect(newUser.course_period[0].value).to.equalTime(period[0]); // lower bound + expect(newUser.course_period[1].value).to.equalTime(period[1]); // upper bound + expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive + + // Check to see if updating a range field works + await newUser.update({ acceptable_marks: [0.8, 0.9] }); + await newUser.reload(); // Ensure the acceptable_marks array is loaded with the complete range definition + expect(newUser.acceptable_marks.length).to.equal(2); + expect(newUser.acceptable_marks[0].value).to.equal('0.8'); // lower bound + expect(newUser.acceptable_marks[1].value).to.equal('0.9'); // upper bound + }); - const oldUser = await User.create({ - username: 'user', - email: ['foo@bar.com'], - course_period: [new Date(2015, 0, 1), new Date(2015, 11, 31)], - }); + it('should save range array correctly', async function () { + const User = this.User; + const holidays = [ + [new Date(2015, 3, 1), new Date(2015, 3, 15)], + [new Date(2015, 8, 1), new Date(2015, 9, 15)], + ]; - // Update the user and check that the returned object's fields have been parsed by the range parser - const [count, users] = await User.update({ course_period: period }, { where: oldUser.where(), returning: true }); - expect(count).to.equal(1); - expect(users[0].course_period[0].value instanceof Date).to.be.ok; - expect(users[0].course_period[1].value instanceof Date).to.be.ok; - expect(users[0].course_period[0].value).to.equalTime(period[0]); // lower bound - expect(users[0].course_period[1].value).to.equalTime(period[1]); // upper bound - expect(users[0].course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(users[0].course_period[1].inclusive).to.deep.equal(false); // exclusive + await User.create({ + username: 'bob', + email: ['myemail@email.com'], + holidays, }); - it('should read range correctly', async function () { - const User = this.User; + const user = await User.findByPk(1); + expect(user.holidays.length).to.equal(2); + expect(user.holidays[0].length).to.equal(2); + expect(user.holidays[0][0].value instanceof Date).to.be.ok; + expect(user.holidays[0][1].value instanceof Date).to.be.ok; + expect(user.holidays[0][0].value).to.equalTime(holidays[0][0]); + expect(user.holidays[0][1].value).to.equalTime(holidays[0][1]); + expect(user.holidays[1].length).to.equal(2); + expect(user.holidays[1][0].value instanceof Date).to.be.ok; + expect(user.holidays[1][1].value instanceof Date).to.be.ok; + expect(user.holidays[1][0].value).to.equalTime(holidays[1][0]); + expect(user.holidays[1][1].value).to.equalTime(holidays[1][1]); + }); - const course_period = [{ value: new Date(2015, 1, 1), inclusive: false }, { value: new Date(2015, 10, 30), inclusive: false }]; + it('should bulkCreate with range property', async function () { + const User = this.User; + const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; - const data = { username: 'user', email: ['foo@bar.com'], course_period }; + await User.bulkCreate([{ + username: 'bob', + email: ['myemail@email.com'], + course_period: period, + }]); + + const user = await User.findByPk(1); + expect(user.course_period[0].value instanceof Date).to.be.ok; + expect(user.course_period[1].value instanceof Date).to.be.ok; + expect(user.course_period[0].value).to.equalTime(period[0]); // lower bound + expect(user.course_period[1].value).to.equalTime(period[1]); // upper bound + expect(user.course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(user.course_period[1].inclusive).to.deep.equal(false); // exclusive + }); - await User.create(data); - const user = await User.findOne({ where: { username: 'user' } }); - // Check that the range fields are the same when retrieving the user - expect(user.course_period).to.deep.equal(data.course_period); - }); + it('should update range correctly', async function () { + const User = this.User; + const period = [new Date(2015, 0, 1), new Date(2015, 11, 31)]; + + const newUser = await User.create({ username: 'user', email: ['foo@bar.com'], course_period: period }); + // Check to see if the default value for a range field works + expect(newUser.acceptable_marks.length).to.equal(2); + expect(newUser.acceptable_marks[0].value).to.equal('0.65'); // lower bound + expect(newUser.acceptable_marks[1].value).to.equal('1'); // upper bound + expect(newUser.acceptable_marks[0].inclusive).to.deep.equal(true); // inclusive + expect(newUser.acceptable_marks[1].inclusive).to.deep.equal(false); // exclusive + expect(newUser.course_period[0].value instanceof Date).to.be.ok; + expect(newUser.course_period[1].value instanceof Date).to.be.ok; + expect(newUser.course_period[0].value).to.equalTime(period[0]); // lower bound + expect(newUser.course_period[1].value).to.equalTime(period[1]); // upper bound + expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive + + const period2 = [new Date(2015, 1, 1), new Date(2015, 10, 30)]; + + // Check to see if updating a range field works + await User.update({ course_period: period2 }, { where: newUser.where() }); + await newUser.reload(); + expect(newUser.course_period[0].value instanceof Date).to.be.ok; + expect(newUser.course_period[1].value instanceof Date).to.be.ok; + expect(newUser.course_period[0].value).to.equalTime(period2[0]); // lower bound + expect(newUser.course_period[1].value).to.equalTime(period2[1]); // upper bound + expect(newUser.course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(newUser.course_period[1].inclusive).to.deep.equal(false); // exclusive + }); - it('should read range array correctly', async function () { - const User = this.User; - const holidays = [ - [{ value: new Date(2015, 3, 1, 10), inclusive: true }, { value: new Date(2015, 3, 15), inclusive: true }], - [{ value: new Date(2015, 8, 1), inclusive: true }, { value: new Date(2015, 9, 15), inclusive: true }], - ]; - const data = { username: 'user', email: ['foo@bar.com'], holidays }; - - await User.create(data); - // Check that the range fields are the same when retrieving the user - const user = await User.findOne({ where: { username: 'user' } }); - expect(user.holidays).to.deep.equal(data.holidays); - }); + it('should update range correctly and return the affected rows', async function () { + const User = this.User; + const period = [new Date(2015, 1, 1), new Date(2015, 10, 30)]; - it('should read range correctly from multiple rows', async function () { - const User = this.User; - const periods = [ - [new Date(2015, 0, 1), new Date(2015, 11, 31)], - [new Date(2016, 0, 1), new Date(2016, 11, 31)], - ]; - - await User - .create({ username: 'user1', email: ['foo@bar.com'], course_period: periods[0] }); - - await User.create({ username: 'user2', email: ['foo2@bar.com'], course_period: periods[1] }); - // Check that the range fields are the same when retrieving the user - const users = await User.findAll({ order: ['username'] }); - expect(users[0].course_period[0].value).to.equalTime(periods[0][0]); // lower bound - expect(users[0].course_period[1].value).to.equalTime(periods[0][1]); // upper bound - expect(users[0].course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(users[0].course_period[1].inclusive).to.deep.equal(false); // exclusive - expect(users[1].course_period[0].value).to.equalTime(periods[1][0]); // lower bound - expect(users[1].course_period[1].value).to.equalTime(periods[1][1]); // upper bound - expect(users[1].course_period[0].inclusive).to.deep.equal(true); // inclusive - expect(users[1].course_period[1].inclusive).to.deep.equal(false); // exclusive + const oldUser = await User.create({ + username: 'user', + email: ['foo@bar.com'], + course_period: [new Date(2015, 0, 1), new Date(2015, 11, 31)], }); - it('should read range correctly from included models as well', async function () { - const period = [new Date(2016, 0, 1), new Date(2016, 11, 31)]; - const HolidayDate = this.sequelize.define('holidayDate', { - period: DataTypes.RANGE(DataTypes.DATE), - }); + // Update the user and check that the returned object's fields have been parsed by the range parser + const [count, users] = await User.update({ course_period: period }, { where: oldUser.where(), returning: true }); + expect(count).to.equal(1); + expect(users[0].course_period[0].value instanceof Date).to.be.ok; + expect(users[0].course_period[1].value instanceof Date).to.be.ok; + expect(users[0].course_period[0].value).to.equalTime(period[0]); // lower bound + expect(users[0].course_period[1].value).to.equalTime(period[1]); // upper bound + expect(users[0].course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(users[0].course_period[1].inclusive).to.deep.equal(false); // exclusive + }); - this.User.hasMany(HolidayDate); + it('should read range correctly', async function () { + const User = this.User; - await this.sequelize - .sync({ force: true }); + const course_period = [{ value: new Date(2015, 1, 1), inclusive: false }, { value: new Date(2015, 10, 30), inclusive: false }]; - const user0 = await this.User - .create({ username: 'user', email: ['foo@bar.com'] }); + const data = { username: 'user', email: ['foo@bar.com'], course_period }; - const holidayDate = await HolidayDate.create({ period }); - await user0.setHolidayDates([holidayDate]); - const user = await this.User.findOne({ where: { username: 'user' }, include: [HolidayDate] }); - expect(user.hasOwnProperty('holidayDates')).to.be.ok; - expect(user.holidayDates.length).to.equal(1); - expect(user.holidayDates[0].period.length).to.equal(2); - expect(user.holidayDates[0].period[0].value).to.equalTime(period[0]); - expect(user.holidayDates[0].period[1].value).to.equalTime(period[1]); - }); + await User.create(data); + const user = await User.findOne({ where: { username: 'user' } }); + // Check that the range fields are the same when retrieving the user + expect(user.course_period).to.deep.equal(data.course_period); }); - it('should save geometry correctly', async function () { - const point = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; - const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], location: point }); - expect(newUser.location).to.deep.include(point); + it('should read range array correctly', async function () { + const User = this.User; + const holidays = [ + [{ value: new Date(2015, 3, 1, 10), inclusive: true }, { value: new Date(2015, 3, 15), inclusive: true }], + [{ value: new Date(2015, 8, 1), inclusive: true }, { value: new Date(2015, 9, 15), inclusive: true }], + ]; + const data = { username: 'user', email: ['foo@bar.com'], holidays }; + + await User.create(data); + // Check that the range fields are the same when retrieving the user + const user = await User.findOne({ where: { username: 'user' } }); + expect(user.holidays).to.deep.equal(data.holidays); }); - it('should update geometry correctly', async function () { + it('should read range correctly from multiple rows', async function () { const User = this.User; - const point1 = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; - const point2 = { type: 'Point', coordinates: [39.828_333, -77.232_222] }; - const oldUser = await User.create({ username: 'user', email: ['foo@bar.com'], location: point1 }); - const [, updatedUsers] = await User.update({ location: point2 }, { where: { username: oldUser.username }, returning: true }); - expect(updatedUsers[0].location).to.deep.include(point2); + const periods = [ + [new Date(2015, 0, 1), new Date(2015, 11, 31)], + [new Date(2016, 0, 1), new Date(2016, 11, 31)], + ]; + + await User + .create({ username: 'user1', email: ['foo@bar.com'], course_period: periods[0] }); + + await User.create({ username: 'user2', email: ['foo2@bar.com'], course_period: periods[1] }); + // Check that the range fields are the same when retrieving the user + const users = await User.findAll({ order: ['username'] }); + expect(users[0].course_period[0].value).to.equalTime(periods[0][0]); // lower bound + expect(users[0].course_period[1].value).to.equalTime(periods[0][1]); // upper bound + expect(users[0].course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(users[0].course_period[1].inclusive).to.deep.equal(false); // exclusive + expect(users[1].course_period[0].value).to.equalTime(periods[1][0]); // lower bound + expect(users[1].course_period[1].value).to.equalTime(periods[1][1]); // upper bound + expect(users[1].course_period[0].inclusive).to.deep.equal(true); // inclusive + expect(users[1].course_period[1].inclusive).to.deep.equal(false); // exclusive }); - it('should read geometry correctly', async function () { - const User = this.User; - const point = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; + it('should read range correctly from included models as well', async function () { + const period = [new Date(2016, 0, 1), new Date(2016, 11, 31)]; + const HolidayDate = this.sequelize.define('holidayDate', { + period: DataTypes.RANGE(DataTypes.DATE), + }); + + this.User.hasMany(HolidayDate); - const user0 = await User.create({ username: 'user', email: ['foo@bar.com'], location: point }); - const user = await User.findOne({ where: { username: user0.username } }); - expect(user.location).to.deep.include(point); + await this.sequelize + .sync({ force: true }); + + const user0 = await this.User + .create({ username: 'user', email: ['foo@bar.com'] }); + + const holidayDate = await HolidayDate.create({ period }); + await user0.setHolidayDates([holidayDate]); + const user = await this.User.findOne({ where: { username: 'user' }, include: [HolidayDate] }); + expect(user.hasOwnProperty('holidayDates')).to.be.ok; + expect(user.holidayDates.length).to.equal(1); + expect(user.holidayDates[0].period.length).to.equal(2); + expect(user.holidayDates[0].period[0].value).to.equalTime(period[0]); + expect(user.holidayDates[0].period[1].value).to.equalTime(period[1]); }); + }); - describe('[POSTGRES] Unquoted identifiers', () => { - it('can insert and select', async function () { - this.sequelize.options.quoteIdentifiers = false; - this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = false; + it('should save geometry correctly', async function () { + const point = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; + const newUser = await this.User.create({ username: 'user', email: ['foo@bar.com'], location: point }); + expect(newUser.location).to.deep.include(point); + }); - this.User = this.sequelize.define('Userxs', { - username: DataTypes.STRING, - fullName: DataTypes.STRING, // Note mixed case - }, { - quoteIdentifiers: false, - }); + it('should update geometry correctly', async function () { + const User = this.User; + const point1 = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; + const point2 = { type: 'Point', coordinates: [39.828_333, -77.232_222] }; + const oldUser = await User.create({ username: 'user', email: ['foo@bar.com'], location: point1 }); + const [, updatedUsers] = await User.update({ location: point2 }, { where: { username: oldUser.username }, returning: true }); + expect(updatedUsers[0].location).to.deep.include(point2); + }); - await this.User.sync({ force: true }); + it('should read geometry correctly', async function () { + const User = this.User; + const point = { type: 'Point', coordinates: [39.807_222, -76.984_722] }; - const user = await this.User - .create({ username: 'user', fullName: 'John Smith' }); + const user0 = await User.create({ username: 'user', email: ['foo@bar.com'], location: point }); + const user = await User.findOne({ where: { username: user0.username } }); + expect(user.location).to.deep.include(point); + }); - // We can insert into a table with non-quoted identifiers - expect(user.id).to.exist; - expect(user.id).not.to.be.null; - expect(user.username).to.equal('user'); - expect(user.fullName).to.equal('John Smith'); + describe('[POSTGRES] Unquoted identifiers', () => { + it('can insert and select', async function () { + this.sequelize.options.quoteIdentifiers = false; + this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = false; - // We can query by non-quoted identifiers - const user2 = await this.User.findOne({ - where: { fullName: 'John Smith' }, - }); + this.User = this.sequelize.define('Userxs', { + username: DataTypes.STRING, + fullName: DataTypes.STRING, // Note mixed case + }, { + quoteIdentifiers: false, + }); - // We can map values back to non-quoted identifiers - expect(user2.id).to.equal(user.id); - expect(user2.username).to.equal('user'); - expect(user2.fullName).to.equal('John Smith'); + await this.User.sync({ force: true }); - // We can query and aggregate by non-quoted identifiers - const count = await this.User - .count({ - where: { fullName: 'John Smith' }, - }); + const user = await this.User + .create({ username: 'user', fullName: 'John Smith' }); - this.sequelize.options.quoteIndentifiers = true; - this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = true; - this.sequelize.options.logging = false; - expect(count).to.equal(1); + // We can insert into a table with non-quoted identifiers + expect(user.id).to.exist; + expect(user.id).not.to.be.null; + expect(user.username).to.equal('user'); + expect(user.fullName).to.equal('John Smith'); + + // We can query by non-quoted identifiers + const user2 = await this.User.findOne({ + where: { fullName: 'John Smith' }, }); - it('can select nested include', async function () { - this.sequelize.options.quoteIdentifiers = false; - this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = false; - this.Professor = this.sequelize.define('Professor', { - fullName: DataTypes.STRING, - }, { - quoteIdentifiers: false, - }); - this.Class = this.sequelize.define('Class', { - name: DataTypes.STRING, - }, { - quoteIdentifiers: false, - }); - this.Student = this.sequelize.define('Student', { - fullName: DataTypes.STRING, - }, { - quoteIdentifiers: false, - }); - this.ClassStudent = this.sequelize.define('ClassStudent', {}, { - quoteIdentifiers: false, - tableName: 'class_student', + // We can map values back to non-quoted identifiers + expect(user2.id).to.equal(user.id); + expect(user2.username).to.equal('user'); + expect(user2.fullName).to.equal('John Smith'); + + // We can query and aggregate by non-quoted identifiers + const count = await this.User + .count({ + where: { fullName: 'John Smith' }, }); - this.Professor.hasMany(this.Class); - this.Class.belongsTo(this.Professor); - this.Class.belongsToMany(this.Student, { through: this.ClassStudent }); - this.Student.belongsToMany(this.Class, { through: this.ClassStudent }); - - try { - await this.Professor.sync({ force: true }); - await this.Student.sync({ force: true }); - await this.Class.sync({ force: true }); - await this.ClassStudent.sync({ force: true }); - - await this.Professor.bulkCreate([ - { - id: 1, - fullName: 'Albus Dumbledore', - }, - { - id: 2, - fullName: 'Severus Snape', - }, - ]); - await this.Class.bulkCreate([ - { - id: 1, - name: 'Transfiguration', - ProfessorId: 1, - }, - { - id: 2, - name: 'Potions', - ProfessorId: 2, - }, - { - id: 3, - name: 'Defence Against the Dark Arts', - ProfessorId: 2, - }, - ]); + this.sequelize.options.quoteIndentifiers = true; + this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = true; + this.sequelize.options.logging = false; + expect(count).to.equal(1); + }); - await this.Student.bulkCreate([ - { - id: 1, - fullName: 'Harry Potter', - }, - { - id: 2, - fullName: 'Ron Weasley', - }, - { - id: 3, - fullName: 'Ginny Weasley', - }, + it('can select nested include', async function () { + this.sequelize.options.quoteIdentifiers = false; + this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = false; + this.Professor = this.sequelize.define('Professor', { + fullName: DataTypes.STRING, + }, { + quoteIdentifiers: false, + }); + this.Class = this.sequelize.define('Class', { + name: DataTypes.STRING, + }, { + quoteIdentifiers: false, + }); + this.Student = this.sequelize.define('Student', { + fullName: DataTypes.STRING, + }, { + quoteIdentifiers: false, + }); + this.ClassStudent = this.sequelize.define('ClassStudent', {}, { + quoteIdentifiers: false, + tableName: 'class_student', + }); + this.Professor.hasMany(this.Class); + this.Class.belongsTo(this.Professor); + this.Class.belongsToMany(this.Student, { through: this.ClassStudent }); + this.Student.belongsToMany(this.Class, { through: this.ClassStudent }); + + try { + await this.Professor.sync({ force: true }); + await this.Student.sync({ force: true }); + await this.Class.sync({ force: true }); + await this.ClassStudent.sync({ force: true }); + + await this.Professor.bulkCreate([ + { + id: 1, + fullName: 'Albus Dumbledore', + }, + { + id: 2, + fullName: 'Severus Snape', + }, + ]); + + await this.Class.bulkCreate([ + { + id: 1, + name: 'Transfiguration', + ProfessorId: 1, + }, + { + id: 2, + name: 'Potions', + ProfessorId: 2, + }, + { + id: 3, + name: 'Defence Against the Dark Arts', + ProfessorId: 2, + }, + ]); + + await this.Student.bulkCreate([ + { + id: 1, + fullName: 'Harry Potter', + }, + { + id: 2, + fullName: 'Ron Weasley', + }, + { + id: 3, + fullName: 'Ginny Weasley', + }, + { + id: 4, + fullName: 'Hermione Granger', + }, + ]); + + await Promise.all([ + this.Student.findByPk(1) + .then(Harry => { + return Harry.setClasses([1, 2, 3]); + }), + this.Student.findByPk(2) + .then(Ron => { + return Ron.setClasses([1, 2]); + }), + this.Student.findByPk(3) + .then(Ginny => { + return Ginny.setClasses([2, 3]); + }), + this.Student.findByPk(4) + .then(Hermione => { + return Hermione.setClasses([1, 2, 3]); + }), + ]); + + const professors = await this.Professor.findAll({ + include: [ { - id: 4, - fullName: 'Hermione Granger', + model: this.Class, + include: [ + { + model: this.Student, + }, + ], }, - ]); - - await Promise.all([ - this.Student.findByPk(1) - .then(Harry => { - return Harry.setClasses([1, 2, 3]); - }), - this.Student.findByPk(2) - .then(Ron => { - return Ron.setClasses([1, 2]); - }), - this.Student.findByPk(3) - .then(Ginny => { - return Ginny.setClasses([2, 3]); - }), - this.Student.findByPk(4) - .then(Hermione => { - return Hermione.setClasses([1, 2, 3]); - }), - ]); - - const professors = await this.Professor.findAll({ - include: [ - { - model: this.Class, - include: [ - { - model: this.Student, - }, - ], - }, - ], - order: [ - ['id'], - [this.Class, 'id'], - [this.Class, this.Student, 'id'], - ], - }); - - expect(professors.length).to.eql(2); - expect(professors[0].fullName).to.eql('Albus Dumbledore'); - expect(professors[0].Classes.length).to.eql(1); - expect(professors[0].Classes[0].Students.length).to.eql(3); - } finally { - this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = true; - } - }); + ], + order: [ + ['id'], + [this.Class, 'id'], + [this.Class, this.Student, 'id'], + ], + }); + + expect(professors.length).to.eql(2); + expect(professors[0].fullName).to.eql('Albus Dumbledore'); + expect(professors[0].Classes.length).to.eql(1); + expect(professors[0].Classes[0].Students.length).to.eql(3); + } finally { + this.sequelize.getQueryInterface().queryGenerator.options.quoteIdentifiers = true; + } }); }); -} +}); diff --git a/packages/core/test/integration/dialects/sqlite/dao.test.js b/packages/core/test/integration/dialects/sqlite/dao.test.js index b4aca34ef2fa..44e2f9884d15 100644 --- a/packages/core/test/integration/dialects/sqlite/dao.test.js +++ b/packages/core/test/integration/dialects/sqlite/dao.test.js @@ -6,7 +6,7 @@ const expect = chai.expect; const Support = require('../../support'); const dialect = Support.getTestDialect(); -const { DataTypes, Op, Sequelize } = require('@sequelize/core'); +const { DataTypes, Op, sql } = require('@sequelize/core'); if (dialect === 'sqlite') { describe('[SQLITE Specific] DAO', () => { @@ -53,7 +53,7 @@ if (dialect === 'sqlite') { }); const obj = await this.User.findAll(); - const user = await obj[0]; + const user = obj[0]; expect(user.get('dateField')).to.be.an.instanceof(Date); expect(user.get('dateField')).to.equalTime(new Date(2010, 10, 10)); }); @@ -69,7 +69,7 @@ if (dialect === 'sqlite') { include: [this.Project], }); - const user = await obj[0]; + const user = obj[0]; expect(user.projects[0].get('dateField')).to.be.an.instanceof(Date); expect(user.projects[0].get('dateField')).to.equalTime(new Date(1990, 5, 5)); }); @@ -83,7 +83,7 @@ if (dialect === 'sqlite') { ]); const user = await this.User.findOne({ - where: Sequelize.json('json_extract(emergency_contact, \'$.name\')', 'kate'), + where: sql.where(sql.literal(`json_extract(emergency_contact, '$.name')`), 'kate'), attributes: ['username', 'emergency_contact'], }); @@ -97,7 +97,7 @@ if (dialect === 'sqlite') { ]); const user = await this.User.findOne({ - where: Sequelize.json('json_type(emergency_contact)', 'array'), + where: sql.where(sql.literal('json_type(emergency_contact)'), 'array'), attributes: ['username', 'emergency_contact'], }); diff --git a/packages/core/test/integration/instance/values.test.js b/packages/core/test/integration/instance/values.test.js index 471bed2e637d..8b56e99756fc 100644 --- a/packages/core/test/integration/instance/values.test.js +++ b/packages/core/test/integration/instance/values.test.js @@ -92,10 +92,9 @@ describe(Support.getTestDialectTeaser('DAO'), () => { const user = await User.create({}); // Create the user first to set the proper default values. PG does not support column references in insert, // so we must create a record with the right value for always_false, then reference it in an update - let now = dialect === 'sqlite' ? this.sequelize.fn('', this.sequelize.fn('datetime', 'now')) : this.sequelize.fn('NOW'); - if (dialect === 'mssql') { - now = this.sequelize.fn('', this.sequelize.fn('getdate')); - } + const now = dialect === 'sqlite' ? this.sequelize.fn('', this.sequelize.fn('datetime', 'now')) + : dialect === 'mssql' ? this.sequelize.fn('', this.sequelize.fn('getdate')) + : this.sequelize.fn('NOW'); user.set({ d: now, diff --git a/packages/core/test/integration/json.test.js b/packages/core/test/integration/json.test.js deleted file mode 100644 index 5746077724b0..000000000000 --- a/packages/core/test/integration/json.test.js +++ /dev/null @@ -1,394 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const Support = require('./support'); - -const { Sequelize, DataTypes, Op, literal } = require('@sequelize/core'); - -const current = Support.sequelize; -const dialect = current.dialect; -const dialectName = Support.getTestDialect(); - -describe('model', () => { - describe('json', () => { - if (!current.dialect.supports.dataTypes.JSON) { - return; - } - - beforeEach(async function () { - this.User = this.sequelize.define('User', { - username: DataTypes.STRING, - emergency_contact: DataTypes.JSON, - emergencyContact: DataTypes.JSON, - }); - this.Order = this.sequelize.define('Order'); - this.Order.belongsTo(this.User); - - await this.sequelize.sync({ force: true }); - }); - - it('should tell me that a column is json', async function () { - const table = await this.sequelize.queryInterface.describeTable('Users'); - switch (dialectName) { - // mssql & sqlite use text columns with CHECK constraints - case 'mssql': - expect(table.emergency_contact.type).to.equal('NVARCHAR(MAX)'); - break; - case 'sqlite': - expect(table.emergency_contact.type).to.equal('TEXT'); - break; - case 'mariadb': - // TODO: expected for mariadb 10.4 : https://jira.mariadb.org/browse/MDEV-15558 - expect(table.emergency_contact.type).to.equal('LONGTEXT'); - break; - default: - expect(table.emergency_contact.type).to.equal('JSON'); - } - }); - - it('should use a placeholder for json with insert', async function () { - await this.User.create({ - username: 'bob', - emergency_contact: { name: 'joe', phones: [1337, 42] }, - }, { - fields: ['id', 'username', 'document', 'emergency_contact'], - logging: sql => { - if (/^mysql|mariadb/.test(dialectName)) { - expect(sql).to.include('?'); - } else if (dialectName === 'sqlite') { - expect(sql).to.include('$sequelize_1'); - } else if (dialectName === 'mssql') { - expect(sql).to.include('@sequelize_1'); - } else { - expect(sql).to.include('$1'); - } - }, - }); - }); - - it('should insert json using a custom field name', async function () { - this.UserFields = this.sequelize.define('UserFields', { - emergencyContact: { type: DataTypes.JSON, field: 'emergency_contact' }, - }); - - await this.UserFields.sync({ force: true }); - - const user = await this.UserFields.create({ - emergencyContact: { name: 'joe', phones: [1337, 42] }, - }); - - expect(user.emergencyContact).to.deep.equal({ name: 'joe', phones: [1337, 42] }); - }); - - it('should update json using a custom field name', async function () { - this.UserFields = this.sequelize.define('UserFields', { - emergencyContact: { type: DataTypes.JSON, field: 'emergy_contact' }, - }); - await this.UserFields.sync({ force: true }); - - const user0 = await this.UserFields.create({ - emergencyContact: { name: 'joe', phones: [1337, 42] }, - }); - - user0.emergencyContact = { name: 'larry' }; - const user = await user0.save(); - expect(user.emergencyContact.name).to.equal('larry'); - }); - - it('should be able retrieve json value as object', async function () { - const emergencyContact = { name: 'kate', phone: 1337 }; - - const user0 = await this.User.create({ username: 'swen', emergency_contact: emergencyContact }); - expect(user0.emergency_contact).to.deep.eq(emergencyContact); - - const user1 = await this.User.findOne({ where: { username: 'swen' }, attributes: ['emergency_contact'] }); - expect(user1.emergency_contact).to.deep.eq(emergencyContact); - }); - - // TODO: enable on all dialects - // JSONB Supports this, but not JSON in postgres/mysql - if (current.dialect.name === 'sqlite') { - it('should be able to find with just string', async function () { - await this.User.create({ - username: 'swen123', - emergency_contact: 'Unknown', - }); - - const user = await this.User.findOne({ - where: { - emergency_contact: 'Unknown', - }, - }); - - expect(user.username).to.equal('swen123'); - }); - } - - if (dialect.supports.jsonOperations) { - it('should be able to retrieve element of array by index', async function () { - const emergencyContact = { name: 'kate', phones: [1337, 42] }; - - const user0 = await this.User.create({ username: 'swen', emergency_contact: emergencyContact }); - expect(user0.emergency_contact).to.eql(emergencyContact); - - const user = await this.User.findOne({ - where: { username: 'swen' }, - attributes: [[Sequelize.json('emergency_contact.phones[1]'), 'firstEmergencyNumber']], - }); - - expect(Number.parseInt(user.getDataValue('firstEmergencyNumber'), 10)).to.equal(42); - }); - - it('should be able to retrieve root level value of an object by key', async function () { - const emergencyContact = { kate: 1337 }; - - const user0 = await this.User.create({ username: 'swen', emergency_contact: emergencyContact }); - expect(user0.emergency_contact).to.eql(emergencyContact); - - const user = await this.User.findOne({ - where: { username: 'swen' }, - attributes: [[Sequelize.json('emergency_contact.kate'), 'katesNumber']], - }); - - expect(Number.parseInt(user.getDataValue('katesNumber'), 10)).to.equal(1337); - }); - - it('should be able to retrieve nested value of an object by path', async function () { - const emergencyContact = { kate: { email: 'kate@kate.com', phones: [1337, 42] } }; - - const user1 = await this.User.create({ username: 'swen', emergency_contact: emergencyContact }); - expect(user1.emergency_contact).to.eql(emergencyContact); - - const user0 = await this.User.findOne({ - where: { username: 'swen' }, - attributes: [[Sequelize.json('emergency_contact.kate.email'), 'katesEmail']], - }); - - expect(user0.getDataValue('katesEmail')).to.equal('kate@kate.com'); - - const user = await this.User.findOne({ - where: { username: 'swen' }, - attributes: [[Sequelize.json('emergency_contact.kate.phones[1]'), 'katesFirstPhone']], - }); - - expect(Number.parseInt(user.getDataValue('katesFirstPhone'), 10)).to.equal(42); - }); - - it('should be able to retrieve a row based on the values of the json document', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } }), - ]); - - const user = await this.User.findOne({ - where: Sequelize.json('emergency_contact.name', 'kate'), - attributes: ['username', 'emergency_contact'], - }); - - expect(user.emergency_contact.name).to.equal('kate'); - }); - - it('should be able to query using the nested query language', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } }), - ]); - - const user = await this.User.findOne({ - where: Sequelize.json({ emergency_contact: { name: 'kate' } }), - }); - - expect(user.emergency_contact.name).to.equal('kate'); - }); - - it('should be able to query using dot notation', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergency_contact: { name: 'joe' } }), - ]); - - const user = await this.User.findOne({ where: Sequelize.json('emergency_contact.name', 'joe') }); - expect(user.emergency_contact.name).to.equal('joe'); - }); - - it('should be able to query using dot notation with uppercase name', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergencyContact: { name: 'kate' } }), - this.User.create({ username: 'anna', emergencyContact: { name: 'joe' } }), - ]); - - const user = await this.User.findOne({ - attributes: [[Sequelize.json('emergencyContact.name'), 'contactName']], - where: Sequelize.json('emergencyContact.name', 'joe'), - }); - - expect(user.get('contactName')).to.equal('joe'); - }); - - it('should be able to query array using property accessor', async function () { - await Promise.all([ - this.User.create({ username: 'swen', emergency_contact: ['kate', 'joe'] }), - this.User.create({ username: 'anna', emergency_contact: [{ name: 'joe' }] }), - ]); - - const user0 = await this.User.findOne({ where: Sequelize.json('emergency_contact.0', 'kate') }); - expect(user0.username).to.equal('swen'); - const user = await this.User.findOne({ where: Sequelize.json('emergency_contact[0].name', 'joe') }); - expect(user.username).to.equal('anna'); - }); - - it('should be able to store strings', async function () { - await this.User.create({ username: 'swen', emergency_contact: 'joe' }); - const user = await this.User.findOne({ where: { username: 'swen' } }); - expect(user.emergency_contact).to.equal('joe'); - }); - - it('should be able to store values that require JSON escaping', async function () { - const text = 'Multi-line \'$string\' needing "escaping" for $$ and $1 type values'; - - const user0 = await this.User.create({ - username: 'swen', - emergency_contact: { value: text }, - }); - - expect(user0.isNewRecord).to.equal(false); - await this.User.findOne({ where: { username: 'swen' } }); - const user = await this.User.findOne({ where: Sequelize.json('emergency_contact.value', text) }); - expect(user.username).to.equal('swen'); - }); - - it('should be able to findOrCreate with values that require JSON escaping', async function () { - const text = 'Multi-line \'$string\' needing "escaping" for $$ and $1 type values'; - - const user0 = await this.User.findOrCreate({ - where: { username: 'swen' }, - defaults: { emergency_contact: { value: text } }, - }); - - expect(!user0.isNewRecord).to.equal(true); - await this.User.findOne({ where: { username: 'swen' } }); - const user = await this.User.findOne({ where: Sequelize.json('emergency_contact.value', text) }); - expect(user.username).to.equal('swen'); - }); - - it('should be able retrieve json value with nested include', async function () { - const user = await this.User.create({ - emergency_contact: { - name: 'kate', - }, - }); - - await this.Order.create({ UserId: user.id }); - - const orders = await this.Order.findAll({ - attributes: ['id'], - include: [{ - model: this.User, - attributes: [ - [this.sequelize.json('emergency_contact.name'), 'katesName'], - ], - }], - }); - - expect(orders[0].User.getDataValue('katesName')).to.equal('kate'); - }); - } - }); - - describe('jsonb', () => { - if (!current.dialect.supports.dataTypes.JSONB) { - return; - } - - beforeEach(async function () { - this.User = this.sequelize.define('User', { - username: DataTypes.STRING, - emergency_contact: DataTypes.JSONB, - }); - this.Order = this.sequelize.define('Order'); - this.Order.belongsTo(this.User); - - await this.sequelize.sync({ force: true }); - }); - - it('should be able retrieve json value with nested include', async function () { - const user = await this.User.create({ - emergency_contact: { - name: 'kate', - }, - }); - - await this.Order.create({ UserId: user.id }); - - const orders = await this.Order.findAll({ - attributes: ['id'], - include: [{ - model: this.User, - attributes: [ - [this.sequelize.json('emergency_contact.name'), 'katesName'], - ], - }], - }); - - expect(orders[0].User.getDataValue('katesName')).to.equal('kate'); - }); - - it('should be able to check any of these array strings exist as top-level keys', async function () { - await this.User.create({ - emergency_contact: { - name: 'kate', - gamer: true, - grade: 'A', - }, - }); - - await this.User.create({ - emergency_contact: { - name: 'richard', - programmer: true, - grade: 'S', - }, - }); - - const users = await this.User.findAll({ - where: { - emergency_contact: { - [Op.anyKeyExists]: ['gamer', 'something'], - }, - }, - }); - - expect(users.length).to.equal(1); - }); - - it('should be able to check all of these array strings exist as top-level keys', async function () { - await this.User.create({ - emergency_contact: { - name: 'kate', - gamer: true, - grade: 'A', - }, - }); - - await this.User.create({ - emergency_contact: { - name: 'richard', - programmer: true, - grade: 'S', - }, - }); - - const users = await this.User.findAll({ - where: { - emergency_contact: { - [Op.allKeysExist]: ['name', 'programmer', 'grade'], - }, - }, - }); - - expect(users.length).to.equal(1); - }); - }); -}); diff --git a/packages/core/test/integration/json.test.ts b/packages/core/test/integration/json.test.ts new file mode 100644 index 000000000000..71bd7ad19f5b --- /dev/null +++ b/packages/core/test/integration/json.test.ts @@ -0,0 +1,476 @@ +import { expect } from 'chai'; +import semver from 'semver'; +import type { InferAttributes, NonAttribute, CreationOptional, InferCreationAttributes } from '@sequelize/core'; +import { DataTypes, Op, Model, sql } from '@sequelize/core'; +import { Attribute, BelongsTo } from '@sequelize/core/decorators-legacy'; +import { + beforeAll2, + beforeEach2, + disableDatabaseResetForSuite, + enableTruncateDatabaseForSuite, + inlineErrorCause, + sequelize, +} from './support'; + +const dialect = sequelize.dialect; +const dialectName = dialect.name; + +/** + * Whether the current dialect supports comparing JSON to JSON directly. + * In dialects like postgres, no "json = json" operator exists, we need to cast to text first. + * It does however support "jsonb = jsonb". + */ +const dialectSupportsJsonEquality = ['sqlite', 'mysql', 'mariadb', 'mssql'].includes(dialectName); + +describe('JSON Manipulation', () => { + if (!dialect.supports.dataTypes.JSON) { + return; + } + + const vars = beforeEach2(async () => { + class User extends Model> { + @Attribute(DataTypes.JSON) + declare jsonAttr: any; + } + + sequelize.addModels([User]); + await sequelize.sync({ force: true }); + + return { User }; + }); + + it('supports inserting json', async () => { + const user = await vars.User.create({ + jsonAttr: { username: 'joe' }, + }); + + expect(user.jsonAttr).to.deep.equal({ username: 'joe' }); + }); + + it('supports updating json', async () => { + const user = await vars.User.create({ + jsonAttr: { username: 'joe' }, + }); + + user.jsonAttr = { name: 'larry' }; + + await user.save(); + + expect(user.jsonAttr).to.deep.equal({ name: 'larry' }); + }); + + it('should be able to store strings that require escaping', async () => { + const text = 'Multi-line \n \'$string\' needing "escaping" for $$ and $1 type values'; + + await vars.User.create({ jsonAttr: text }); + const user = await vars.User.findOne({ rejectOnEmpty: true }); + expect(user.jsonAttr).to.equal(text); + }); +}); + +const JSON_OBJECT = { name: 'swen', phones: [1337, 42] }; +const JSON_STRING = 'kate'; + +describe('JSON Querying', () => { + if (!dialect.supports.dataTypes.JSON) { + return; + } + + disableDatabaseResetForSuite(); + + const vars = beforeAll2(async () => { + class User extends Model, InferCreationAttributes> { + declare id: CreationOptional; + + @Attribute(DataTypes.JSON) + declare objectJsonAttr: object; + + @Attribute(DataTypes.JSON) + declare stringJsonAttr: string; + } + + class Order extends Model> { + @BelongsTo(User, 'userId') + declare user: NonAttribute; + + @Attribute(DataTypes.INTEGER) + declare userId: number; + } + + sequelize.addModels([User, Order]); + await sequelize.sync({ force: true }); + + const user = await User.create({ + objectJsonAttr: JSON_OBJECT, + stringJsonAttr: JSON_STRING, + }); + + await Order.create({ userId: user.id }); + + return { User, Order }; + }); + + it('parses retrieved JSON values', async () => { + const user = await vars.User.findOne({ rejectOnEmpty: true }); + + expect(user.objectJsonAttr).to.deep.eq(JSON_OBJECT); + expect(user.stringJsonAttr).to.eq(JSON_STRING); + }); + + if (dialectSupportsJsonEquality) { + it('should be able to compare JSON to JSON directly', async () => { + const user = await vars.User.findOne({ + where: { + stringJsonAttr: JSON_STRING, + }, + }); + + expect(user).to.exist; + }); + } else { + it('should not be able to compare JSON to JSON directly', async () => { + await expect(vars.User.findOne({ + where: { + stringJsonAttr: JSON_STRING, + }, + })).to.be.rejected; + }); + } + + if (dialect.supports.jsonOperations) { + it('should be able to retrieve element of array by index', async () => { + const user = await vars.User.findOne({ + attributes: [[sql.attribute('objectJsonAttr.phones[1]'), 'firstEmergencyNumber']], + rejectOnEmpty: true, + }); + + // @ts-expect-error -- typings are not currently designed to handle custom attributes + const firstNumber: string = user.getDataValue('firstEmergencyNumber'); + + expect(Number.parseInt(firstNumber, 10)).to.equal(42); + }); + + it('should be able to query using JSON path objects', async () => { + // JSON requires casting to text in postgres. There is no "json = json" operator + // No-cast version is tested higher up in this suite + const comparison = dialectName === 'postgres' ? { 'name::text': '"swen"' } : { name: 'swen' }; + + const user = await vars.User.findOne({ + where: { objectJsonAttr: comparison }, + }); + + expect(user).to.exist; + }); + + it('should be able to query using JSON path dot notation', async () => { + // JSON requires casting to text in postgres. There is no "json = json" operator + // No-cast version is tested higher up in this suite + const comparison = dialectName === 'postgres' ? { 'objectJsonAttr.name::text': '"swen"' } : { 'objectJsonAttr.name': 'swen' }; + + const user = await vars.User.findOne({ + where: comparison, + }); + + expect(user).to.exist; + }); + + it('should be able to query using the JSON unquote syntax', async () => { + const user = await vars.User.findOne({ + // JSON unquote does not require casting to text, as it already returns text + where: { 'objectJsonAttr.name:unquote': 'swen' }, + }); + + expect(user).to.exist; + }); + + it('should be able retrieve json value with nested include', async () => { + const orders = await vars.Order.findAll({ + attributes: ['id'], + include: [{ + model: vars.User, + attributes: [ + [sql.attribute('objectJsonAttr.name'), 'name'], + ], + }], + }); + + // we can't automatically detect that the output is JSON type in mariadb < 10.4.3, + // and we don't yet support specifying (nor inferring) the type of custom attributes, + // so for now the output is different in this specific case + const expectedResult = dialectName === 'mariadb' && semver.lt(sequelize.getDatabaseVersion(), '10.4.3') ? '"swen"' : 'swen'; + + // @ts-expect-error -- getDataValue does not support custom attributes + expect(orders[0].user.getDataValue('name')).to.equal(expectedResult); + }); + } +}); + +describe('JSON Casting', () => { + if (!dialect.supports.dataTypes.JSON || !dialect.supports.jsonOperations) { + return; + } + + enableTruncateDatabaseForSuite(); + + const vars = beforeAll2(async () => { + class User extends Model, InferCreationAttributes> { + @Attribute(DataTypes.JSON) + declare jsonAttr: any; + } + + sequelize.addModels([User]); + await sequelize.sync({ force: true }); + + return { User }; + }); + + it('supports casting to timestamp types', async () => { + await vars.User.create({ + jsonAttr: { + date: new Date('2021-01-02').toISOString(), + }, + }); + + const cast = dialectName === 'mysql' || dialectName === 'mariadb' ? 'DATETIME' : 'TIMESTAMPTZ'; + + const user = await vars.User.findOne({ + where: { + [`jsonAttr.date:unquote::${cast}`]: new Date('2021-01-02'), + }, + }); + + expect(user).to.exist; + + const user2 = await vars.User.findOne({ + where: { + [`jsonAttr.date:unquote::${cast}`]: { + [Op.between]: [new Date('2021-01-01'), new Date('2021-01-03')], + }, + }, + }); + + expect(user2).to.exist; + }); + + it('supports casting to boolean', async () => { + // These dialects do not have a native BOOLEAN type + if (dialectName === 'mariadb' || dialectName === 'mysql') { + return; + } + + await vars.User.create({ + jsonAttr: { + boolean: true, + }, + }); + + const user = await vars.User.findOne({ + where: { + 'jsonAttr.boolean:unquote::boolean': true, + }, + }); + + expect(user).to.exist; + }); + + it('supports casting to numbers', async () => { + await vars.User.create({ + jsonAttr: { + integer: 7, + }, + }); + + const cast = dialectName === 'mysql' || dialectName === 'mariadb' ? 'SIGNED' : 'INTEGER'; + + const user = await vars.User.findOne({ + where: { + [`jsonAttr.integer:unquote::${cast}`]: 7, + }, + }); + + expect(user).to.exist; + }); +}); + +describe('JSONB Querying', () => { + if (!dialect.supports.dataTypes.JSONB) { + return; + } + + disableDatabaseResetForSuite(); + + const vars = beforeAll2(async () => { + class User extends Model, InferCreationAttributes> { + declare id: CreationOptional; + + @Attribute(DataTypes.JSONB) + declare objectJsonbAttr: object; + + @Attribute(DataTypes.JSONB) + declare stringJsonbAttr: CreationOptional; + } + + class Order extends Model> { + @BelongsTo(User, 'userId') + declare user: NonAttribute; + + @Attribute(DataTypes.INTEGER) + declare userId: number; + } + + sequelize.addModels([User, Order]); + await sequelize.sync({ force: true }); + + const user = await User.create({ + objectJsonbAttr: JSON_OBJECT, + stringJsonbAttr: JSON_STRING, + }); + + await Order.create({ userId: user.id }); + + return { User, Order }; + }); + + it('should be able to query using the nested query language', async () => { + const user = await vars.User.findOne({ + // JSONB does not require casting + where: { objectJsonbAttr: { name: 'swen' } }, + }); + + expect(user).to.exist; + }); + + it('should be able to query using the JSON unquote syntax', async () => { + const user = await vars.User.findOne({ + where: { 'objectJsonbAttr.name:unquote': 'swen' }, + }); + + expect(user).to.exist; + }); + + it('should be able to query using dot syntax', async () => { + const user = await vars.User.findOne({ + // JSONB does not require casting, nor unquoting + where: { 'objectJsonbAttr.name': 'swen' }, + }); + + expect(user).to.exist; + }); + + it('should be able retrieve json value with nested include', async () => { + const orders = await vars.Order.findAll({ + attributes: ['id'], + include: [{ + model: vars.User, + attributes: [ + [sql.attribute('objectJsonbAttr.name'), 'name'], + ], + }], + }); + + // @ts-expect-error -- getDataValue's typing does not support custom attributes + expect(orders[0].user.getDataValue('name')).to.equal('swen'); + }); + + it('should be able to check any of these array strings exist as top-level keys', async () => { + const user = await vars.User.findOne({ + where: { + objectJsonbAttr: { + [Op.anyKeyExists]: ['name', 'does-not-exist'], + }, + }, + }); + + expect(user).to.exist; + }); + + it('should be able to check all of these array strings exist as top-level keys', async () => { + const user = await vars.User.findOne({ + where: { + objectJsonbAttr: { + [Op.allKeysExist]: ['name', 'phones'], + }, + }, + }); + + expect(user).to.exist; + }); + + it('should be able to findOrCreate with values that require escaping', async () => { + const text = 'Multi-line \'$string\' needing "escaping" for $$ and $1 type values'; + + const [user, created] = await vars.User.findOrCreate({ + where: { objectJsonbAttr: { text } }, + defaults: { objectJsonbAttr: { text } }, + }); + + expect(created).to.equal(true); + expect(user.isNewRecord).to.equal(false); + + const refreshedUser = await vars.User.findOne({ where: { 'objectJsonbAttr.text:unquote': text } }); + expect(refreshedUser).to.exist; + }); +}); + +describe('JSONB Casting', () => { + if (!dialect.supports.dataTypes.JSONB) { + return; + } + + enableTruncateDatabaseForSuite(); + + const vars = beforeAll2(async () => { + class User extends Model, InferCreationAttributes> { + @Attribute(DataTypes.JSONB) + declare jsonbAttr: any; + } + + sequelize.addModels([User]); + await sequelize.sync({ force: true }); + + return { User }; + }); + + it('supports comparing to json null', async () => { + await vars.User.create({ + jsonbAttr: { + // This is JSON null + value: null, + }, + }); + + const user = await vars.User.findOne({ + where: { + // Using the 'EQ' operator compares to SQL NULL + 'jsonbAttr.value': { [Op.eq]: null }, + }, + }); + + expect(user).to.exist; + }); + + it('supports comparing to SQL NULL', async () => { + await vars.User.create({ + jsonbAttr: {}, + }); + + const user = await vars.User.findOne({ + where: { + // Using the 'IS' operator compares to SQL NULL + 'jsonbAttr.value': { [Op.is]: null }, + }, + }); + + expect(user).to.exist; + }); + + it('requires being explicit when comparing to NULL', async () => { + const error = await expect(vars.User.findOne({ + where: { + 'jsonbAttr.value': null, + }, + })).to.be.rejected; + + expect(inlineErrorCause(error)).to.include('Because JSON has two possible null values, comparing a JSON/JSONB attribute to NULL requires an explicit comparison operator. Use the `Op.is` operator to compare to SQL NULL, or the `Op.eq` operator to compare to JSON null.'); + }); +}); diff --git a/packages/core/test/integration/model.test.js b/packages/core/test/integration/model.test.js index 7d965ef0370e..8ba54193b424 100644 --- a/packages/core/test/integration/model.test.js +++ b/packages/core/test/integration/model.test.js @@ -1000,28 +1000,6 @@ describe(Support.getTestDialectTeaser('Model'), () => { } }); - it('throws an error if where has a key with undefined value', async function () { - const data = [ - { username: 'Peter', secretValue: '42' }, - { username: 'Paul', secretValue: '42' }, - { username: 'Bob', secretValue: '43' }, - ]; - - await this.User.bulkCreate(data); - try { - await this.User.update({ username: 'Bill' }, { - where: { - secretValue: '42', - username: undefined, - }, - }); - throw new Error('Update should throw an error if where has a key with undefined value'); - } catch (error) { - expect(error).to.be.an.instanceof(Error); - expect(error.message).to.equal('WHERE parameter "username" has invalid "undefined" value'); - } - }); - it('updates only values that match the allowed fields', async function () { const data = [{ username: 'Peter', secretValue: '42' }]; @@ -1349,19 +1327,6 @@ describe(Support.getTestDialectTeaser('Model'), () => { expect(await User.findAll()).to.have.lengthOf(0); }); - it('throws an error if where has a key with undefined value', async function () { - const User = this.sequelize.define('User', { username: DataTypes.STRING }); - - await this.sequelize.sync({ force: true }); - try { - await User.destroy({ where: { username: undefined } }); - throw new Error('Destroy should throw an error if where has a key with undefined value'); - } catch (error) { - expect(error).to.be.an.instanceof(Error); - expect(error.message).to.equal('WHERE parameter "username" has invalid "undefined" value'); - } - }); - if (current.dialect.supports.transactions) { it('supports transactions', async function () { const sequelize = await Support.prepareTransactionTest(this.sequelize); @@ -1525,7 +1490,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { ]); const user = await User.findByPk(1); await user.destroy(); - expect(await User.findOne({ where: 1, paranoid: false })).to.exist; + expect(await User.findOne({ where: { id: 1 }, paranoid: false })).to.exist; expect(await User.findByPk(1)).to.be.null; expect(await User.count()).to.equal(2); expect(await User.count({ paranoid: false })).to.equal(3); @@ -2580,12 +2545,6 @@ describe(Support.getTestDialectTeaser('Model'), () => { expect(res).to.have.length(2); }); - it('should fail when array contains strings', async function () { - await expect(this.User.findAll({ - where: ['this is a mistake', ['dont do it!']], - })).to.eventually.be.rejectedWith(Error, 'Support for literal replacements in the `where` object has been removed.'); - }); - it('should not fail with an include', async function () { const users = await this.User.findAll({ where: this.sequelize.literal(`${this.sequelize.queryInterface.queryGenerator.quoteIdentifiers('Projects.title')} = ${this.sequelize.queryInterface.queryGenerator.escape('republic')}`), diff --git a/packages/core/test/integration/model/create.test.js b/packages/core/test/integration/model/create.test.js index e456042902a7..5b2900bb8831 100644 --- a/packages/core/test/integration/model/create.test.js +++ b/packages/core/test/integration/model/create.test.js @@ -849,13 +849,17 @@ describe(Support.getTestDialectTeaser('Model'), () => { it('is possible to use casting when creating an instance', async function () { const type = ['mysql', 'mariadb'].includes(dialectName) ? 'signed' : 'integer'; + const bindParam = dialectName === 'postgres' ? '$1' + : dialectName === 'sqlite' ? '$sequelize_1' + : dialectName === 'mssql' ? '@sequelize_1' + : '?'; let match = false; const user = await this.User.create({ intVal: this.sequelize.cast('1', type), }, { logging(sql) { - expect(sql).to.match(new RegExp(`CAST\\(N?'1' AS ${type.toUpperCase()}\\)`)); + expect(sql).to.include(`CAST(${bindParam} AS ${type.toUpperCase()})`); match = true; }, }); diff --git a/packages/core/test/integration/model/findAll.test.js b/packages/core/test/integration/model/findAll.test.js index dcbe3abb07f7..f8a7ee9cef02 100644 --- a/packages/core/test/integration/model/findAll.test.js +++ b/packages/core/test/integration/model/findAll.test.js @@ -1444,11 +1444,6 @@ The following associations are defined on "Worker": "ToDos"`); expect(user.Image.get('path')).to.equal('folder1/folder2/logo.png'); } }); - - it('should throw for undefined where parameters', async function () { - await expect(this.User.findAll({ where: { username: undefined } })) - .to.be.rejectedWith('WHERE parameter "username" has invalid "undefined" value'); - }); }); }); diff --git a/packages/core/test/integration/model/json.test.js b/packages/core/test/integration/model/json.test.js index 9bc805c5884a..a15f4fba188d 100644 --- a/packages/core/test/integration/model/json.test.js +++ b/packages/core/test/integration/model/json.test.js @@ -1,11 +1,10 @@ 'use strict'; const chai = require('chai'); -const dayjs = require('dayjs'); const expect = chai.expect; const Support = require('../support'); -const { DataTypes, Op, Sequelize } = require('@sequelize/core'); +const { DataTypes, Op } = require('@sequelize/core'); const current = Support.sequelize; const dialect = current.dialect; @@ -19,12 +18,11 @@ describe(Support.getTestDialectTeaser('Model'), () => { beforeEach(async function () { this.Event = this.sequelize.define('Event', { data: { - // TODO: This should be JSONB, not JSON, because the auto-GIN index is only added - // to JSONB columns. This was accidentally changed by https://github.com/sequelize/sequelize/issues/7094 - // re-enable the index when fixed - type: DataTypes.JSON, + // TODO: JSON & JSONB tests should be split + type: dialect.name === 'postgres' ? DataTypes.JSONB : DataTypes.JSON, field: 'event_data', - // index: true, + // This is only available on JSONB + index: dialect.name === 'postgres', }, json: DataTypes.JSON, }); @@ -38,7 +36,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { await this.Event.findOrCreate({ where: { - json: { some: { input: 'Hello' } }, + json: { 'some.input:unquote': 'Hello' }, }, defaults: { json: { some: { input: 'Hello' }, input: [1, 2, 3] }, @@ -86,392 +84,11 @@ describe(Support.getTestDialectTeaser('Model'), () => { }); }); - describe('update', () => { - if (dialect.supports.jsonOperations) { - it('should update with JSON column (dot notation)', async function () { - await this.Event.bulkCreate([{ - id: 1, - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }, - }, { - id: 2, - data: { - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Multiverse Scientist', - }, - }]); - - await this.Event.update({ - data: { - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Galactic Fed Prisioner', - }, - }, { - where: { - 'data.name.first': 'Rick', - }, - }); - - const event = await this.Event.findByPk(2); - expect(event.get('data')).to.eql({ - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Galactic Fed Prisioner', - }); - }); - - it('should update with JSON column (JSON notation)', async function () { - await this.Event.bulkCreate([{ - id: 1, - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }, - }, { - id: 2, - data: { - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Multiverse Scientist', - }, - }]); - - await this.Event.update({ - data: { - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Galactic Fed Prisioner', - }, - }, { - where: { - data: { - name: { - first: 'Rick', - }, - }, - }, - }); - - const event = await this.Event.findByPk(2); - expect(event.get('data')).to.eql({ - name: { - first: 'Rick', - last: 'Sanchez', - }, - employment: 'Galactic Fed Prisioner', - }); - }); - } - - it('should update an instance with JSON data', async function () { - const event0 = await this.Event.create({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }, - }); - - await event0.update({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: null, - }, - }); - - const events = await this.Event.findAll(); - const event = events[0]; - - expect(event.get('data')).to.eql({ - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: null, - }); - }); - }); - describe('find', () => { if (!dialect.supports.jsonOperations) { return; } - it('should be possible to query a nested value', async function () { - await Promise.all([this.Event.create({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }, - }), this.Event.create({ - data: { - name: { - first: 'Marge', - last: 'Simpson', - }, - employment: 'Housewife', - }, - })]); - - const events = await this.Event.findAll({ - where: { - data: { - employment: 'Housewife', - }, - }, - }); - - const event = events[0]; - - expect(events.length).to.equal(1); - expect(event.get('data')).to.eql({ - name: { - first: 'Marge', - last: 'Simpson', - }, - employment: 'Housewife', - }); - }); - it('should be possible to query dates with array operators', async function () { - const now = dayjs().millisecond(0).toDate(); - const before = dayjs().millisecond(0).subtract(1, 'day') - .toDate(); - const after = dayjs().millisecond(0).add(1, 'day') - .toDate(); - - await Promise.all([this.Event.create({ - json: { - user: 'Homer', - lastLogin: now, - }, - })]); - - const events0 = await this.Event.findAll({ - where: { - json: { - lastLogin: now, - }, - }, - }); - - const event0 = events0[0]; - - expect(events0.length).to.equal(1); - expect(event0.get('json')).to.eql({ - user: 'Homer', - lastLogin: now.toISOString(), - }); - - const events = await this.Event.findAll({ - where: { - json: { - lastLogin: { [Op.between]: [before, after] }, - }, - }, - }); - - const event = events[0]; - - expect(events.length).to.equal(1); - expect(event.get('json')).to.eql({ - user: 'Homer', - lastLogin: now.toISOString(), - }); - }); - it('should be possible to query a boolean with array operators', async function () { - await Promise.all([this.Event.create({ - json: { - user: 'Homer', - active: true, - }, - })]); - - const events0 = await this.Event.findAll({ - where: { - json: { - active: true, - }, - }, - }); - - const event0 = events0[0]; - - expect(events0.length).to.equal(1); - expect(event0.get('json')).to.eql({ - user: 'Homer', - active: true, - }); - - const events = await this.Event.findAll({ - where: { - json: { - active: { [Op.in]: [true, false] }, - }, - }, - }); - - const event = events[0]; - - expect(events.length).to.equal(1); - expect(event.get('json')).to.eql({ - user: 'Homer', - active: true, - }); - }); - it('should be possible to query a nested integer value', async function () { - await Promise.all([this.Event.create({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - age: 40, - }, - }), this.Event.create({ - data: { - name: { - first: 'Marge', - last: 'Simpson', - }, - age: 37, - }, - })]); - - const events = await this.Event.findAll({ - where: { - data: { - age: { - [Op.gt]: 38, - }, - }, - }, - }); - - const event = events[0]; - - expect(events.length).to.equal(1); - expect(event.get('data')).to.eql({ - name: { - first: 'Homer', - last: 'Simpson', - }, - age: 40, - }); - }); - it('should be possible to query a nested null value', async function () { - await Promise.all([this.Event.create({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }, - }), this.Event.create({ - data: { - name: { - first: 'Marge', - last: 'Simpson', - }, - employment: null, - }, - })]); - - const events = await this.Event.findAll({ - where: { - data: { - employment: null, - }, - }, - }); - - expect(events.length).to.equal(1); - expect(events[0].get('data')).to.eql({ - name: { - first: 'Marge', - last: 'Simpson', - }, - employment: null, - }); - }); - it('should be possible to query for nested fields with hyphens/dashes, #8718', async function () { - await Promise.all([this.Event.create({ - data: { - name: { - first: 'Homer', - last: 'Simpson', - }, - status_report: { - 'red-indicator': { - level$$level: true, - }, - }, - employment: 'Nuclear Safety Inspector', - }, - }), this.Event.create({ - data: { - name: { - first: 'Marge', - last: 'Simpson', - }, - employment: null, - }, - })]); - - const events = await this.Event.findAll({ - where: { - data: { - status_report: { - 'red-indicator': { - level$$level: true, - }, - }, - }, - }, - }); - - expect(events.length).to.equal(1); - expect(events[0].get('data')).to.eql({ - name: { - first: 'Homer', - last: 'Simpson', - }, - status_report: { - 'red-indicator': { - level$$level: true, - }, - }, - employment: 'Nuclear Safety Inspector', - }); - }); it('should be possible to query multiple nested values', async function () { await this.Event.create({ data: { @@ -517,7 +134,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { ], }); - expect(events.length).to.equal(2); + expect(events).to.have.length(2); expect(events[0].get('data')).to.eql({ name: { @@ -660,76 +277,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { await this.sequelize.sync({ force: true }); }); - it('should properly escape the single quotes', async function () { - await this.Model.create({ - data: { - type: 'Point', - properties: { - exploit: '\'); DELETE YOLO INJECTIONS; -- ', - }, - }, - }); - }); - - it('should properly escape the single quotes in array', async function () { - await this.Model.create({ - data: { - type: 'Point', - coordinates: [39.807_222, '\'); DELETE YOLO INJECTIONS; --'], - }, - }); - }); - if (dialect.supports.jsonOperations) { - it('should properly escape path keys', async function () { - await this.Model.findAll({ - raw: true, - attributes: ['id'], - where: { - data: { - 'a\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- ': 1, - }, - }, - }); - }); - - it('should properly escape path keys with sequelize.json', async function () { - await this.Model.findAll({ - raw: true, - attributes: ['id'], - where: this.sequelize.json('data.id\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- ', '1'), - }); - }); - - it('should be possible to find with properly escaped select query', async function () { - await this.Model.create({ - data: { - type: 'Point', - properties: { - exploit: '\'); DELETE YOLO INJECTIONS; -- ', - }, - }, - }); - - const result = await this.Model.findOne({ - where: { - data: { - type: 'Point', - properties: { - exploit: '\'); DELETE YOLO INJECTIONS; -- ', - }, - }, - }, - }); - - expect(result.get('data')).to.deep.equal({ - type: 'Point', - properties: { - exploit: '\'); DELETE YOLO INJECTIONS; -- ', - }, - }); - }); - it('should query an instance with JSONB data and order while trying to inject', async function () { await this.Event.create({ data: { @@ -759,46 +307,27 @@ describe(Support.getTestDialectTeaser('Model'), () => { }, })]); - if (current.options.dialect === 'sqlite') { - const events = await this.Event.findAll({ - where: { - data: { - name: { - last: 'Simpson', - }, + const events = await this.Event.findAll({ + where: { + data: { + name: { + last: 'Simpson', }, }, - order: [ - ['data.name.first}\'); INSERT INJECTION HERE! SELECT (\''], - ], - }); + }, + order: [ + ['data.name.first}\'); INSERT INJECTION HERE! SELECT (\''], + ], + }); - expect(events).to.be.ok; - expect(events[0].get('data')).to.eql({ - name: { - first: 'Homer', - last: 'Simpson', - }, - employment: 'Nuclear Safety Inspector', - }); - - return; - } - - if (current.options.dialect === 'postgres') { - await expect(this.Event.findAll({ - where: { - data: { - name: { - last: 'Simpson', - }, - }, - }, - order: [ - ['data.name.first}\'); INSERT INJECTION HERE! SELECT (\''], - ], - })).to.eventually.be.rejectedWith(Error); - } + expect(events).to.be.ok; + expect(events[0].get('data')).to.eql({ + name: { + first: 'Homer', + last: 'Simpson', + }, + employment: 'Nuclear Safety Inspector', + }); }); } }); diff --git a/packages/core/test/integration/model/paranoid.test.js b/packages/core/test/integration/model/paranoid.test.js index e00e974051ae..e9d932a5ec69 100644 --- a/packages/core/test/integration/model/paranoid.test.js +++ b/packages/core/test/integration/model/paranoid.test.js @@ -8,6 +8,8 @@ const expect = chai.expect; const sinon = require('sinon'); const current = Support.sequelize; +const { dialect } = current; +const dialectName = dialect.name; describe(Support.getTestDialectTeaser('Model'), () => { describe('paranoid', () => { @@ -93,7 +95,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { type: DataTypes.STRING, }, data: { - type: DataTypes.JSON, + type: dialectName === 'postgres' ? DataTypes.JSONB : DataTypes.JSON, }, deletedAt: { type: DataTypes.DATE, diff --git a/packages/core/test/integration/model/schema.test.js b/packages/core/test/integration/model/schema.test.js index 88e7eaaf87db..40837b7db7d5 100644 --- a/packages/core/test/integration/model/schema.test.js +++ b/packages/core/test/integration/model/schema.test.js @@ -317,16 +317,12 @@ describe(Support.getTestDialectTeaser('Model'), () => { beforeEach(async function () { const Location = this.Location; - try { - await Location.sync({ force: true }); - await Location.create({ name: 'HQ' }); - const obj = await Location.findOne({ where: { name: 'HQ' } }); - expect(obj).to.not.be.null; - expect(obj.name).to.equal('HQ'); - locationId = obj.id; - } catch (error) { - expect(error).to.be.null; - } + await Location.sync({ force: true }); + await Location.create({ name: 'HQ' }); + const obj = await Location.findOne({ where: { name: 'HQ' } }); + expect(obj).to.not.be.null; + expect(obj.name).to.equal('HQ'); + locationId = obj.id; }); it('should be able to insert and retrieve associated data into the table in schema_one', async function () { diff --git a/packages/core/test/integration/model/scope.test.js b/packages/core/test/integration/model/scope.test.js index f2fbedec1fc6..045085425d15 100644 --- a/packages/core/test/integration/model/scope.test.js +++ b/packages/core/test/integration/model/scope.test.js @@ -52,7 +52,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { }, }, like_t: { - where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('username')), 'LIKE', '%t%'), + where: Sequelize.where(Sequelize.fn('LOWER', Sequelize.col('username')), Op.like, '%t%'), }, }, }); diff --git a/packages/core/test/integration/sequelize.test.js b/packages/core/test/integration/sequelize.test.js index f3214a8f5383..9185ee9ca594 100644 --- a/packages/core/test/integration/sequelize.test.js +++ b/packages/core/test/integration/sequelize.test.js @@ -38,16 +38,6 @@ describe(Support.getTestDialectTeaser('Sequelize'), () => { expect(sequelize.config.host).to.equal('127.0.0.1'); }); - it('should set operators aliases on dialect queryGenerator', () => { - const operatorsAliases = { fake: true }; - const sequelize = Support.createSequelizeInstance({ operatorsAliases }); - - expect(sequelize).to.have.property('dialect'); - expect(sequelize.dialect).to.have.property('queryGenerator'); - expect(sequelize.dialect.queryGenerator).to.have.property('OperatorsAliasMap'); - expect(sequelize.dialect.queryGenerator.OperatorsAliasMap).to.be.eql(operatorsAliases); - }); - if (dialect === 'sqlite') { it('should work with connection strings (1)', () => { new Sequelize('sqlite://test.sqlite'); diff --git a/packages/core/test/integration/sequelize/query.test.js b/packages/core/test/integration/sequelize/query.test.js index 67cf07c2faad..eb86073288b4 100644 --- a/packages/core/test/integration/sequelize/query.test.js +++ b/packages/core/test/integration/sequelize/query.test.js @@ -3,7 +3,7 @@ const { expect } = require('chai'); const Support = require('../support'); -const { Sequelize, DataTypes, DatabaseError, UniqueConstraintError, ForeignKeyConstraintError } = require('@sequelize/core'); +const { Sequelize, DataTypes, DatabaseError, UniqueConstraintError, ForeignKeyConstraintError, sql } = require('@sequelize/core'); const dialectName = Support.getTestDialect(); const sequelize = Support.sequelize; @@ -714,7 +714,7 @@ describe(Support.getTestDialectTeaser('Sequelize'), () => { if (['postgres', 'sqlite', 'mssql'].includes(dialectName)) { it('does not improperly escape arrays of strings bound to named parameters', async function () { - const result = await this.sequelize.query('select :stringArray as foo', { raw: true, replacements: { stringArray: ['"string"'] } }); + const result = await this.sequelize.query('select :stringArray as foo', { raw: true, replacements: { stringArray: sql.list(['"string"']) } }); expect(result[0]).to.deep.equal([{ foo: '"string"' }]); }); } diff --git a/packages/core/test/integration/support.ts b/packages/core/test/integration/support.ts index a7b6aa75e987..c21dbd390bf6 100644 --- a/packages/core/test/integration/support.ts +++ b/packages/core/test/integration/support.ts @@ -61,12 +61,27 @@ export function disableDatabaseResetForSuite() { }); } +let databaseTruncateEnabled = false; +export function enableTruncateDatabaseForSuite() { + disableDatabaseResetForSuite(); + + before(async () => { + databaseTruncateEnabled = true; + }); + + after(() => { + databaseTruncateEnabled = false; + }); +} + beforeEach(async () => { - if (databaseResetDisabled) { - return; + if (!databaseResetDisabled) { + await Support.clearDatabase(Support.sequelize); } - await Support.clearDatabase(Support.sequelize); + if (databaseTruncateEnabled) { + await Support.sequelize.truncate({ cascade: true }); + } }); afterEach(async function checkRunningQueries() { diff --git a/packages/core/test/support.ts b/packages/core/test/support.ts index 296e8f86212e..d13bf9551d94 100644 --- a/packages/core/test/support.ts +++ b/packages/core/test/support.ts @@ -47,7 +47,7 @@ function withInlineCause(cb: (() => any)): () => void { }; } -function inlineErrorCause(error: unknown): string { +export function inlineErrorCause(error: unknown): string { if (!(error instanceof Error)) { return String(error); } @@ -407,7 +407,7 @@ export function toHaveProperties>(properties type MaybeLazy = T | (() => T); export function expectsql( - query: MaybeLazy<{ query: string, bind: unknown } | Error>, + query: MaybeLazy<{ query: string, bind?: unknown } | Error>, assertions: { query: PartialRecord, bind: PartialRecord, @@ -418,7 +418,7 @@ export function expectsql( assertions: PartialRecord, ): void; export function expectsql( - query: MaybeLazy, + query: MaybeLazy, assertions: | { query: PartialRecord, bind: PartialRecord } | PartialRecord, diff --git a/packages/core/test/types/sequelize.ts b/packages/core/test/types/sequelize.ts index 886cb0f5d6c5..5ea53145db75 100644 --- a/packages/core/test/types/sequelize.ts +++ b/packages/core/test/types/sequelize.ts @@ -31,7 +31,6 @@ Sequelize.and(); Sequelize.or(); Sequelize.json('data.id'); Sequelize.where(Sequelize.col('ABS'), Op.is, null); -Sequelize.where(Sequelize.col('ABS'), '=', null); // instance members sequelize.fn('max', sequelize.col('age')); diff --git a/packages/core/test/unit/data-types/arrays.test.ts b/packages/core/test/unit/data-types/arrays.test.ts index 3e9717ed1c2d..ced0b8d5a2bf 100644 --- a/packages/core/test/unit/data-types/arrays.test.ts +++ b/packages/core/test/unit/data-types/arrays.test.ts @@ -1,9 +1,9 @@ import { expect } from 'chai'; import { ValidationErrorItem, DataTypes } from '@sequelize/core'; -import { sequelize } from '../../support'; +import { expectsql, sequelize } from '../../support'; import { testDataTypeSql } from './_utils'; -const dialect = sequelize.dialect; +const { dialect, queryGenerator } = sequelize; describe('DataTypes.ARRAY', () => { const unsupportedError = new Error(`${dialect.name} does not support the ARRAY data type.\nSee https://sequelize.org/docs/v7/other-topics/other-data-types/ for a list of supported data types.`); @@ -112,4 +112,38 @@ describe('DataTypes.ARRAY', () => { }); }); }); + + describe('escape', () => { + if (!dialect.supports.dataTypes.ARRAY) { + return; + } + + it('escapes array of JSON', () => { + expectsql(queryGenerator.escape([ + { some: 'nested', more: { nested: true }, answer: 42 }, + 43, + 'joe', + ], { type: DataTypes.ARRAY(DataTypes.JSON) }), { + postgres: 'ARRAY[\'{"some":"nested","more":{"nested":true},"answer":42}\',\'43\',\'"joe"\']::JSON[]', + }); + }); + + if (dialect.supports.dataTypes.JSONB) { + it('escapes array of JSONB', () => { + expectsql( + queryGenerator.escape( + [ + { some: 'nested', more: { nested: true }, answer: 42 }, + 43, + 'joe', + ], + { type: DataTypes.ARRAY(DataTypes.JSONB) }, + ), + { + postgres: 'ARRAY[\'{"some":"nested","more":{"nested":true},"answer":42}\',\'43\',\'"joe"\']::JSONB[]', + }, + ); + }); + } + }); }); diff --git a/packages/core/test/unit/data-types/misc-data-types.test.ts b/packages/core/test/unit/data-types/misc-data-types.test.ts index a2f42d3086ae..39fa1d3881e7 100644 --- a/packages/core/test/unit/data-types/misc-data-types.test.ts +++ b/packages/core/test/unit/data-types/misc-data-types.test.ts @@ -2,10 +2,11 @@ import assert from 'node:assert'; import { expect } from 'chai'; import type { DataTypeInstance } from '@sequelize/core'; import { DataTypes, ValidationErrorItem } from '@sequelize/core'; -import { expectsql, sequelize, getTestDialect } from '../../support'; +import { expectsql, sequelize } from '../../support'; import { testDataTypeSql } from './_utils'; -const dialectName = getTestDialect(); +const { queryGenerator, dialect } = sequelize; +const dialectName = dialect.name; describe('DataTypes.BOOLEAN', () => { testDataTypeSql('BOOLEAN', DataTypes.BOOLEAN, { @@ -132,6 +133,62 @@ describe('DataTypes.JSON', () => { mssql: 'NVARCHAR(MAX)', sqlite: 'TEXT', }); + + describe('escape', () => { + if (!dialect.supports.dataTypes.JSON) { + return; + } + + it('escapes plain string', () => { + expectsql(queryGenerator.escape('string', { type: new DataTypes.JSON() }), { + default: `'"string"'`, + mysql: `CAST('"string"' AS JSON)`, + mssql: `N'"string"'`, + }); + }); + + it('escapes plain int', () => { + expectsql(queryGenerator.escape(0, { type: new DataTypes.JSON() }), { + default: `'0'`, + mysql: `CAST('0' AS JSON)`, + mssql: `N'0'`, + }); + expectsql(queryGenerator.escape(123, { type: new DataTypes.JSON() }), { + default: `'123'`, + mysql: `CAST('123' AS JSON)`, + mssql: `N'123'`, + }); + }); + + it('escapes boolean', () => { + expectsql(queryGenerator.escape(true, { type: new DataTypes.JSON() }), { + default: `'true'`, + mysql: `CAST('true' AS JSON)`, + mssql: `N'true'`, + }); + expectsql(queryGenerator.escape(false, { type: new DataTypes.JSON() }), { + default: `'false'`, + mysql: `CAST('false' AS JSON)`, + mssql: `N'false'`, + }); + }); + + it('escapes NULL', () => { + expectsql(queryGenerator.escape(null, { type: new DataTypes.JSON() }), { + default: `'null'`, + mysql: `CAST('null' AS JSON)`, + mssql: `N'null'`, + }); + }); + + it('nested object', () => { + expectsql(queryGenerator.escape({ some: 'nested', more: { nested: true }, answer: 42 }, { type: new DataTypes.JSON() }), { + default: `'{"some":"nested","more":{"nested":true},"answer":42}'`, + mysql: `CAST('{"some":"nested","more":{"nested":true},"answer":42}' AS JSON)`, + mssql: `N'{"some":"nested","more":{"nested":true},"answer":42}'`, + }); + }); + }); }); describe('DataTypes.JSONB', () => { diff --git a/packages/core/test/unit/dialects/abstract/query-generator.test.js b/packages/core/test/unit/dialects/abstract/query-generator.test.js deleted file mode 100644 index 62e8082e6b99..000000000000 --- a/packages/core/test/unit/dialects/abstract/query-generator.test.js +++ /dev/null @@ -1,145 +0,0 @@ -'use strict'; - -const chai = require('chai'); - -const expect = chai.expect; -const { Op } = require('@sequelize/core'); -const Support = require('../../../support'); - -const getAbstractQueryGenerator = Support.getAbstractQueryGenerator; -const { AbstractQueryGenerator } = require('@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/query-generator.js'); -const { expectsql } = require('../../../support'); - -describe('QueryGenerator', () => { - describe('whereItemQuery', () => { - it('should generate correct query for Symbol operators', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - QG.whereItemQuery(Op.or, [{ test: { [Op.gt]: 5 } }, { test: { [Op.lt]: 3 } }, { test: { [Op.in]: [4] } }]) - .should.be.equal('(test > 5 OR test < 3 OR test IN (4))'); - - QG.whereItemQuery(Op.and, [{ test: { [Op.between]: [2, 5] } }, { test: { [Op.ne]: 3 } }, { test: { [Op.not]: 4 } }]) - .should.be.equal('(test BETWEEN 2 AND 5 AND test != 3 AND test != 4)'); - - QG.whereItemQuery(Op.or, [{ test: { [Op.is]: null } }, { testSame: { [Op.eq]: null } }]) - .should.be.equal('(test IS NULL OR testSame IS NULL)'); - }); - - it('should not parse any strings as aliases operators', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - expect(() => QG.whereItemQuery('$or', [{ test: 5 }, { test: 3 }])) - .to.throw('Could not guess type of value { test: 5 }'); - - expect(() => QG.whereItemQuery('$and', [{ test: 5 }, { test: 3 }])) - .to.throw('Could not guess type of value { test: 5 }'); - - expect(() => QG.whereItemQuery('test', { $gt: 5 })) - .to.throw('Could not guess type of value { \'$gt\': 5 }'); - - expect(() => QG.whereItemQuery('test', { $between: [2, 5] })) - .to.throw('Could not guess type of value { \'$between\': [ 2, 5 ] }'); - - expect(() => QG.whereItemQuery('test', { $ne: 3 })) - .to.throw('Could not guess type of value { \'$ne\': 3 }'); - - expect(() => QG.whereItemQuery('test', { $not: 3 })) - .to.throw('Could not guess type of value { \'$not\': 3 }'); - - expect(() => QG.whereItemQuery('test', { $in: [4] })) - .to.throw('Could not guess type of value { \'$in\': [ 4 ] }'); - - // simulate transaction passed into where query argument - class Sequelize { - constructor() { - this.config = { - password: 'password', - }; - } - } - - class Transaction { - constructor() { - this.sequelize = new Sequelize(); - } - } - - expect(() => QG.whereItemQuery('test', new Transaction())).to.throw( - 'Could not guess type of value Transaction { sequelize: Sequelize { config: [Object] } }', - ); - }); - - it('should parse set aliases strings as operators', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - const aliases = { - OR: Op.or, - '!': Op.not, - '^^': Op.gt, - }; - - QG.setOperatorsAliases(aliases); - - QG.whereItemQuery('OR', [{ test: { '^^': 5 } }, { test: { '!': 3 } }, { test: { [Op.in]: [4] } }]) - .should.be.equal('(test > 5 OR test != 3 OR test IN (4))'); - - QG.whereItemQuery(Op.and, [{ test: { [Op.between]: [2, 5] } }, { test: { '!': 3 } }, { test: { '^^': 4 } }]) - .should.be.equal('(test BETWEEN 2 AND 5 AND test != 3 AND test > 4)'); - - expect(() => QG.whereItemQuery('OR', [{ test: { '^^': 5 } }, { test: { $not: 3 } }, { test: { [Op.in]: [4] } }])) - .to.throw('Could not guess type of value { \'$not\': 3 }'); - - expect(() => QG.whereItemQuery('OR', [{ test: { $gt: 5 } }, { test: { '!': 3 } }, { test: { [Op.in]: [4] } }])) - .to.throw('Could not guess type of value { \'$gt\': 5 }'); - - expect(() => QG.whereItemQuery('$or', [{ test: 5 }, { test: 3 }])) - .to.throw('Could not guess type of value { test: 5 }'); - - expect(() => QG.whereItemQuery('$and', [{ test: 5 }, { test: 3 }])) - .to.throw('Could not guess type of value { test: 5 }'); - - expect(() => QG.whereItemQuery('test', { $gt: 5 })) - .to.throw('Could not guess type of value { \'$gt\': 5 }'); - - expect(() => QG.whereItemQuery('test', { $between: [2, 5] })) - .to.throw('Could not guess type of value { \'$between\': [ 2, 5 ] }'); - - expect(() => QG.whereItemQuery('test', { $ne: 3 })) - .to.throw('Could not guess type of value { \'$ne\': 3 }'); - - expect(() => QG.whereItemQuery('test', { $not: 3 })) - .to.throw('Could not guess type of value { \'$not\': 3 }'); - - expect(() => QG.whereItemQuery('test', { $in: [4] })) - .to.throw('Could not guess type of value { \'$in\': [ 4 ] }'); - }); - - it('should correctly parse sequelize.where with .fn as logic', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - QG.handleSequelizeMethod(this.sequelize.where(this.sequelize.col('foo'), 'LIKE', this.sequelize.col('bar'))) - .should.be.equal('foo LIKE bar'); - - QG.handleSequelizeMethod(this.sequelize.where(this.sequelize.col('foo'), Op.ne, null)) - .should.be.equal('foo IS NOT NULL'); - - QG.handleSequelizeMethod(this.sequelize.where(this.sequelize.col('foo'), Op.not, null)) - .should.be.equal('foo IS NOT NULL'); - }); - - // this was a band-aid over a deeper problem ('$bind' being considered to be a bind parameter when it's a string), which has been fixed - it('should not escape $ in fn() arguments', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - const out = QG.handleSequelizeMethod(this.sequelize.fn('upper', '$user')); - - expectsql(out, { - default: `upper('$user')`, - mssql: `upper(N'$user')`, - }); - }); - }); - - describe('format', () => { - it('should throw an error if passed SequelizeMethod', function () { - const QG = getAbstractQueryGenerator(this.sequelize); - const value = this.sequelize.fn('UPPER', 'test'); - expect(() => QG.format(value)).to.throw(Error); - }); - }); -}); diff --git a/packages/core/test/unit/dialects/db2/query-generator.test.js b/packages/core/test/unit/dialects/db2/query-generator.test.js index be043a8de0e9..0f0d7fa59556 100644 --- a/packages/core/test/unit/dialects/db2/query-generator.test.js +++ b/packages/core/test/unit/dialects/db2/query-generator.test.js @@ -7,7 +7,7 @@ const Support = require('../../../support'); const dialect = Support.getTestDialect(); const _ = require('lodash'); -const { Op } = require('@sequelize/core'); +const { Op, sql } = require('@sequelize/core'); const { Db2QueryGenerator: QueryGenerator } = require('@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/db2/query-generator.js'); const { createSequelizeInstance } = require('../../../support'); @@ -57,14 +57,16 @@ if (dialect === 'db2') { expectation: { id: 'INTEGER' }, }, // No Default Values allowed for certain types - { - title: 'No Default value for DB2 BLOB allowed', - arguments: [{ id: { type: 'BLOB', defaultValue: [] } }], - expectation: { id: 'BLOB DEFAULT ' }, - }, + // TODO: this test is broken. It adds the default value because the data type is a string, which can't have special behaviors. + // change type to an actual Sequelize DataType & re-enable + // { + // title: 'No Default value for DB2 BLOB allowed', + // arguments: [{ id: { type: 'BLOB', defaultValue: Buffer.from([]) } }], + // expectation: { id: 'BLOB DEFAULT ' }, + // }, { title: 'No Default value for DB2 TEXT allowed', - arguments: [{ id: { type: 'TEXT', defaultValue: [] } }], + arguments: [{ id: { type: 'TEXT', defaultValue: 'abc' } }], expectation: { id: 'TEXT' }, }, // New references style @@ -154,14 +156,6 @@ if (dialect === 'db2') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\';', context: QueryGenerator, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\'\';DROP TABLE myTable;\';', - context: QueryGenerator, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."id" = 2;', - context: QueryGenerator, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM "myTable" ORDER BY "id";', @@ -192,55 +186,6 @@ if (dialect === 'db2') { expectation: 'SELECT * FROM "myTable" AS "myTable" ORDER BY "myTable"."id" DESC, "myTable"."name";', context: QueryGenerator, needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM "myTable" ORDER BY f1(f2("id")) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: `SELECT * FROM "myTable" ORDER BY f1("myTable"."id") DESC, f2(12, 'lalala', '2011-03-27 10:01:55.000') ASC;`, - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") = \'jan\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") LIKE \'%t%\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -274,19 +219,6 @@ if (dialect === 'db2') { arguments: ['myTable', { group: 'name', order: [['id', 'DESC']] }], expectation: 'SELECT * FROM "myTable" GROUP BY "name" ORDER BY "id" DESC;', context: QueryGenerator, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE ("myTable"."archived" IS NULL AND COALESCE("place_type_codename", "announcement_type_codename") IN (\'Lost\', \'Found\'));', - context: QueryGenerator, - needsSequelize: true, }, { arguments: ['myTable', { limit: 10 }], expectation: 'SELECT * FROM "myTable" FETCH NEXT 10 ROWS ONLY;', @@ -305,51 +237,6 @@ if (dialect === 'db2') { arguments: ['myTable', { limit: 0 }], expectation: 'SELECT * FROM "myTable";', context: QueryGenerator, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."boat" = \'canoe\' AND "myTable"."weather" = \'cold\';', - context: QueryGenerator, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM "myTable";', - context: QueryGenerator, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM "myTable" WHERE 1=1;', - context: QueryGenerator, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM "myTable";', - context: QueryGenerator, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" = BLOB(\'Sequelize\');', - context: QueryGenerator, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" != 0;', - context: QueryGenerator, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" IS NOT NULL;', - context: QueryGenerator, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" IS NOT true;', - context: QueryGenerator, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" != 3;', - context: QueryGenerator, }, { title: 'Empty having', arguments: ['myTable', function () { @@ -369,7 +256,7 @@ if (dialect === 'db2') { having: { creationYear: { [Op.gt]: 2002 } }, }; }], - expectation: 'SELECT "test".* FROM (SELECT * FROM "myTable" AS "test" HAVING "creationYear" > 2002) AS "test";', + expectation: 'SELECT "test".* FROM (SELECT * FROM "myTable" AS "test" HAVING "test"."creationYear" > 2002) AS "test";', context: QueryGenerator, needsSequelize: true, }, @@ -388,12 +275,6 @@ if (dialect === 'db2') { query: 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("name") VALUES ($sequelize_1));', bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("name","birthday") VALUES ($sequelize_1,$sequelize_2));', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, - }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -433,18 +314,6 @@ if (dialect === 'db2') { bind: { sequelize_1: 'foo', sequelize_2: 1 }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { foo: false }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("foo") VALUES ($sequelize_1));', - bind: { sequelize_1: false }, - }, - }, { - arguments: ['myTable', { foo: true }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("foo") VALUES ($sequelize_1));', - bind: { sequelize_1: true }, - }, }, { arguments: ['myTable', function (sequelize) { return { @@ -498,19 +367,6 @@ if (dialect === 'db2') { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2);', @@ -542,18 +398,6 @@ if (dialect === 'db2') { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { bar: false }, { name: 'foo' }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2);', - bind: { sequelize_1: false, sequelize_2: 'foo' }, - }, - }, { - arguments: ['myTable', { bar: true }, { name: 'foo' }], - expectation: { - query: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2);', - bind: { sequelize_1: true, sequelize_2: 'foo' }, - }, }, { arguments: ['myTable', function (sequelize) { return { diff --git a/packages/core/test/unit/dialects/mariadb/query-generator.test.js b/packages/core/test/unit/dialects/mariadb/query-generator.test.js index de78ca815a72..69ce7ff3f7ea 100644 --- a/packages/core/test/unit/dialects/mariadb/query-generator.test.js +++ b/packages/core/test/unit/dialects/mariadb/query-generator.test.js @@ -168,14 +168,6 @@ if (dialect === 'mariadb') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\';', context: QueryGenerator, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\\\';DROP TABLE myTable;\';', - context: QueryGenerator, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`id` = 2;', - context: QueryGenerator, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM `myTable` ORDER BY `id`;', @@ -206,55 +198,6 @@ if (dialect === 'mariadb') { expectation: 'SELECT * FROM `myTable` AS `myTable` ORDER BY `myTable`.`id` DESC, `myTable`.`name`;', context: QueryGenerator, needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(f2(`id`)) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(`myTable`.`id`) DESC, f2(12, \'lalala\', \'2011-03-27 10:01:55.000\') ASC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) = \'jan\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) LIKE \'%t%\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -288,19 +231,6 @@ if (dialect === 'mariadb') { arguments: ['myTable', { group: 'name', order: [['id', 'DESC']] }], expectation: 'SELECT * FROM `myTable` GROUP BY `name` ORDER BY `id` DESC;', context: QueryGenerator, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (`myTable`.`archived` IS NULL AND COALESCE(`place_type_codename`, `announcement_type_codename`) IN (\'Lost\', \'Found\'));', - context: QueryGenerator, - needsSequelize: true, }, { arguments: ['myTable', { limit: 10 }], expectation: 'SELECT * FROM `myTable` LIMIT 10;', @@ -324,61 +254,6 @@ if (dialect === 'mariadb') { arguments: ['myTable', { offset: 0 }], expectation: 'SELECT * FROM `myTable`;', context: QueryGenerator, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`boat` = \'canoe\' AND `myTable`.`weather` = \'cold\';', - context: QueryGenerator, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM `myTable` WHERE 1=1;', - context: QueryGenerator, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` = X\'53657175656c697a65\';', - context: QueryGenerator, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 0;', - context: QueryGenerator, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT NULL;', - context: QueryGenerator, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT true;', - context: QueryGenerator, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 3;', - context: QueryGenerator, - }, { - title: 'Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.regexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` REGEXP \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` NOT REGEXP \'^[h|a|t]\';', - context: QueryGenerator, }, { title: 'Empty having', arguments: ['myTable', function () { @@ -398,7 +273,7 @@ if (dialect === 'mariadb') { having: { creationYear: { [Op.gt]: 2002 } }, }; }], - expectation: 'SELECT `test`.* FROM (SELECT * FROM `myTable` AS `test` HAVING `creationYear` > 2002) AS `test`;', + expectation: 'SELECT `test`.* FROM (SELECT * FROM `myTable` AS `test` HAVING `test`.`creationYear` > 2002) AS `test`;', context: QueryGenerator, needsSequelize: true, }, @@ -417,18 +292,6 @@ if (dialect === 'mariadb') { query: 'INSERT INTO `myTable` (`name`) VALUES ($sequelize_1);', bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`birthday`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, - }, - }, { - arguments: ['myTable', { name: 'foo', foo: 1 }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`foo`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: 1 }, - }, }, { arguments: ['myTable', { data: Buffer.from('Sequelize') }], expectation: { @@ -469,18 +332,6 @@ if (dialect === 'mariadb') { bind: { sequelize_1: 'foo', sequelize_2: 1 }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { foo: false }], - expectation: { - query: 'INSERT INTO `myTable` (`foo`) VALUES ($sequelize_1);', - bind: { sequelize_1: false }, - }, - }, { - arguments: ['myTable', { foo: true }], - expectation: { - query: 'INSERT INTO `myTable` (`foo`) VALUES ($sequelize_1);', - bind: { sequelize_1: true }, - }, }, { arguments: ['myTable', function (sequelize) { return { @@ -537,19 +388,6 @@ if (dialect === 'mariadb') { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', @@ -581,18 +419,6 @@ if (dialect === 'mariadb') { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { bar: false }, { name: 'foo' }], - expectation: { - query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', - bind: { sequelize_1: false, sequelize_2: 'foo' }, - }, - }, { - arguments: ['myTable', { bar: true }, { name: 'foo' }], - expectation: { - query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', - bind: { sequelize_1: true, sequelize_2: 'foo' }, - }, }, { arguments: ['myTable', function (sequelize) { return { diff --git a/packages/core/test/unit/dialects/mysql/query-generator.test.js b/packages/core/test/unit/dialects/mysql/query-generator.test.js index 5cb68794dcca..d8010942efa6 100644 --- a/packages/core/test/unit/dialects/mysql/query-generator.test.js +++ b/packages/core/test/unit/dialects/mysql/query-generator.test.js @@ -168,14 +168,6 @@ if (dialect === 'mysql') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\';', context: QueryGenerator, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\\\';DROP TABLE myTable;\';', - context: QueryGenerator, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`id` = 2;', - context: QueryGenerator, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM `myTable` ORDER BY `id`;', @@ -206,55 +198,6 @@ if (dialect === 'mysql') { expectation: 'SELECT * FROM `myTable` AS `myTable` ORDER BY `myTable`.`id` DESC, `myTable`.`name`;', context: QueryGenerator, needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(f2(`id`)) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(`myTable`.`id`) DESC, f2(12, \'lalala\', \'2011-03-27 10:01:55.000\') ASC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) = \'jan\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) LIKE \'%t%\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -288,19 +231,6 @@ if (dialect === 'mysql') { arguments: ['myTable', { group: 'name', order: [['id', 'DESC']] }], expectation: 'SELECT * FROM `myTable` GROUP BY `name` ORDER BY `id` DESC;', context: QueryGenerator, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (`myTable`.`archived` IS NULL AND COALESCE(`place_type_codename`, `announcement_type_codename`) IN (\'Lost\', \'Found\'));', - context: QueryGenerator, - needsSequelize: true, }, { arguments: ['myTable', { limit: 10 }], expectation: 'SELECT * FROM `myTable` LIMIT 10;', @@ -324,61 +254,6 @@ if (dialect === 'mysql') { arguments: ['myTable', { offset: 0 }], expectation: 'SELECT * FROM `myTable`;', context: QueryGenerator, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`boat` = \'canoe\' AND `myTable`.`weather` = \'cold\';', - context: QueryGenerator, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM `myTable` WHERE 1=1;', - context: QueryGenerator, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` = X\'53657175656c697a65\';', - context: QueryGenerator, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 0;', - context: QueryGenerator, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT NULL;', - context: QueryGenerator, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT true;', - context: QueryGenerator, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 3;', - context: QueryGenerator, - }, { - title: 'Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.regexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` REGEXP \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` NOT REGEXP \'^[h|a|t]\';', - context: QueryGenerator, }, { title: 'Empty having', arguments: ['myTable', function () { @@ -398,7 +273,7 @@ if (dialect === 'mysql') { having: { creationYear: { [Op.gt]: 2002 } }, }; }], - expectation: 'SELECT `test`.* FROM (SELECT * FROM `myTable` AS `test` HAVING `creationYear` > 2002) AS `test`;', + expectation: 'SELECT `test`.* FROM (SELECT * FROM `myTable` AS `test` HAVING `test`.`creationYear` > 2002) AS `test`;', context: QueryGenerator, needsSequelize: true, }, @@ -417,12 +292,6 @@ if (dialect === 'mysql') { query: 'INSERT INTO `myTable` (`name`) VALUES ($sequelize_1);', bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`birthday`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, - }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -462,18 +331,6 @@ if (dialect === 'mysql') { bind: { sequelize_1: 'foo', sequelize_2: 1 }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { foo: false }], - expectation: { - query: 'INSERT INTO `myTable` (`foo`) VALUES ($sequelize_1);', - bind: { sequelize_1: false }, - }, - }, { - arguments: ['myTable', { foo: true }], - expectation: { - query: 'INSERT INTO `myTable` (`foo`) VALUES ($sequelize_1);', - bind: { sequelize_1: true }, - }, }, { arguments: ['myTable', function (sequelize) { return { @@ -530,19 +387,6 @@ if (dialect === 'mysql') { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', @@ -574,18 +418,6 @@ if (dialect === 'mysql') { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { bar: false }, { name: 'foo' }], - expectation: { - query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', - bind: { sequelize_1: false, sequelize_2: 'foo' }, - }, - }, { - arguments: ['myTable', { bar: true }, { name: 'foo' }], - expectation: { - query: 'UPDATE `myTable` SET `bar`=$sequelize_1 WHERE `name` = $sequelize_2', - bind: { sequelize_1: true, sequelize_2: 'foo' }, - }, }, { arguments: ['myTable', function (sequelize) { return { diff --git a/packages/core/test/unit/dialects/postgres/query-generator.test.js b/packages/core/test/unit/dialects/postgres/query-generator.test.js index 502f3c00ee4a..ac999e0c0d64 100644 --- a/packages/core/test/unit/dialects/postgres/query-generator.test.js +++ b/packages/core/test/unit/dialects/postgres/query-generator.test.js @@ -208,12 +208,6 @@ if (dialect.startsWith('postgres')) { }, { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\';', - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\'\';DROP TABLE myTable;\';', - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."id" = 2;', }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM "myTable" ORDER BY "id";', @@ -254,68 +248,6 @@ if (dialect.startsWith('postgres')) { arguments: ['myTable', { offset: 0 }], expectation: 'SELECT * FROM "myTable";', context: QueryGenerator, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") = \'jan\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") LIKE \'%t%\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM "myTable" ORDER BY f1(f2("id")) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: 'SELECT * FROM "myTable" ORDER BY f1("myTable"."id") DESC, f2(12, \'lalala\', \'2011-03-27 10:01:55.000 +00:00\') ASC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { Op.in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE ("myTable"."archived" IS NULL AND COALESCE("place_type_codename", "announcement_type_codename") IN (\'Lost\', \'Found\'));', - context: QueryGenerator, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -358,18 +290,10 @@ if (dialect.startsWith('postgres')) { }, { arguments: [{ tableName: 'myTable', schema: 'mySchema' }], expectation: 'SELECT * FROM "mySchema"."myTable";', - }, { - arguments: [{ tableName: 'myTable', schema: 'mySchema' }, { where: { name: 'foo\';DROP TABLE mySchema.myTable;' } }], - expectation: 'SELECT * FROM "mySchema"."myTable" WHERE "mySchema"."myTable"."name" = \'foo\'\';DROP TABLE mySchema.myTable;\';', - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: `SELECT * FROM "myTable" WHERE "myTable"."field" = '\\x53657175656c697a65';`, - context: QueryGenerator, }, { title: 'string in array should escape \' as \'\'', arguments: ['myTable', { where: { aliases: { [Op.contains]: ['Queen\'s'] } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."aliases" @> ARRAY[\'Queen\'\'s\']::VARCHAR(255)[];', + expectation: 'SELECT * FROM "myTable" WHERE "myTable"."aliases" @> ARRAY[\'Queen\'\'s\'];', }, // Variants when quoteIdentifiers is false @@ -389,14 +313,6 @@ if (dialect.startsWith('postgres')) { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM myTable WHERE myTable.name = \'foo\';', context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM myTable WHERE myTable.name = \'foo\'\';DROP TABLE myTable;\';', - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM myTable WHERE myTable.id = 2;', - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', { order: ['id DESC'] }], expectation: 'SELECT * FROM myTable ORDER BY id DESC;', @@ -430,50 +346,6 @@ if (dialect.startsWith('postgres')) { arguments: [{ tableName: 'myTable', schema: 'mySchema' }], expectation: 'SELECT * FROM mySchema.myTable;', context: { options: { quoteIdentifiers: false } }, - }, { - arguments: [{ tableName: 'myTable', schema: 'mySchema' }, { where: { name: 'foo\';DROP TABLE mySchema.myTable;' } }], - expectation: 'SELECT * FROM mySchema.myTable WHERE mySchema.myTable.name = \'foo\'\';DROP TABLE mySchema.myTable;\';', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use != if Op.ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field != 0;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use IS NOT if Op.ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field IS NOT NULL;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use IS NOT if Op.not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field IS NOT true;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use != if Op.not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field != 3;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.regexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" ~ \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" !~ \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Case-insensitive Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.iRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" ~* \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Case-insensitive Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notIRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" !~* \'^[h|a|t]\';', - context: QueryGenerator, }, ], @@ -515,15 +387,6 @@ if (dialect.startsWith('postgres')) { query: 'INSERT INTO "myTable" ("name") VALUES ($sequelize_1);', bind: { sequelize_1: `foo';DROP TABLE myTable;` }, }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }], - expectation: { - query: 'INSERT INTO "myTable" ("name","birthday") VALUES ($sequelize_1,$sequelize_2);', - bind: { - sequelize_1: 'foo', - sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), - }, - }, }, { arguments: ['myTable', { data: Buffer.from('Sequelize') }], expectation: { @@ -532,12 +395,6 @@ if (dialect.startsWith('postgres')) { sequelize_1: Buffer.from('Sequelize'), }, }, - }, { - arguments: ['myTable', { name: 'foo', numbers: [1, 2, 3] }], - expectation: { - query: 'INSERT INTO "myTable" ("name","numbers") VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: [1, 2, 3] }, - }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -617,20 +474,6 @@ if (dialect.startsWith('postgres')) { bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }], - expectation: { - query: 'INSERT INTO myTable (name,birthday) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { name: 'foo', numbers: [1, 2, 3] }], - expectation: { - query: 'INSERT INTO myTable (name,numbers) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: [1, 2, 3] }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -763,23 +606,23 @@ if (dialect.startsWith('postgres')) { context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', [{ name: 'foo', nullValue: null }, { name: 'bar', nullValue: null }]], - expectation: 'INSERT INTO myTable (name,nullValue) VALUES (\'foo\',NULL),(\'bar\',NULL);', + expectation: `INSERT INTO myTable (name,nullValue) VALUES ('foo',NULL),('bar',NULL);`, context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', [{ name: 'foo', nullValue: null }, { name: 'bar', nullValue: null }]], - expectation: 'INSERT INTO myTable (name,nullValue) VALUES (\'foo\',NULL),(\'bar\',NULL);', + expectation: `INSERT INTO myTable (name,nullValue) VALUES ('foo',NULL),('bar',NULL);`, context: { options: { quoteIdentifiers: false, omitNull: false } }, }, { arguments: ['myTable', [{ name: 'foo', nullValue: null }, { name: 'bar', nullValue: null }]], - expectation: 'INSERT INTO myTable (name,nullValue) VALUES (\'foo\',NULL),(\'bar\',NULL);', + expectation: `INSERT INTO myTable (name,nullValue) VALUES ('foo',NULL),('bar',NULL);`, context: { options: { omitNull: true, quoteIdentifiers: false } }, // Note: We don't honour this because it makes little sense when some rows may have nulls and others not }, { arguments: ['myTable', [{ name: 'foo', nullValue: undefined }, { name: 'bar', nullValue: undefined }]], - expectation: 'INSERT INTO myTable (name,nullValue) VALUES (\'foo\',NULL),(\'bar\',NULL);', + expectation: `INSERT INTO myTable (name,nullValue) VALUES ('foo',NULL),('bar',NULL);`, context: { options: { omitNull: true, quoteIdentifiers: false } }, // Note: As above }, { arguments: [{ schema: 'mySchema', tableName: 'myTable' }, [{ name: 'foo' }, { name: 'bar' }]], - expectation: 'INSERT INTO mySchema.myTable (name) VALUES (\'foo\'),(\'bar\');', + expectation: `INSERT INTO mySchema.myTable (name) VALUES ('foo'),('bar');`, context: { options: { quoteIdentifiers: false } }, }, { arguments: [{ schema: 'mySchema', tableName: 'myTable' }, [{ name: JSON.stringify({ info: 'Look ma a " quote' }) }, { name: JSON.stringify({ info: 'Look ma another " quote' }) }]], @@ -794,18 +637,6 @@ if (dialect.startsWith('postgres')) { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2', @@ -817,12 +648,6 @@ if (dialect.startsWith('postgres')) { query: 'UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2 RETURNING *', bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, - }, { - arguments: ['myTable', { numbers: [1, 2, 3] }, { name: 'foo' }], - expectation: { - query: 'UPDATE "myTable" SET "numbers"=$sequelize_1 WHERE "name" = $sequelize_2', - bind: { sequelize_1: [1, 2, 3], sequelize_2: 'foo' }, - }, }, { arguments: ['myTable', { name: 'foo\';DROP TABLE myTable;' }, { name: 'foo' }], expectation: { @@ -856,12 +681,6 @@ if (dialect.startsWith('postgres')) { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true } }, - }, { - arguments: [{ tableName: 'myTable', schema: 'mySchema' }, { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE "mySchema"."myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, }, { arguments: [{ tableName: 'myTable', schema: 'mySchema' }, { name: 'foo\';DROP TABLE mySchema.myTable;' }, { name: 'foo' }], expectation: { @@ -894,33 +713,12 @@ if (dialect.startsWith('postgres')) { // Variants when quoteIdentifiers is false { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE myTable SET name=$sequelize_1,birthday=$sequelize_2 WHERE id = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE myTable SET name=$sequelize_1,birthday=$sequelize_2 WHERE id = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE myTable SET bar=$sequelize_1 WHERE name = $sequelize_2', bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { numbers: [1, 2, 3] }, { name: 'foo' }], - expectation: { - query: 'UPDATE myTable SET numbers=$sequelize_1 WHERE name = $sequelize_2', - bind: { sequelize_1: [1, 2, 3], sequelize_2: 'foo' }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', { name: 'foo\';DROP TABLE myTable;' }, { name: 'foo' }], expectation: { @@ -956,13 +754,6 @@ if (dialect.startsWith('postgres')) { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true, quoteIdentifiers: false } }, - }, { - arguments: [{ schema: 'mySchema', tableName: 'myTable' }, { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE mySchema.myTable SET name=$sequelize_1,birthday=$sequelize_2 WHERE id = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: [{ schema: 'mySchema', tableName: 'myTable' }, { name: 'foo\';DROP TABLE mySchema.myTable;' }, { name: 'foo' }], expectation: { diff --git a/packages/core/test/unit/dialects/postgres/range-data-type.test.ts b/packages/core/test/unit/dialects/postgres/range-data-type.test.ts index ec786fc12d7c..d778e3cdbe33 100644 --- a/packages/core/test/unit/dialects/postgres/range-data-type.test.ts +++ b/packages/core/test/unit/dialects/postgres/range-data-type.test.ts @@ -1,17 +1,21 @@ import { expect } from 'chai'; import type { Rangable } from '@sequelize/core'; import { DataTypes } from '@sequelize/core'; -import { createSequelizeInstance, getTestDialect } from '../../../support'; +import { createSequelizeInstance, sequelize } from '../../../support'; + +const dialectName = sequelize.dialect.name; describe('[POSTGRES Specific] RANGE DataType', () => { - if (getTestDialect() !== 'postgres') { + if (!dialectName.startsWith('postgres')) { return; } - const { dialect } = createSequelizeInstance({ + const sequelizeWithTzOffset = createSequelizeInstance({ timezone: '+02:00', }); + const dialect = sequelizeWithTzOffset.dialect; + const integerRangeType = DataTypes.RANGE(DataTypes.INTEGER).toDialectDataType(dialect); const bigintRangeType = DataTypes.RANGE(DataTypes.BIGINT).toDialectDataType(dialect); const decimalRangeType = DataTypes.RANGE(DataTypes.DECIMAL).toDialectDataType(dialect); @@ -20,53 +24,53 @@ describe('[POSTGRES Specific] RANGE DataType', () => { describe('escape', () => { it('should handle empty objects correctly', () => { - expect(integerRangeType.escape([])).to.equal(`'empty'`); + expect(integerRangeType.escape([])).to.equal(`'empty'::int4range`); }); it('should handle null as empty bound', () => { - expect(integerRangeType.escape([null, 1])).to.equal(`'[,1)'`); - expect(integerRangeType.escape([1, null])).to.equal(`'[1,)'`); - expect(integerRangeType.escape([null, null])).to.equal(`'[,)'`); + expect(integerRangeType.escape([null, 1])).to.equal(`'[,1)'::int4range`); + expect(integerRangeType.escape([1, null])).to.equal(`'[1,)'::int4range`); + expect(integerRangeType.escape([null, null])).to.equal(`'[,)'::int4range`); }); it('should handle Infinity/-Infinity as infinity/-infinity bounds', () => { - expect(integerRangeType.escape([Number.POSITIVE_INFINITY, 1])).to.equal(`'[infinity,1)'`); - expect(integerRangeType.escape([1, Number.POSITIVE_INFINITY])).to.equal(`'[1,infinity)'`); - expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, 1])).to.equal(`'[-infinity,1)'`); - expect(integerRangeType.escape([1, Number.NEGATIVE_INFINITY])).to.equal(`'[1,-infinity)'`); - expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY])).to.equal(`'[-infinity,infinity)'`); + expect(integerRangeType.escape([Number.POSITIVE_INFINITY, 1])).to.equal(`'[infinity,1)'::int4range`); + expect(integerRangeType.escape([1, Number.POSITIVE_INFINITY])).to.equal(`'[1,infinity)'::int4range`); + expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, 1])).to.equal(`'[-infinity,1)'::int4range`); + expect(integerRangeType.escape([1, Number.NEGATIVE_INFINITY])).to.equal(`'[1,-infinity)'::int4range`); + expect(integerRangeType.escape([Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY])).to.equal(`'[-infinity,infinity)'::int4range`); }); it('should throw error when array length is not 0 or 2', () => { expect(() => { // @ts-expect-error -- testing that invalid input throws - integerRangeType.escape([1], stringifyOptions); + integerRangeType.escape([1]); }).to.throw(); expect(() => { // @ts-expect-error -- testing that invalid input throws - integerRangeType.escape([1, 2, 3], stringifyOptions); + integerRangeType.escape([1, 2, 3]); }).to.throw(); }); it('should throw error when non-array parameter is passed', () => { expect(() => { // @ts-expect-error -- testing that invalid input throws - integerRangeType.escape({}, stringifyOptions); + integerRangeType.escape({}); }).to.throw(); expect(() => { integerRangeType.escape('test'); }).to.throw(); expect(() => { // @ts-expect-error -- testing that invalid input throws - integerRangeType.escape(undefined, stringifyOptions); + integerRangeType.escape(); }).to.throw(); }); it('should handle array of objects with `inclusive` and `value` properties', () => { - expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { value: 1 }])).to.equal(`'[0,1)'`); - expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'`); - expect(integerRangeType.escape([{ inclusive: false, value: 0 }, 1])).to.equal(`'(0,1)'`); - expect(integerRangeType.escape([0, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'`); + expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { value: 1 }])).to.equal(`'[0,1)'::int4range`); + expect(integerRangeType.escape([{ inclusive: true, value: 0 }, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'::int4range`); + expect(integerRangeType.escape([{ inclusive: false, value: 0 }, 1])).to.equal(`'(0,1)'::int4range`); + expect(integerRangeType.escape([0, { inclusive: true, value: 1 }])).to.equal(`'[0,1]'::int4range`); }); it('should handle date values', () => { @@ -74,7 +78,7 @@ describe('[POSTGRES Specific] RANGE DataType', () => { expect(dateRangeType.escape([ new Date(Date.UTC(2000, 1, 1)), new Date(Date.UTC(2000, 1, 2)), - ])).to.equal(`'[2000-02-01 02:00:00.000 +02:00,2000-02-02 02:00:00.000 +02:00)'`); + ])).to.equal(`'[2000-02-01 02:00:00.000 +02:00,2000-02-02 02:00:00.000 +02:00)'::tstzrange`); }); }); @@ -84,23 +88,23 @@ describe('[POSTGRES Specific] RANGE DataType', () => { const infiniteRangeSQL = `'[,)'`; it('should stringify integer range to infinite range', () => { - expect(integerRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(integerRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::int4range`); }); it('should stringify bigint range to infinite range', () => { - expect(bigintRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(bigintRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::int8range`); }); it('should stringify numeric range to infinite range', () => { - expect(decimalRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(decimalRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::numrange`); }); it('should stringify dateonly ranges to infinite range', () => { - expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::daterange`); }); it('should stringify date ranges to infinite range', () => { - expect(dateRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(dateRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::tstzrange`); }); }); @@ -109,23 +113,23 @@ describe('[POSTGRES Specific] RANGE DataType', () => { const infiniteRangeSQL = '\'[-infinity,infinity)\''; it('should stringify integer range to infinite range', () => { - expect(integerRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(integerRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::int4range`); }); it('should stringify bigint range to infinite range', () => { - expect(bigintRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(bigintRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::int8range`); }); it('should stringify numeric range to infinite range', () => { - expect(decimalRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(decimalRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::numrange`); }); it('should stringify dateonly ranges to infinite range', () => { - expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(dateOnlyRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::daterange`); }); it('should stringify date ranges to infinite range', () => { - expect(dateRangeType.escape(infiniteRange)).to.equal(infiniteRangeSQL); + expect(dateRangeType.escape(infiniteRange)).to.equal(`${infiniteRangeSQL}::tstzrange`); }); }); }); diff --git a/packages/core/test/unit/dialects/snowflake/query-generator.test.js b/packages/core/test/unit/dialects/snowflake/query-generator.test.js index 6b21cf4d566d..c760de1bd903 100644 --- a/packages/core/test/unit/dialects/snowflake/query-generator.test.js +++ b/packages/core/test/unit/dialects/snowflake/query-generator.test.js @@ -258,14 +258,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\';', context: QueryGenerator, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."name" = \'foo\'\';DROP TABLE myTable;\';', - context: QueryGenerator, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."id" = 2;', - context: QueryGenerator, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM "myTable" ORDER BY "id";', @@ -296,56 +288,7 @@ if (dialect === 'snowflake') { expectation: 'SELECT * FROM "myTable" AS "myTable" ORDER BY "myTable"."id" DESC, "myTable"."name";', context: QueryGenerator, needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM "myTable" ORDER BY f1(f2("id")) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: `SELECT * FROM "myTable" ORDER BY f1("myTable"."id") DESC, f2(12, 'lalala', '2011-03-27 10:01:55.000') ASC;`, - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") = \'jan\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE (LOWER("user"."name") LIKE \'%t%\' AND "myTable"."type" = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { + }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], expectation: 'SELECT * FROM "myTable" GROUP BY "name";', @@ -378,19 +321,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', { group: 'name', order: [['id', 'DESC']] }], expectation: 'SELECT * FROM "myTable" GROUP BY "name" ORDER BY "id" DESC;', context: QueryGenerator, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM "myTable" WHERE ("myTable"."archived" IS NULL AND COALESCE("place_type_codename", "announcement_type_codename") IN (\'Lost\', \'Found\'));', - context: QueryGenerator, - needsSequelize: true, }, { arguments: ['myTable', { limit: 10 }], expectation: 'SELECT * FROM "myTable" LIMIT 10;', @@ -414,61 +344,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', { offset: 0 }], expectation: 'SELECT * FROM "myTable";', context: QueryGenerator, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."boat" = \'canoe\' AND "myTable"."weather" = \'cold\';', - context: QueryGenerator, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM "myTable";', - context: QueryGenerator, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM "myTable" WHERE 1=1;', - context: QueryGenerator, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM "myTable";', - context: QueryGenerator, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" = X\'53657175656c697a65\';', - context: QueryGenerator, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" != 0;', - context: QueryGenerator, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" IS NOT NULL;', - context: QueryGenerator, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" IS NOT true;', - context: QueryGenerator, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" != 3;', - context: QueryGenerator, - }, { - title: 'Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.regexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" REGEXP \'^[h|a|t]\';', - context: QueryGenerator, - }, { - title: 'Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM "myTable" WHERE "myTable"."field" NOT REGEXP \'^[h|a|t]\';', - context: QueryGenerator, }, { title: 'Empty having', arguments: ['myTable', function () { @@ -488,7 +363,7 @@ if (dialect === 'snowflake') { having: { creationYear: { [Op.gt]: 2002 } }, }; }], - expectation: 'SELECT "test".* FROM (SELECT * FROM "myTable" AS "test" HAVING "creationYear" > 2002) AS "test";', + expectation: 'SELECT "test".* FROM (SELECT * FROM "myTable" AS "test" HAVING "test"."creationYear" > 2002) AS "test";', context: QueryGenerator, needsSequelize: true, }, @@ -510,14 +385,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM myTable WHERE myTable.name = \'foo\';', context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM myTable WHERE myTable.name = \'foo\'\';DROP TABLE myTable;\';', - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM myTable WHERE myTable.id = 2;', - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM myTable ORDER BY id;', @@ -548,55 +415,6 @@ if (dialect === 'snowflake') { expectation: 'SELECT * FROM myTable AS myTable ORDER BY myTable.id DESC, myTable.name;', context: { options: { quoteIdentifiers: false } }, needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM myTable ORDER BY f1(f2(id)) DESC;', - context: { options: { quoteIdentifiers: false } }, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: `SELECT * FROM myTable ORDER BY f1(myTable.id) DESC, f2(12, 'lalala', '2011-03-27 10:01:55.000') ASC;`, - context: { options: { quoteIdentifiers: false } }, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM myTable WHERE (LOWER(user.name) = \'jan\' AND myTable.type = 1);', - context: { options: { quoteIdentifiers: false } }, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM myTable WHERE (LOWER(user.name) LIKE \'%t%\' AND myTable.type = 1);', - context: { options: { quoteIdentifiers: false } }, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -630,20 +448,7 @@ if (dialect === 'snowflake') { arguments: ['myTable', { group: 'name', order: [['id', 'DESC']] }], expectation: 'SELECT * FROM myTable GROUP BY name ORDER BY id DESC;', context: { options: { quoteIdentifiers: false } }, - }, { - title: 'Combination of sequelize.fn, sequelize.col and { in: ... }', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - { archived: null }, - sequelize.where(sequelize.fn('COALESCE', sequelize.col('place_type_codename'), sequelize.col('announcement_type_codename')), { [Op.in]: ['Lost', 'Found'] }), - ), - }; - }], - expectation: 'SELECT * FROM myTable WHERE (myTable.archived IS NULL AND COALESCE(place_type_codename, announcement_type_codename) IN (\'Lost\', \'Found\'));', - context: { options: { quoteIdentifiers: false } }, - needsSequelize: true, - }, { + }, { arguments: ['myTable', { limit: 10 }], expectation: 'SELECT * FROM myTable LIMIT 10;', context: { options: { quoteIdentifiers: false } }, @@ -666,61 +471,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', { offset: 0 }], expectation: 'SELECT * FROM myTable;', context: { options: { quoteIdentifiers: false } }, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM myTable WHERE myTable.boat = \'canoe\' AND myTable.weather = \'cold\';', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM myTable;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM myTable WHERE 1=1;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM myTable;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field = X\'53657175656c697a65\';', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field != 0;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field IS NOT NULL;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field IS NOT true;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field != 3;', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'Regular Expression in where clause', - arguments: ['myTable', { where: { field: { [Op.regexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field REGEXP \'^[h|a|t]\';', - context: { options: { quoteIdentifiers: false } }, - }, { - title: 'Regular Expression negation in where clause', - arguments: ['myTable', { where: { field: { [Op.notRegexp]: '^[h|a|t]' } } }], - expectation: 'SELECT * FROM myTable WHERE myTable.field NOT REGEXP \'^[h|a|t]\';', - context: { options: { quoteIdentifiers: false } }, }, { title: 'Empty having', arguments: ['myTable', function () { @@ -740,7 +490,7 @@ if (dialect === 'snowflake') { having: { creationYear: { [Op.gt]: 2002 } }, }; }], - expectation: 'SELECT test.* FROM (SELECT * FROM myTable AS test HAVING creationYear > 2002) AS test;', + expectation: 'SELECT test.* FROM (SELECT * FROM myTable AS test HAVING test.creationYear > 2002) AS test;', context: { options: { quoteIdentifiers: false } }, needsSequelize: true, }, @@ -759,12 +509,6 @@ if (dialect === 'snowflake') { query: 'INSERT INTO "myTable" ("name") VALUES ($sequelize_1);', bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }], - expectation: { - query: 'INSERT INTO "myTable" ("name","birthday") VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, - }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -804,18 +548,6 @@ if (dialect === 'snowflake') { bind: { sequelize_1: 'foo', sequelize_2: 1 }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { foo: false }], - expectation: { - query: 'INSERT INTO "myTable" ("foo") VALUES ($sequelize_1);', - bind: { sequelize_1: false }, - }, - }, { - arguments: ['myTable', { foo: true }], - expectation: { - query: 'INSERT INTO "myTable" ("foo") VALUES ($sequelize_1);', - bind: { sequelize_1: true }, - }, }, { arguments: ['myTable', function (sequelize) { return { @@ -844,13 +576,6 @@ if (dialect === 'snowflake') { bind: { sequelize_1: 'foo\';DROP TABLE myTable;' }, }, context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }], - expectation: { - query: 'INSERT INTO myTable (name,birthday) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', { name: 'foo', foo: 1 }], expectation: { @@ -893,20 +618,6 @@ if (dialect === 'snowflake') { bind: { sequelize_1: 'foo', sequelize_2: 1 }, }, context: { options: { omitNull: true, quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { foo: false }], - expectation: { - query: 'INSERT INTO myTable (foo) VALUES ($sequelize_1);', - bind: { sequelize_1: false }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { foo: true }], - expectation: { - query: 'INSERT INTO myTable (foo) VALUES ($sequelize_1);', - bind: { sequelize_1: true }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', function (sequelize) { return { @@ -967,10 +678,6 @@ if (dialect === 'snowflake') { arguments: ['myTable', [{ name: 'foo\';DROP TABLE myTable;' }, { name: 'bar' }]], expectation: 'INSERT INTO myTable (name) VALUES (\'foo\'\';DROP TABLE myTable;\'),(\'bar\');', context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', [{ name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { name: 'bar', birthday: new Date(Date.UTC(2012, 2, 27, 10, 1, 55)) }]], - expectation: `INSERT INTO myTable (name,birthday) VALUES ('foo','2011-03-27 10:01:55.000'),('bar','2012-03-27 10:01:55.000');`, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', [{ name: 'foo', foo: 1 }, { name: 'bar', foo: 2 }]], expectation: 'INSERT INTO myTable (name,foo) VALUES (\'foo\',1),(\'bar\',2);', @@ -1004,19 +711,6 @@ if (dialect === 'snowflake') { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE "myTable" SET "name"=$sequelize_1,"birthday"=$sequelize_2 WHERE "id" = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2', @@ -1048,18 +742,6 @@ if (dialect === 'snowflake') { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true } }, - }, { - arguments: ['myTable', { bar: false }, { name: 'foo' }], - expectation: { - query: 'UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2', - bind: { sequelize_1: false, sequelize_2: 'foo' }, - }, - }, { - arguments: ['myTable', { bar: true }, { name: 'foo' }], - expectation: { - query: 'UPDATE "myTable" SET "bar"=$sequelize_1 WHERE "name" = $sequelize_2', - bind: { sequelize_1: true, sequelize_2: 'foo' }, - }, }, { arguments: ['myTable', function (sequelize) { return { @@ -1086,21 +768,6 @@ if (dialect === 'snowflake') { // Variants when quoteIdentifiers is false { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE myTable SET name=$sequelize_1,birthday=$sequelize_2 WHERE id = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - context: { options: { quoteIdentifiers: false } }, - - }, { - arguments: ['myTable', { name: 'foo', birthday: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)) }, { id: 2 }], - expectation: { - query: 'UPDATE myTable SET name=$sequelize_1,birthday=$sequelize_2 WHERE id = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), sequelize_3: 2 }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { arguments: ['myTable', { bar: 2 }, { name: 'foo' }], expectation: { query: 'UPDATE myTable SET bar=$sequelize_1 WHERE name = $sequelize_2', @@ -1135,20 +802,6 @@ if (dialect === 'snowflake') { bind: { sequelize_1: 2, sequelize_2: 'foo' }, }, context: { options: { omitNull: true, quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { bar: false }, { name: 'foo' }], - expectation: { - query: 'UPDATE myTable SET bar=$sequelize_1 WHERE name = $sequelize_2', - bind: { sequelize_1: false, sequelize_2: 'foo' }, - }, - context: { options: { quoteIdentifiers: false } }, - }, { - arguments: ['myTable', { bar: true }, { name: 'foo' }], - expectation: { - query: 'UPDATE myTable SET bar=$sequelize_1 WHERE name = $sequelize_2', - bind: { sequelize_1: true, sequelize_2: 'foo' }, - }, - context: { options: { quoteIdentifiers: false } }, }, { arguments: ['myTable', function (sequelize) { return { diff --git a/packages/core/test/unit/dialects/sqlite/query-generator.test.js b/packages/core/test/unit/dialects/sqlite/query-generator.test.js index ba8c78b0a550..e3b26892294e 100644 --- a/packages/core/test/unit/dialects/sqlite/query-generator.test.js +++ b/packages/core/test/unit/dialects/sqlite/query-generator.test.js @@ -148,14 +148,6 @@ if (dialect === 'sqlite') { arguments: ['myTable', { where: { name: 'foo' } }], expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\';', context: QueryGenerator, - }, { - arguments: ['myTable', { where: { name: 'foo\';DROP TABLE myTable;' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`name` = \'foo\'\';DROP TABLE myTable;\';', - context: QueryGenerator, - }, { - arguments: ['myTable', { where: 2 }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`id` = 2;', - context: QueryGenerator, }, { arguments: ['myTable', { order: ['id'] }], expectation: 'SELECT * FROM `myTable` ORDER BY `id`;', @@ -186,55 +178,6 @@ if (dialect === 'sqlite') { expectation: 'SELECT * FROM `myTable` AS `myTable` ORDER BY `myTable`.`id` DESC, `myTable`.`name`;', context: QueryGenerator, needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and default comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'jan'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) = \'jan\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'sequelize.where with .fn as attribute and LIKE comparator', - arguments: ['myTable', function (sequelize) { - return { - where: sequelize.and( - sequelize.where(sequelize.fn('LOWER', sequelize.col('user.name')), 'LIKE', '%t%'), - { type: 1 }, - ), - }; - }], - expectation: 'SELECT * FROM `myTable` WHERE (LOWER(`user`.`name`) LIKE \'%t%\' AND `myTable`.`type` = 1);', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take functions as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [[sequelize.fn('f1', sequelize.fn('f2', sequelize.col('id'))), 'DESC']], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(f2(`id`)) DESC;', - context: QueryGenerator, - needsSequelize: true, - }, { - title: 'functions can take all types as arguments', - arguments: ['myTable', function (sequelize) { - return { - order: [ - [sequelize.fn('f1', sequelize.col('myTable.id')), 'DESC'], - [sequelize.fn('f2', 12, 'lalala', new Date(Date.UTC(2011, 2, 27, 10, 1, 55))), 'ASC'], - ], - }; - }], - expectation: 'SELECT * FROM `myTable` ORDER BY f1(`myTable`.`id`) DESC, f2(12, \'lalala\', \'2011-03-27 10:01:55.000 +00:00\') ASC;', - context: QueryGenerator, - needsSequelize: true, }, { title: 'single string argument should be quoted', arguments: ['myTable', { group: 'name' }], @@ -285,51 +228,6 @@ if (dialect === 'sqlite') { arguments: ['myTable', { offset: 2 }], expectation: 'SELECT * FROM `myTable` LIMIT -1 OFFSET 2;', context: QueryGenerator, - }, { - title: 'multiple where arguments', - arguments: ['myTable', { where: { boat: 'canoe', weather: 'cold' } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`boat` = \'canoe\' AND `myTable`.`weather` = \'cold\';', - context: QueryGenerator, - }, { - title: 'no where arguments (object)', - arguments: ['myTable', { where: {} }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'no where arguments (string)', - arguments: ['myTable', { where: [''] }], - expectation: 'SELECT * FROM `myTable` WHERE 1=1;', - context: QueryGenerator, - }, { - title: 'no where arguments (null)', - arguments: ['myTable', { where: null }], - expectation: 'SELECT * FROM `myTable`;', - context: QueryGenerator, - }, { - title: 'buffer as where argument', - arguments: ['myTable', { where: { field: Buffer.from('Sequelize') } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` = X\'53657175656c697a65\';', - context: QueryGenerator, - }, { - title: 'use != if ne !== null', - arguments: ['myTable', { where: { field: { [Op.ne]: 0 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 0;', - context: QueryGenerator, - }, { - title: 'use IS NOT if ne === null', - arguments: ['myTable', { where: { field: { [Op.ne]: null } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT NULL;', - context: QueryGenerator, - }, { - title: 'use IS NOT if not === BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: true } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` IS NOT 1;', - context: QueryGenerator, - }, { - title: 'use != if not !== BOOLEAN', - arguments: ['myTable', { where: { field: { [Op.not]: 3 } } }], - expectation: 'SELECT * FROM `myTable` WHERE `myTable`.`field` != 3;', - context: QueryGenerator, }, ], @@ -358,30 +256,6 @@ if (dialect === 'sqlite') { query: 'INSERT INTO `myTable` (`name`,`value`) VALUES ($sequelize_1,$sequelize_2);', bind: { sequelize_1: 'bar', sequelize_2: null }, }, - }, { - arguments: ['myTable', { name: 'bar', value: undefined }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`value`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'bar', sequelize_2: undefined }, - }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`birthday`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, - }, - }, { - arguments: ['myTable', { name: 'foo', value: true }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`value`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: true }, - }, - }, { - arguments: ['myTable', { name: 'foo', value: false }], - expectation: { - query: 'INSERT INTO `myTable` (`name`,`value`) VALUES ($sequelize_1,$sequelize_2);', - bind: { sequelize_1: 'foo', sequelize_2: false }, - }, }, { arguments: ['myTable', { name: 'foo', foo: 1, nullValue: null }], expectation: { @@ -471,18 +345,6 @@ if (dialect === 'sqlite') { updateQuery: [ { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - }, { - arguments: ['myTable', { name: 'foo', birthday: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate() }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`birthday`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'foo', sequelize_2: dayjs('2011-03-27 10:01:55 +0000', 'YYYY-MM-DD HH:mm:ss Z').toDate(), sequelize_3: 2 }, - }, - }, { arguments: ['myTable', { name: 'foo' }, { id: 2 }], expectation: { query: 'UPDATE `myTable` SET `name`=$sequelize_1 WHERE `id` = $sequelize_2', @@ -500,24 +362,6 @@ if (dialect === 'sqlite') { query: 'UPDATE `myTable` SET `name`=$sequelize_1,`value`=$sequelize_2 WHERE `id` = $sequelize_3', bind: { sequelize_1: 'bar', sequelize_2: null, sequelize_3: 2 }, }, - }, { - arguments: ['myTable', { name: 'bar', value: undefined }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `name`=$sequelize_1,`value`=$sequelize_2 WHERE `id` = $sequelize_3', - bind: { sequelize_1: 'bar', sequelize_2: undefined, sequelize_3: 2 }, - }, - }, { - arguments: ['myTable', { flag: true }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `flag`=$sequelize_1 WHERE `id` = $sequelize_2', - bind: { sequelize_1: true, sequelize_2: 2 }, - }, - }, { - arguments: ['myTable', { flag: false }, { id: 2 }], - expectation: { - query: 'UPDATE `myTable` SET `flag`=$sequelize_1 WHERE `id` = $sequelize_2', - bind: { sequelize_1: false, sequelize_2: 2 }, - }, }, { arguments: ['myTable', { bar: 2, nullValue: null }, { name: 'foo' }], expectation: { diff --git a/packages/core/test/unit/model/include.test.js b/packages/core/test/unit/model/include.test.js index a46b0940f3c6..e73bed724d4d 100644 --- a/packages/core/test/unit/model/include.test.js +++ b/packages/core/test/unit/model/include.test.js @@ -226,7 +226,7 @@ describe(Support.getTestDialectTeaser('Model'), () => { include: [{ model: this.Project.scope('foobar'), as: 'projects' }], }); - expect(options.include[0]).to.have.property('where').which.deep.equals({ foo: 42 }); + expect(options.include[0]).to.have.property('where').which.deep.equals({ bar: 42 }); }); }); diff --git a/packages/core/test/unit/model/validation.test.js b/packages/core/test/unit/model/validation.test.js index 36947981ce24..dc34ac51d491 100644 --- a/packages/core/test/unit/model/validation.test.js +++ b/packages/core/test/unit/model/validation.test.js @@ -346,6 +346,10 @@ describe(Support.getTestDialectTeaser('InstanceValidator'), () => { describe('findAll', () => { it('should allow $in', async () => { + if (!dialect.supports.dataTypes.ARRAY) { + return; + } + await expect(User.findAll({ where: { name: { @@ -357,10 +361,10 @@ describe(Support.getTestDialectTeaser('InstanceValidator'), () => { })).not.to.be.rejected; }); - it('should allow $like for uuid', async () => { + it('should allow $like for uuid if cast', async () => { await expect(User.findAll({ where: { - uid: { + 'uid::text': { [Op.like]: '12345678%', }, }, diff --git a/packages/core/test/unit/query-generator/get-where-conditions.test.ts b/packages/core/test/unit/query-generator/get-where-conditions.test.ts deleted file mode 100644 index d6ba8827a3d6..000000000000 --- a/packages/core/test/unit/query-generator/get-where-conditions.test.ts +++ /dev/null @@ -1,15 +0,0 @@ -import { expect } from 'chai'; -import { sequelize } from '../../support'; - -describe('QueryGenerator#getWhereConditions', () => { - const queryGenerator = sequelize.queryInterface.queryGenerator; - - it('throws if called with invalid arguments', () => { - const User = sequelize.define('User'); - - expect(() => { - // @ts-expect-error -- TODO: https://github.com/sequelize/sequelize/pull/14020 - queryGenerator.getWhereConditions(new Date(), User.getTableName(), User); - }).to.throw('Unsupported where option value'); - }); -}); diff --git a/packages/core/test/unit/query-generator/insert-query.test.ts b/packages/core/test/unit/query-generator/insert-query.test.ts index 24b580e52f3a..71b1ba105fbe 100644 --- a/packages/core/test/unit/query-generator/insert-query.test.ts +++ b/packages/core/test/unit/query-generator/insert-query.test.ts @@ -84,6 +84,26 @@ describe('QueryGenerator#insertQuery', () => { expect(bind).to.be.undefined; }); + // This test was added due to a regression where these values were being converted to strings + it('binds number values', () => { + if (!sequelize.dialect.supports.dataTypes.ARRAY) { + return; + } + + const { query, bind } = queryGenerator.insertQuery(User.tableName, { + numbers: [1, 2, 3], + }); + + expectsql(query, { + default: `INSERT INTO "Users" ([numbers]) VALUES ($sequelize_1);`, + db2: `SELECT * FROM FINAL TABLE (INSERT INTO "Users" ("numbers") VALUES ($sequelize_1));`, + ibmi: `SELECT * FROM FINAL TABLE (INSERT INTO "Users" ("numbers") VALUES ($sequelize_1))`, + }); + expect(bind).to.deep.eq({ + sequelize_1: [1, 2, 3], + }); + }); + describe('returning', () => { it('supports returning: true', () => { const { query } = queryGenerator.insertQuery(User.table, { @@ -144,5 +164,99 @@ describe('QueryGenerator#insertQuery', () => { ibmi: 'SELECT * FROM FINAL TABLE (INSERT INTO "Users" ("firstName") VALUES ($sequelize_1))', }); }); + + it('binds date values', () => { + const result = queryGenerator.insertQuery('myTable', { birthday: new Date('2011-03-27T10:01:55Z') }); + expectsql(result, { + query: { + default: 'INSERT INTO [myTable] ([birthday]) VALUES ($sequelize_1);', + 'db2 ibmi': 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("birthday") VALUES ($sequelize_1));', + }, + bind: { + mysql: { + sequelize_1: '2011-03-27 10:01:55.000', + }, + mariadb: { + sequelize_1: '2011-03-27 10:01:55.000', + }, + db2: { + sequelize_1: '2011-03-27 10:01:55.000', + }, + ibmi: { + sequelize_1: '2011-03-27 10:01:55.000', + }, + snowflake: { + sequelize_1: '2011-03-27 10:01:55.000', + }, + sqlite: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + }, + postgres: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + }, + mssql: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + }, + }, + }); + }); + + it('binds boolean values', () => { + const result = queryGenerator.insertQuery('myTable', { positive: true, negative: false }); + expectsql(result, { + query: { + default: 'INSERT INTO [myTable] ([positive],[negative]) VALUES ($sequelize_1,$sequelize_2);', + 'db2 ibmi': 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("positive","negative") VALUES ($sequelize_1,$sequelize_2));', + }, + bind: { + sqlite: { + sequelize_1: 1, + sequelize_2: 0, + }, + mysql: { + sequelize_1: 1, + sequelize_2: 0, + }, + mariadb: { + sequelize_1: 1, + sequelize_2: 0, + }, + mssql: { + sequelize_1: 1, + sequelize_2: 0, + }, + postgres: { + sequelize_1: true, + sequelize_2: false, + }, + db2: { + sequelize_1: true, + sequelize_2: false, + }, + ibmi: { + sequelize_1: 1, + sequelize_2: 0, + }, + snowflake: { + sequelize_1: true, + sequelize_2: false, + }, + }, + }); + }); + + // TODO: Should we ignore undefined values instead? undefined is closer to "missing property" than null + it('treats undefined as null', () => { + const { query, bind } = queryGenerator.insertQuery('myTable', { value: undefined, name: 'bar' }); + expectsql(query, { + default: 'INSERT INTO [myTable] ([value],[name]) VALUES ($sequelize_1,$sequelize_2);', + 'db2 ibmi': 'SELECT * FROM FINAL TABLE (INSERT INTO "myTable" ("value","name") VALUES ($sequelize_1,$sequelize_2));', + }); + + expect(bind).to.deep.eq({ + sequelize_1: null, + sequelize_2: 'bar', + }); + }); }); }); diff --git a/packages/core/test/unit/query-generator/json-path-extraction-query.test.ts b/packages/core/test/unit/query-generator/json-path-extraction-query.test.ts index 05809cc4e68a..f473d3aff58e 100644 --- a/packages/core/test/unit/query-generator/json-path-extraction-query.test.ts +++ b/packages/core/test/unit/query-generator/json-path-extraction-query.test.ts @@ -1,54 +1,75 @@ -import { expectsql, getTestDialect, sequelize } from '../../support'; +import { expectPerDialect, sequelize } from '../../support'; -const dialectName = getTestDialect(); +const dialect = sequelize.dialect; +const dialectName = dialect.name; -const notSupportedError = new Error(`JSON operations are not supported in ${dialectName}.`); +const notSupportedError = new Error(`JSON Paths are not supported in ${dialectName}.`); describe('QueryGenerator#jsonPathExtractionQuery', () => { const queryGenerator = sequelize.getQueryInterface().queryGenerator; - // TODO: add tests that check that profile can start and end with ` or " - // TODO: add tests where id contains characters like ., $, ', ", ,, { or } - // TODO: throw if isJson is used but not supported by the dialect + it('creates a json extract operation (object)', () => { + // "jsonPathExtractionQuery" does not quote the first parameter, because the first parameter is *not* an identifier, + // it can be any SQL expression, e.g. a column name, a function call, a subquery, etc. + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), ['id'], false), { + default: notSupportedError, + mariadb: `json_compact(json_extract(\`profile\`,'$.id'))`, + 'mysql sqlite': `json_extract(\`profile\`,'$.id')`, + postgres: `"profile"->'id'`, + }); + }); + + it('creates a json extract operation (array)', () => { + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), [0], false), { + default: notSupportedError, + mariadb: `json_compact(json_extract(\`profile\`,'$[0]'))`, + 'mysql sqlite': `json_extract(\`profile\`,'$[0]')`, + postgres: `"profile"->0`, + }); + }); + + it('creates a nested json extract operation', () => { + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), ['id', 'username', 0, '0', 'name'], false), { + default: notSupportedError, + mariadb: `json_compact(json_extract(\`profile\`,'$.id.username[0]."0".name'))`, + 'mysql sqlite': `json_extract(\`profile\`,'$.id.username[0]."0".name')`, + postgres: `"profile"#>ARRAY['id','username','0','0','name']`, + }); + }); - it('should handle isJson parameter true', () => { - expectsql(() => queryGenerator.jsonPathExtractionQuery('profile', 'id', true), { + it(`escapes characters such as ", $, and '`, () => { + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), [`"`, `'`, `$`], false), { default: notSupportedError, - // TODO: mariadb should be consistent with mysql - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\'))', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\'))', - postgres: `("profile"#>'{id}')`, - sqlite: 'json_extract(`profile`,\'$.id\')', + mysql: `json_extract(\`profile\`,'$."\\\\""."\\'"."$"')`, + mariadb: `json_compact(json_extract(\`profile\`,'$."\\\\""."\\'"."$"'))`, + sqlite: `json_extract(\`profile\`,'$."\\""."''"."$"')`, + postgres: `"profile"#>ARRAY['"','''','$']`, }); }); - it('should use default handling if isJson is false', () => { - expectsql(() => queryGenerator.jsonPathExtractionQuery('profile', 'id', false), { + it('creates a json extract+unquote operation (object)', () => { + // "jsonPathExtractionQuery" does not quote the first parameter, because the first parameter is *not* an identifier, + // it can be any SQL expression, e.g. a column name, a function call, a subquery, etc. + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), ['id'], true), { default: notSupportedError, - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\'))', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\'))', - postgres: `("profile"#>>'{id}')`, - sqlite: 'json_extract(`profile`,\'$.id\')', + 'mariadb mysql sqlite': `json_unquote(json_extract(\`profile\`,'$.id'))`, + postgres: `"profile"->>'id'`, }); }); - it('should use default handling if isJson is not passed', () => { - expectsql(() => queryGenerator.jsonPathExtractionQuery('profile', 'id'), { + it('creates a json extract+unquote operation (array)', () => { + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), [0], true), { default: notSupportedError, - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\'))', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\'))', - postgres: `("profile"#>>'{id}')`, - sqlite: 'json_extract(`profile`,\'$.id\')', + 'mariadb mysql sqlite': `json_unquote(json_extract(\`profile\`,'$[0]'))`, + postgres: `"profile"->>0`, }); }); - it('should support passing a string array as path', () => { - expectsql(() => queryGenerator.jsonPathExtractionQuery('profile', ['id', 'username']), { + it('creates a nested json extract+unquote operation', () => { + expectPerDialect(() => queryGenerator.jsonPathExtractionQuery(queryGenerator.quoteIdentifier('profile'), ['id', 'username', 0, '0', 'name'], true), { default: notSupportedError, - mariadb: 'json_unquote(json_extract(`profile`,\'$.id.username\'))', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\".\\"username\\"\'))', - postgres: `("profile"#>>'{id,username}')`, - sqlite: 'json_extract(`profile`,\'$.id.username\')', + 'mysql mariadb sqlite': `json_unquote(json_extract(\`profile\`,'$.id.username[0]."0".name'))`, + postgres: `"profile"#>>ARRAY['id','username','0','0','name']`, }); }); }); diff --git a/packages/core/test/unit/query-generator/select-query.test.ts b/packages/core/test/unit/query-generator/select-query.test.ts index 48e391739c43..334a30a56895 100644 --- a/packages/core/test/unit/query-generator/select-query.test.ts +++ b/packages/core/test/unit/query-generator/select-query.test.ts @@ -1,9 +1,11 @@ import { expect } from 'chai'; import type { InferAttributes, Model } from '@sequelize/core'; -import { Op, literal, DataTypes, or, fn, where, cast, col } from '@sequelize/core'; +import { Op, DataTypes, or, sql as sqlTag } from '@sequelize/core'; import { _validateIncludedElements } from '@sequelize/core/_non-semver-use-at-your-own-risk_/model-internals.js'; import { expectsql, sequelize } from '../../support'; +const { attribute, col, cast, where, fn, literal } = sqlTag; + describe('QueryGenerator#selectQuery', () => { const queryGenerator = sequelize.getQueryInterface().queryGenerator; @@ -89,13 +91,64 @@ describe('QueryGenerator#selectQuery', () => { }); }); + it('supports empty where object', () => { + const sql = queryGenerator.selectQuery(User.table, { + model: User, + attributes: [ + 'id', + ], + where: {}, + }, User); + + expectsql(sql, { + default: `SELECT [id] FROM [Users] AS [User];`, + }); + }); + + it('escapes WHERE clause correctly', () => { + const sql = queryGenerator.selectQuery(User.table, { + model: User, + attributes: [ + 'id', + ], + where: { username: 'foo\';DROP TABLE mySchema.myTable;' }, + }, User); + + expectsql(sql, { + default: `SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = 'foo'';DROP TABLE mySchema.myTable;';`, + 'mysql mariadb': `SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = 'foo\\';DROP TABLE mySchema.myTable;';`, + mssql: `SELECT [id] FROM [Users] AS [User] WHERE [User].[username] = N'foo'';DROP TABLE mySchema.myTable;';`, + }); + }); + + if (sequelize.dialect.supports.jsonOperations) { + it('accepts json paths in attributes', () => { + const sql = queryGenerator.selectQuery(User.table, { + model: User, + attributes: [ + [attribute('data.email'), 'email'], + ], + }, User); + + expectsql(sql, { + postgres: `SELECT "data"->'email' AS "email" FROM "Users" AS "User";`, + mariadb: `SELECT json_compact(json_extract(\`data\`,'$.email')) AS \`email\` FROM \`Users\` AS \`User\`;`, + 'sqlite mysql': `SELECT json_extract([data],'$.email') AS [email] FROM [Users] AS [User];`, + }); + + }); + } + describe('replacements', () => { it('parses named replacements in literals', () => { // The goal of this test is to test that :replacements are parsed in literals in as many places as possible const sql = queryGenerator.selectQuery(User.table, { model: User, - attributes: [[fn('uppercase', literal(':attr')), 'id'], literal(':attr2')], + attributes: [ + [fn('uppercase', literal(':attr')), 'id'], + literal(':attr2'), + ], where: { username: or( { [Op.eq]: literal(':data') }, @@ -126,9 +179,9 @@ describe('QueryGenerator#selectQuery', () => { default: ` SELECT uppercase('id') AS [id], 'id2' FROM [Users] AS [User] - WHERE ([User].[username] = 'repl1' OR uppercase(CAST('repl1' AS STRING)) = 'repl1') + WHERE [User].[username] = 'repl1' OR [User].[username] = (uppercase(CAST('repl1' AS STRING)) = 'repl1') GROUP BY 'the group' - HAVING [username] = 'repl1' + HAVING [User].[username] = 'repl1' ORDER BY 'repl2' LIMIT 'repl3' OFFSET 'repl4'; @@ -136,33 +189,23 @@ describe('QueryGenerator#selectQuery', () => { mssql: ` SELECT uppercase(N'id') AS [id], N'id2' FROM [Users] AS [User] - WHERE ([User].[username] = N'repl1' OR uppercase(CAST(N'repl1' AS STRING)) = N'repl1') + WHERE [User].[username] = N'repl1' OR [User].[username] = (uppercase(CAST(N'repl1' AS STRING)) = N'repl1') GROUP BY N'the group' - HAVING [username] = N'repl1' + HAVING [User].[username] = N'repl1' ORDER BY N'repl2' OFFSET N'repl4' ROWS FETCH NEXT N'repl3' ROWS ONLY; `, - db2: ` + 'db2 ibmi': ` SELECT uppercase('id') AS "id", 'id2' FROM "Users" AS "User" - WHERE ("User"."username" = 'repl1' OR uppercase(CAST('repl1' AS STRING)) = 'repl1') + WHERE "User"."username" = 'repl1' OR "User"."username" = (uppercase(CAST('repl1' AS STRING)) = 'repl1') GROUP BY 'the group' - HAVING "username" = 'repl1' + HAVING "User"."username" = 'repl1' ORDER BY 'repl2' OFFSET 'repl4' ROWS FETCH NEXT 'repl3' ROWS ONLY; `, - ibmi: ` - SELECT uppercase('id') AS "id", 'id2' - FROM "Users" AS "User" - WHERE ("User"."username" = 'repl1' OR uppercase(CAST('repl1' AS STRING)) = 'repl1') - GROUP BY 'the group' - HAVING "username" = 'repl1' - ORDER BY 'repl2' - OFFSET 'repl4' ROWS - FETCH NEXT 'repl3' ROWS ONLY - `, }); }); @@ -437,7 +480,7 @@ describe('QueryGenerator#selectQuery', () => { }, replacements: ['repl1', 'repl2', 'repl3'], }, User), - ).to.throw(`The following literal includes positional replacements (?). + ).to.throwWithCause(`The following literal includes positional replacements (?). Only named replacements (:name) are allowed in literal() because we cannot guarantee the order in which they will be evaluated: ➜ literal("?")`); }); @@ -502,11 +545,103 @@ Only named replacements (:name) are allowed in literal() because we cannot guara }, User); expectsql(sql, { - default: `SELECT *, YEAR([createdAt]) AS [creationYear] FROM [Users] AS [User] GROUP BY [creationYear], [title] HAVING [creationYear] > 2002;`, + default: `SELECT *, YEAR([createdAt]) AS [creationYear] FROM [Users] AS [User] GROUP BY [creationYear], [title] HAVING [User].[creationYear] > 2002;`, }); }); }); + describe('previously supported values', () => { + it('raw replacements for where', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: ['name IN (?)', [1, 'test', 3, 'derp']], + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got 'name IN (?)'`); + }); + + it('raw replacements for nested where', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: [['name IN (?)', [1, 'test', 3, 'derp']]], + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got 'name IN (?)'`); + }); + + it('raw replacements for having', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + having: ['name IN (?)', [1, 'test', 3, 'derp']], + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got 'name IN (?)'`); + }); + + it('raw replacements for nested having', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + having: [['name IN (?)', [1, 'test', 3, 'derp']]], + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got 'name IN (?)'`); + }); + + it('raw string from where', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: `name = 'something'`, + }); + }).to.throwWithCause(Error, 'Support for `{ where: \'raw query\' }` has been removed.'); + }); + + it('raw string from having', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + having: `name = 'something'`, + }); + }).to.throwWithCause(Error, 'Support for `{ where: \'raw query\' }` has been removed.'); + }); + + it('rejects where: null', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: null, + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got null`); + }); + + it('rejects where: primitive', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: 1, + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got 1`); + }); + + it('rejects where: array of primitives', () => { + expect(() => { + queryGenerator.selectQuery('User', { + attributes: ['*'], + // @ts-expect-error -- this is not a valid value anymore + where: [''], + }); + }).to.throwWithCause(Error, `Invalid Query: expected a plain object, an array or a sequelize SQL method but got ''`); + }); + }); + describe('minifyAliases', () => { it('minifies custom attributes', () => { const sql = queryGenerator.selectQuery(User.table, { diff --git a/packages/core/test/unit/query-generator/update-query.test.ts b/packages/core/test/unit/query-generator/update-query.test.ts index 79c43c06b8a3..ddf9eadfd775 100644 --- a/packages/core/test/unit/query-generator/update-query.test.ts +++ b/packages/core/test/unit/query-generator/update-query.test.ts @@ -63,4 +63,126 @@ describe('QueryGenerator#updateQuery', () => { expect(bind).to.be.undefined; }); + + it('binds date values', () => { + const result = queryGenerator.updateQuery('myTable', { + date: new Date('2011-03-27T10:01:55Z'), + }, { id: 2 }); + + expectsql(result, { + query: { + default: 'UPDATE [myTable] SET [date]=$sequelize_1 WHERE [id] = $sequelize_2', + db2: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "date"=$sequelize_1 WHERE "id" = $sequelize_2);', + }, + bind: { + mysql: { + sequelize_1: '2011-03-27 10:01:55.000', + sequelize_2: 2, + }, + mariadb: { + sequelize_1: '2011-03-27 10:01:55.000', + sequelize_2: 2, + }, + db2: { + sequelize_1: '2011-03-27 10:01:55.000', + sequelize_2: 2, + }, + ibmi: { + sequelize_1: '2011-03-27 10:01:55.000', + sequelize_2: 2, + }, + snowflake: { + sequelize_1: '2011-03-27 10:01:55.000', + sequelize_2: 2, + }, + sqlite: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + sequelize_2: 2, + }, + postgres: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + sequelize_2: 2, + }, + mssql: { + sequelize_1: '2011-03-27 10:01:55.000 +00:00', + sequelize_2: 2, + }, + }, + }); + }); + + it('binds boolean values', () => { + const result = queryGenerator.updateQuery('myTable', { + positive: true, + negative: false, + }, { id: 2 }); + + expectsql(result, { + query: { + default: 'UPDATE [myTable] SET [positive]=$sequelize_1,[negative]=$sequelize_2 WHERE [id] = $sequelize_3', + db2: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "positive"=$sequelize_1,"negative"=$sequelize_2 WHERE "id" = $sequelize_3);', + }, + bind: { + sqlite: { + sequelize_1: 1, + sequelize_2: 0, + sequelize_3: 2, + }, + mysql: { + sequelize_1: 1, + sequelize_2: 0, + sequelize_3: 2, + }, + mariadb: { + sequelize_1: 1, + sequelize_2: 0, + sequelize_3: 2, + }, + mssql: { + sequelize_1: 1, + sequelize_2: 0, + sequelize_3: 2, + }, + postgres: { + sequelize_1: true, + sequelize_2: false, + sequelize_3: 2, + }, + db2: { + sequelize_1: true, + sequelize_2: false, + sequelize_3: 2, + }, + ibmi: { + sequelize_1: 1, + sequelize_2: 0, + sequelize_3: 2, + }, + snowflake: { + sequelize_1: true, + sequelize_2: false, + sequelize_3: 2, + }, + }, + }); + }); + + // TODO: Should we ignore undefined values instead? undefined is closer to "missing property" than null + it('treats undefined as null', () => { + const { query, bind } = queryGenerator.updateQuery('myTable', { + value: undefined, + name: 'bar', + }, { id: 2 }); + + expectsql(query, { + default: 'UPDATE [myTable] SET [value]=$sequelize_1,[name]=$sequelize_2 WHERE [id] = $sequelize_3', + db2: 'SELECT * FROM FINAL TABLE (UPDATE "myTable" SET "value"=$sequelize_1,"name"=$sequelize_2 WHERE "id" = $sequelize_3);', + }); + + expect(bind).to.deep.eq({ + sequelize_1: null, + sequelize_2: 'bar', + sequelize_3: 2, + }); + }); }); diff --git a/packages/core/test/unit/sql/add-constraint.test.js b/packages/core/test/unit/sql/add-constraint.test.js index 3edd20500f8b..a2ff48d9b0be 100644 --- a/packages/core/test/unit/sql/add-constraint.test.js +++ b/packages/core/test/unit/sql/add-constraint.test.js @@ -72,7 +72,7 @@ describe(Support.getTestDialectTeaser('QueryGenerator#addConstraint'), () => { }, }, }), { - default: 'ALTER TABLE [myTable] ADD CONSTRAINT [check_mycolumn_where] CHECK (([myColumn] > 50 AND [myColumn] < 100));', + default: 'ALTER TABLE [myTable] ADD CONSTRAINT [check_mycolumn_where] CHECK ([myColumn] > 50 AND [myColumn] < 100);', }); }); diff --git a/packages/core/test/unit/sql/delete.test.js b/packages/core/test/unit/sql/delete.test.js index f9bae267d4e5..a64edf5e4606 100644 --- a/packages/core/test/unit/sql/delete.test.js +++ b/packages/core/test/unit/sql/delete.test.js @@ -215,16 +215,10 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }; it(util.inspect(options, { depth: 2 }), () => { - const sqlOrError = _.attempt( - sql.deleteQuery.bind(sql), - options.table, - options.where, - options, - User, - ); - - return expectsql(sqlOrError, { - default: new Error('WHERE parameter "name" has invalid "undefined" value'), + return expectsql(() => sql.deleteQuery(options.table, options.where, options, User), { + default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. +Value: { name: undefined } +Caused by: "undefined" cannot be escaped`), }); }); }); diff --git a/packages/core/test/unit/sql/generateJoin.test.js b/packages/core/test/unit/sql/generateJoin.test.js index a424f5adb902..d53c920bb7b4 100644 --- a/packages/core/test/unit/sql/generateJoin.test.js +++ b/packages/core/test/unit/sql/generateJoin.test.js @@ -303,7 +303,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, }, ], - }, { default: 'LEFT OUTER JOIN [task] AS [Tasks] ON ([User].[id_user] = [Tasks].[user_id] OR [Tasks].[user_id] = 2)' }, + }, { default: 'LEFT OUTER JOIN [task] AS [Tasks] ON [User].[id_user] = [Tasks].[user_id] OR [Tasks].[user_id] = 2' }, ); testsql( @@ -343,7 +343,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, { - default: 'LEFT OUTER JOIN [user] AS [Company->Owner] ON ([Company].[owner_id] = [Company->Owner].[id_user] OR [Company->Owner].[id_user] = 2)', + default: 'LEFT OUTER JOIN [user] AS [Company->Owner] ON [Company].[owner_id] = [Company->Owner].[id_user] OR [Company->Owner].[id_user] = 2', }, ); diff --git a/packages/core/test/unit/sql/get-constraint-snippet.test.js b/packages/core/test/unit/sql/get-constraint-snippet.test.js index 1b369c779122..c68fdc2a25e5 100644 --- a/packages/core/test/unit/sql/get-constraint-snippet.test.js +++ b/packages/core/test/unit/sql/get-constraint-snippet.test.js @@ -70,7 +70,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, }, }), { - default: 'CONSTRAINT [check_mycolumn_where] CHECK (([myColumn] > 50 AND [myColumn] < 100))', + default: 'CONSTRAINT [check_mycolumn_where] CHECK ([myColumn] > 50 AND [myColumn] < 100)', }); }); diff --git a/packages/core/test/unit/sql/index.test.js b/packages/core/test/unit/sql/index.test.js index ac9c1981f0e2..ec35515ab2ae 100644 --- a/packages/core/test/unit/sql/index.test.js +++ b/packages/core/test/unit/sql/index.test.js @@ -148,11 +148,11 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }, }, }), { - ibmi: 'CREATE INDEX "table_type" ON "table" ("type") WHERE ("type" = \'group\' OR "type" = \'private\')', - sqlite: 'CREATE INDEX `table_type` ON `table` (`type`) WHERE (`type` = \'group\' OR `type` = \'private\')', - db2: 'CREATE INDEX "table_type" ON "table" ("type") WHERE ("type" = \'group\' OR "type" = \'private\')', - postgres: 'CREATE INDEX "table_type" ON "table" ("type") WHERE ("type" = \'group\' OR "type" = \'private\')', - mssql: 'CREATE INDEX [table_type] ON [table] ([type]) WHERE ([type] = N\'group\' OR [type] = N\'private\')', + ibmi: 'CREATE INDEX "table_type" ON "table" ("type") WHERE "type" = \'group\' OR "type" = \'private\'', + sqlite: 'CREATE INDEX `table_type` ON `table` (`type`) WHERE `type` = \'group\' OR `type` = \'private\'', + db2: 'CREATE INDEX "table_type" ON "table" ("type") WHERE "type" = \'group\' OR "type" = \'private\'', + postgres: 'CREATE INDEX "table_type" ON "table" ("type") WHERE "type" = \'group\' OR "type" = \'private\'', + mssql: 'CREATE INDEX [table_type] ON [table] ([type]) WHERE [type] = N\'group\' OR [type] = N\'private\'', }); expectsql(sql.addIndexQuery('table', { diff --git a/packages/core/test/unit/sql/json.test.js b/packages/core/test/unit/sql/json.test.js deleted file mode 100644 index ae44e3018d27..000000000000 --- a/packages/core/test/unit/sql/json.test.js +++ /dev/null @@ -1,209 +0,0 @@ -'use strict'; - -const Support = require('../../support'); -const { DataTypes, Sequelize } = require('@sequelize/core'); -const expect = require('chai').expect; - -const expectsql = Support.expectsql; -const current = Support.sequelize; -const sql = current.dialect.queryGenerator; -const dialect = current.dialect; - -// Notice: [] will be replaced by dialect specific tick/quote character when there is not dialect specific expectation but only a default expectation - -describe(Support.getTestDialectTeaser('SQL'), () => { - if (!dialect.supports.dataTypes.JSON) { - return; - } - - describe('JSON', () => { - describe('escape', () => { - it('plain string', () => { - expectsql(sql.escape('string', { type: new DataTypes.JSON() }), { - default: `'"string"'`, - mssql: `N'"string"'`, - mariadb: `'\\"string\\"'`, - mysql: `'\\"string\\"'`, - }); - }); - - it('plain int', () => { - expectsql(sql.escape(0, { type: new DataTypes.JSON() }), { - default: `'0'`, - mssql: `N'0'`, - }); - expectsql(sql.escape(123, { type: new DataTypes.JSON() }), { - default: `'123'`, - mssql: `N'123'`, - }); - }); - - it('boolean', () => { - expectsql(sql.escape(true, { type: new DataTypes.JSON() }), { - default: `'true'`, - mssql: `N'true'`, - }); - expectsql(sql.escape(false, { type: new DataTypes.JSON() }), { - default: `'false'`, - mssql: `N'false'`, - }); - }); - - it('NULL', () => { - expectsql(sql.escape(null, { type: new DataTypes.JSON() }), { - default: 'NULL', - }); - }); - - it('nested object', () => { - expectsql(sql.escape({ some: 'nested', more: { nested: true }, answer: 42 }, { type: new DataTypes.JSON() }), { - default: `'{"some":"nested","more":{"nested":true},"answer":42}'`, - mssql: `N'{"some":"nested","more":{"nested":true},"answer":42}'`, - mariadb: `'{\\"some\\":\\"nested\\",\\"more\\":{\\"nested\\":true},\\"answer\\":42}'`, - mysql: `'{\\"some\\":\\"nested\\",\\"more\\":{\\"nested\\":true},\\"answer\\":42}'`, - }); - }); - - if (current.dialect.supports.dataTypes.ARRAY) { - it('array of JSON', () => { - expectsql(sql.escape([ - { some: 'nested', more: { nested: true }, answer: 42 }, - 43, - 'joe', - ], { type: DataTypes.ARRAY(DataTypes.JSON) }), { - postgres: 'ARRAY[\'{"some":"nested","more":{"nested":true},"answer":42}\',\'43\',\'"joe"\']::JSON[]', - }); - }); - - if (current.dialect.supports.dataTypes.JSONB) { - it('array of JSONB', () => { - expectsql( - sql.escape( - [ - { some: 'nested', more: { nested: true }, answer: 42 }, - 43, - 'joe', - ], - { type: DataTypes.ARRAY(DataTypes.JSONB) }, - ), - { - postgres: 'ARRAY[\'{"some":"nested","more":{"nested":true},"answer":42}\',\'43\',\'"joe"\']::JSONB[]', - }, - ); - }); - } - } - }); - - describe('path extraction', () => { - if (!dialect.supports.jsonOperations) { - return; - } - - it('condition object', () => { - expectsql(sql.whereItemQuery(undefined, Sequelize.json({ id: 1 })), { - postgres: '("id"#>>\'{}\') = \'1\'', - sqlite: 'json_extract(`id`,\'$\') = \'1\'', - mariadb: 'json_unquote(json_extract(`id`,\'$\')) = \'1\'', - mysql: 'json_unquote(json_extract(`id`,\'$\')) = \'1\'', - }); - }); - - it('nested condition object', () => { - expectsql(sql.whereItemQuery(undefined, Sequelize.json({ profile: { id: 1 } })), { - postgres: '("profile"#>>\'{id}\') = \'1\'', - sqlite: 'json_extract(`profile`,\'$.id\') = \'1\'', - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\')) = \'1\'', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\')) = \'1\'', - }); - }); - - it('multiple condition object', () => { - expectsql(sql.whereItemQuery(undefined, Sequelize.json({ property: { value: 1 }, another: { value: 'string' } })), { - postgres: '("property"#>>\'{value}\') = \'1\' AND ("another"#>>\'{value}\') = \'string\'', - sqlite: 'json_extract(`property`,\'$.value\') = \'1\' AND json_extract(`another`,\'$.value\') = \'string\'', - mariadb: 'json_unquote(json_extract(`property`,\'$.value\')) = \'1\' AND json_unquote(json_extract(`another`,\'$.value\')) = \'string\'', - mysql: 'json_unquote(json_extract(`property`,\'$.\\"value\\"\')) = \'1\' AND json_unquote(json_extract(`another`,\'$.\\"value\\"\')) = \'string\'', - }); - }); - - it('property array object', () => { - expectsql(sql.whereItemQuery(undefined, Sequelize.json({ property: [[4, 6], [8]] })), { - postgres: '("property"#>>\'{0,0}\') = \'4\' AND ("property"#>>\'{0,1}\') = \'6\' AND ("property"#>>\'{1,0}\') = \'8\'', - sqlite: 'json_extract(`property`,\'$[0][0]\') = \'4\' AND json_extract(`property`,\'$[0][1]\') = \'6\' AND json_extract(`property`,\'$[1][0]\') = \'8\'', - mariadb: 'json_unquote(json_extract(`property`,\'$[0][0]\')) = \'4\' AND json_unquote(json_extract(`property`,\'$[0][1]\')) = \'6\' AND json_unquote(json_extract(`property`,\'$[1][0]\')) = \'8\'', - mysql: 'json_unquote(json_extract(`property`,\'$[0][0]\')) = \'4\' AND json_unquote(json_extract(`property`,\'$[0][1]\')) = \'6\' AND json_unquote(json_extract(`property`,\'$[1][0]\')) = \'8\'', - }); - }); - - it('dot notation', () => { - expectsql(sql.whereItemQuery(Sequelize.json('profile.id'), '1'), { - postgres: '("profile"#>>\'{id}\') = \'1\'', - sqlite: 'json_extract(`profile`,\'$.id\') = \'1\'', - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\')) = \'1\'', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\')) = \'1\'', - }); - }); - - it('item dot notation array', () => { - expectsql(sql.whereItemQuery(Sequelize.json('profile.id.0.1'), '1'), { - postgres: '("profile"#>>\'{id,0,1}\') = \'1\'', - sqlite: 'json_extract(`profile`,\'$.id[0][1]\') = \'1\'', - mariadb: 'json_unquote(json_extract(`profile`,\'$.id[0][1]\')) = \'1\'', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"[0][1]\')) = \'1\'', - }); - }); - - it('column named "json"', () => { - expectsql(sql.whereItemQuery(Sequelize.json('json'), '{}'), { - postgres: '("json"#>>\'{}\') = \'{}\'', - sqlite: 'json_extract(`json`,\'$\') = \'{}\'', - mariadb: 'json_unquote(json_extract(`json`,\'$\')) = \'{}\'', - mysql: 'json_unquote(json_extract(`json`,\'$\')) = \'{}\'', - }); - }); - }); - - describe('raw json query', () => { - if (!dialect.supports.jsonOperations) { - return; - } - - if (current.dialect.name === 'postgres') { - it('#>> operator', () => { - expectsql(sql.whereItemQuery(Sequelize.json('("data"#>>\'{id}\')'), 'id'), { - postgres: '("data"#>>\'{id}\') = \'id\'', - }); - }); - } - - it('json function', () => { - expectsql(sql.handleSequelizeMethod(Sequelize.json('json(\'{"profile":{"name":"david"}}\')')), { - default: 'json(\'{"profile":{"name":"david"}}\')', - }); - }); - - it('nested json functions', () => { - expectsql(sql.handleSequelizeMethod(Sequelize.json('json_extract(json_object(\'{"profile":null}\'), "profile")')), { - default: 'json_extract(json_object(\'{"profile":null}\'), "profile")', - }); - }); - - it('escaped string argument', () => { - expectsql(sql.handleSequelizeMethod(Sequelize.json('json(\'{"quote":{"single":"\'\'","double":""""},"parenthesis":"())("}\')')), { - default: 'json(\'{"quote":{"single":"\'\'","double":""""},"parenthesis":"())("}\')', - }); - }); - - it('unbalanced statement', () => { - expect(() => sql.handleSequelizeMethod(Sequelize.json('json())'))).to.throw(); - expect(() => sql.handleSequelizeMethod(Sequelize.json('json_extract(json()'))).to.throw(); - }); - - it('separator injection', () => { - expect(() => sql.handleSequelizeMethod(Sequelize.json('json(; DELETE YOLO INJECTIONS; -- )'))).to.throw(); - expect(() => sql.handleSequelizeMethod(Sequelize.json('json(); DELETE YOLO INJECTIONS; -- '))).to.throw(); - }); - }); - }); -}); diff --git a/packages/core/test/unit/sql/literal.test.ts b/packages/core/test/unit/sql/literal.test.ts new file mode 100644 index 000000000000..4c7d4b8409e5 --- /dev/null +++ b/packages/core/test/unit/sql/literal.test.ts @@ -0,0 +1,152 @@ +import { cast, fn, Op, json, where } from '@sequelize/core'; +import { expectsql, sequelize } from '../../support'; + +const dialect = sequelize.dialect; +const queryGenerator = sequelize.queryGenerator; + +describe('json', () => { + if (!dialect.supports.jsonOperations) { + return; + } + + it('supports WhereOptions', () => { + const conditions = { + metadata: { + language: 'icelandic', + pg_rating: { dk: 'G' }, + }, + another_json_field: { x: 1 }, + }; + + expectsql(() => queryGenerator.escape(json(conditions)), { + postgres: `("metadata"->'language' = '"icelandic"' AND "metadata"#>ARRAY['pg_rating','dk'] = '"G"') AND "another_json_field"->'x' = '1'`, + sqlite: `(json_extract(\`metadata\`,'$.language') = '"icelandic"' AND json_extract(\`metadata\`,'$.pg_rating.dk') = '"G"') AND json_extract(\`another_json_field\`,'$.x') = '1'`, + mariadb: `(json_compact(json_extract(\`metadata\`,'$.language')) = '"icelandic"' AND json_compact(json_extract(\`metadata\`,'$.pg_rating.dk')) = '"G"') AND json_compact(json_extract(\`another_json_field\`,'$.x')) = '1'`, + mysql: `(json_extract(\`metadata\`,'$.language') = CAST('"icelandic"' AS JSON) AND json_extract(\`metadata\`,'$.pg_rating.dk') = CAST('"G"' AS JSON)) AND json_extract(\`another_json_field\`,'$.x') = CAST('1' AS JSON)`, + }); + }); + + it('supports the json path notation', () => { + const path = 'metadata.pg_rating.dk'; + + expectsql(() => queryGenerator.escape(json(path)), { + postgres: `"metadata"#>ARRAY['pg_rating','dk']`, + mariadb: `json_compact(json_extract(\`metadata\`,'$.pg_rating.dk'))`, + 'sqlite mysql': `json_extract(\`metadata\`,'$.pg_rating.dk')`, + }); + }); + + it('supports numbers in the dot notation', () => { + expectsql(queryGenerator.escape(json('profile.id.0.1')), { + postgres: `"profile"#>ARRAY['id','0','1']`, + mariadb: `json_compact(json_extract(\`profile\`,'$.id."0"."1"'))`, + 'sqlite mysql': `json_extract(\`profile\`,'$.id."0"."1"')`, + }); + }); + + it('can take a value to compare against', () => { + const path = 'metadata.pg_rating.is'; + const value = 'U'; + + expectsql(() => queryGenerator.escape(json(path, value)), { + postgres: `"metadata"#>ARRAY['pg_rating','is'] = '"U"'`, + sqlite: `json_extract(\`metadata\`,'$.pg_rating.is') = '"U"'`, + mariadb: `json_compact(json_extract(\`metadata\`,'$.pg_rating.is')) = '"U"'`, + mysql: `json_extract(\`metadata\`,'$.pg_rating.is') = CAST('"U"' AS JSON)`, + }); + }); + + // TODO: add a way to let `where` know what the type of the value is in raw queries + // it('accepts a condition object', () => { + // expectsql(queryGenerator.escape(json({ id: 1 })), { + // postgres: `"id" = '1'`, + // }); + // }); + // + // it('column named "json"', () => { + // expectsql(queryGenerator.escape(where(json('json'), Op.eq, {})), { + // postgres: `("json"#>>'{}') = '{}'`, + // }); + // }); + + it('accepts a nested condition object', () => { + expectsql(queryGenerator.escape(json({ profile: { id: 1 } })), { + postgres: `"profile"->'id' = '1'`, + sqlite: `json_extract(\`profile\`,'$.id') = '1'`, + mariadb: `json_compact(json_extract(\`profile\`,'$.id')) = '1'`, + mysql: `json_extract(\`profile\`,'$.id') = CAST('1' AS JSON)`, + }); + }); + + it('accepts multiple condition object', () => { + expectsql(queryGenerator.escape(json({ property: { value: 1 }, another: { value: 'string' } })), { + postgres: `"property"->'value' = '1' AND "another"->'value' = '"string"'`, + sqlite: `json_extract(\`property\`,'$.value') = '1' AND json_extract(\`another\`,'$.value') = '"string"'`, + mariadb: `json_compact(json_extract(\`property\`,'$.value')) = '1' AND json_compact(json_extract(\`another\`,'$.value')) = '"string"'`, + mysql: `json_extract(\`property\`,'$.value') = CAST('1' AS JSON) AND json_extract(\`another\`,'$.value') = CAST('"string"' AS JSON)`, + }); + }); + + it('can be used inside of where', () => { + expectsql(queryGenerator.escape(where(json('profile.id'), '1')), { + postgres: `"profile"->'id' = '"1"'`, + sqlite: `json_extract(\`profile\`,'$.id') = '"1"'`, + mariadb: `json_compact(json_extract(\`profile\`,'$.id')) = '"1"'`, + mysql: `json_extract(\`profile\`,'$.id') = CAST('"1"' AS JSON)`, + }); + }); +}); + +describe('cast', () => { + it('accepts condition object (auto casting)', () => { + expectsql(() => queryGenerator.escape(fn('SUM', cast({ + [Op.or]: { + foo: 'foo', + bar: 'bar', + }, + }, 'int'))), { + default: `SUM(CAST(([foo] = 'foo' OR [bar] = 'bar') AS INT))`, + mssql: `SUM(CAST(([foo] = N'foo' OR [bar] = N'bar') AS INT))`, + }); + }); +}); + +describe('fn', () => { + // this was a band-aid over a deeper problem ('$bind' being considered to be a bind parameter when it's a string), which has been fixed + it('should not escape $ in fn() arguments', () => { + const out = queryGenerator.escape(fn('upper', '$user')); + + expectsql(out, { + default: `upper('$user')`, + mssql: `upper(N'$user')`, + }); + }); + + it('accepts all sorts of values as arguments', () => { + const out = queryGenerator.escape( + fn('concat', 'user', 1, true, new Date(Date.UTC(2011, 2, 27, 10, 1, 55)), fn('lower', 'user')), + ); + + expectsql(out, { + postgres: `concat('user', 1, true, '2011-03-27 10:01:55.000 +00:00', lower('user'))`, + mssql: `concat(N'user', 1, 1, N'2011-03-27 10:01:55.000 +00:00', lower(N'user'))`, + sqlite: `concat('user', 1, 1, '2011-03-27 10:01:55.000 +00:00', lower('user'))`, + ibmi: `concat('user', 1, 1, '2011-03-27 10:01:55.000', lower('user'))`, + default: `concat('user', 1, true, '2011-03-27 10:01:55.000', lower('user'))`, + }); + }); + + it('accepts arrays', () => { + if (!dialect.supports.dataTypes.ARRAY) { + return; + } + + const out = queryGenerator.escape( + fn('concat', ['abc']), + ); + + expectsql(out, { + default: `concat(ARRAY['abc'])`, + }); + }); +}); diff --git a/packages/core/test/unit/sql/select.test.js b/packages/core/test/unit/sql/select.test.js index 64fd90d8ab46..f55edd57f8e8 100644 --- a/packages/core/test/unit/sql/select.test.js +++ b/packages/core/test/unit/sql/select.test.js @@ -23,7 +23,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { testFunction(util.inspect(options, { depth: 2 }), () => { return expectsql( - sql.selectQuery( + () => sql.selectQuery( options.table || model && model.getTableName(), options, options.model, @@ -179,8 +179,8 @@ describe(Support.getTestDialectTeaser('SQL'), () => { FROM [users] AS [user] INNER JOIN [project_users] AS [project_user] ON [user].[id_user] = [project_user].[user_id] - AND [project_user].[project_id] = 1 - AND [project_user].[status] = 1 + AND ([project_user].[project_id] = 1 + AND [project_user].[status] = 1) ORDER BY [subquery_order_0] ASC${current.dialect.name === 'mssql' ? ', [user].[id_user]' : ''}${sql.addLimitAndOffset({ limit: 3, order: ['last_name', 'ASC'] })} ) AS sub`, `SELECT * FROM ( @@ -188,8 +188,8 @@ describe(Support.getTestDialectTeaser('SQL'), () => { FROM [users] AS [user] INNER JOIN [project_users] AS [project_user] ON [user].[id_user] = [project_user].[user_id] - AND [project_user].[project_id] = 5 - AND [project_user].[status] = 1 + AND ([project_user].[project_id] = 5 + AND [project_user].[status] = 1) ORDER BY [subquery_order_0] ASC${current.dialect.name === 'mssql' ? ', [user].[id_user]' : ''}${sql.addLimitAndOffset({ limit: 3, order: ['last_name', 'ASC'] })} ) AS sub`, ].join(current.dialect.supports['UNION ALL'] ? ' UNION ALL ' : ' UNION ') @@ -596,12 +596,12 @@ describe(Support.getTestDialectTeaser('SQL'), () => { default: 'SELECT [User].* FROM ' + '(SELECT [User].[name], [User].[age], [User].[id], [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM [User] AS [User] ' + 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id] ' - + `WHERE ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];`, + + `WHERE ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE [postaliasname].[user_id] = [User].[id]${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];`, }); }); it('w/ nested column filter', () => { - expectsql(sql.selectQuery('User', { + expectsql(() => sql.selectQuery('User', { table: User.getTableName(), model: User, attributes: ['name', 'age'], @@ -621,7 +621,7 @@ describe(Support.getTestDialectTeaser('SQL'), () => { default: 'SELECT [User].* FROM ' + '(SELECT [User].[name], [User].[age], [User].[id], [postaliasname].[id] AS [postaliasname.id], [postaliasname].[title] AS [postaliasname.title] FROM [User] AS [User] ' + 'INNER JOIN [Post] AS [postaliasname] ON [User].[id] = [postaliasname].[user_id] ' - + `WHERE [postaliasname].[title] = ${sql.escape('test')} AND ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE ([postaliasname].[user_id] = [User].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];`, + + `WHERE [postaliasname].[title] = ${sql.escape('test')} AND ( SELECT [user_id] FROM [Post] AS [postaliasname] WHERE [postaliasname].[user_id] = [User].[id]${sql.addLimitAndOffset({ limit: 1, tableAs: 'postaliasname' }, User)} ) IS NOT NULL) AS [User];`, }); }); }); @@ -674,10 +674,10 @@ describe(Support.getTestDialectTeaser('SQL'), () => { + 'SELECT [Company].[name], [Company].[public], [Company].[id] FROM [Company] AS [Company] ' + 'INNER JOIN [Users] AS [Users] ON [Company].[id] = [Users].[companyId] ' + 'INNER JOIN [Professions] AS [Users->Profession] ON [Users].[professionId] = [Users->Profession].[id] ' - + `WHERE ([Company].[scopeId] IN (42)) AND [Users->Profession].[name] = ${sql.escape('test')} AND ( ` + + `WHERE ([Company].[scopeId] IN (42) AND [Users->Profession].[name] = ${sql.escape('test')}) AND ( ` + 'SELECT [Users].[companyId] FROM [Users] AS [Users] ' + 'INNER JOIN [Professions] AS [Profession] ON [Users].[professionId] = [Profession].[id] ' - + `WHERE ([Users].[companyId] = [Company].[id])${sql.addLimitAndOffset({ limit: 1, tableAs: 'Users' }, User)} ` + + `WHERE [Users].[companyId] = [Company].[id]${sql.addLimitAndOffset({ limit: 1, tableAs: 'Users' }, User)} ` + `) IS NOT NULL${sql.addLimitAndOffset({ limit: 5, offset: 0, tableAs: 'Company' }, Company)}) AS [Company];`, }); }); @@ -1019,60 +1019,4 @@ describe(Support.getTestDialectTeaser('SQL'), () => { }); }); }); - - describe('raw query', () => { - it('raw replacements for where', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - where: ['name IN (?)', [1, 'test', 3, 'derp']], - }); - }).to.throw(Error, 'Support for literal replacements in the `where` object has been removed.'); - }); - - it('raw replacements for nested where', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - where: [['name IN (?)', [1, 'test', 3, 'derp']]], - }); - }).to.throw(Error, 'Support for literal replacements in the `where` object has been removed.'); - }); - - it('raw replacements for having', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - having: ['name IN (?)', [1, 'test', 3, 'derp']], - }); - }).to.throw(Error, 'Support for literal replacements in the `where` object has been removed.'); - }); - - it('raw replacements for nested having', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - having: [['name IN (?)', [1, 'test', 3, 'derp']]], - }); - }).to.throw(Error, 'Support for literal replacements in the `where` object has been removed.'); - }); - - it('raw string from where', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - where: 'name = \'something\'', - }); - }).to.throw(Error, 'Support for `{where: \'raw query\'}` has been removed.'); - }); - - it('raw string from having', () => { - expect(() => { - sql.selectQuery('User', { - attributes: ['*'], - having: 'name = \'something\'', - }); - }).to.throw(Error, 'Support for `{where: \'raw query\'}` has been removed.'); - }); - }); }); diff --git a/packages/core/test/unit/sql/where.test.ts b/packages/core/test/unit/sql/where.test.ts index 7e7d68bd0db1..8e444a54245c 100644 --- a/packages/core/test/unit/sql/where.test.ts +++ b/packages/core/test/unit/sql/where.test.ts @@ -11,23 +11,20 @@ import type { Col, Literal, Fn, - Cast, + Cast, AttributeNames, } from '@sequelize/core'; -import { DataTypes, QueryTypes, Op, literal, col, where, fn, json, cast, and, or, Model } from '@sequelize/core'; -import type { WhereItemsQueryOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/query-generator.js'; +import { DataTypes, Op, and, or, Model, sql, json } from '@sequelize/core'; +import type { FormatWhereOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/dialects/abstract/query-generator-typescript.js'; import { createTester, sequelize, expectsql, getTestDialectTeaser } from '../../support'; -const sql = sequelize.dialect.queryGenerator; +const { literal, col, where, fn, cast, attribute } = sql; + +const queryGen = sequelize.dialect.queryGenerator; // Notice: [] will be replaced by dialect specific tick/quote character // when there is no dialect specific expectation but only a default expectation -// TODO: -// - fix and resolve any .skip test -// - don't disable test suites if the dialect doesn't support. Instead, ensure dialect throws an error if these operators are used. -// - drop Op.values & automatically determine if Op.any & Op.all need to use Op.values? - -type Options = Omit; +// TODO: fix and resolve any .skip test type Expectations = { [dialectName: string]: string | Error, @@ -37,6 +34,7 @@ const dialectSupportsArray = () => sequelize.dialect.supports.dataTypes.ARRAY; const dialectSupportsRange = () => sequelize.dialect.supports.dataTypes.RANGE; const dialectSupportsJsonB = () => sequelize.dialect.supports.dataTypes.JSONB; const dialectSupportsJson = () => sequelize.dialect.supports.dataTypes.JSON; +const dialectSupportsJsonOperations = () => sequelize.dialect.supports.jsonOperations; class TestModel extends Model> { declare intAttr1: number; @@ -60,6 +58,8 @@ class TestModel extends Model> { declare aliasedInt: number; declare aliasedJsonAttr: object; declare aliasedJsonbAttr: object; + + declare uuidAttr: string; } type TestModelWhere = WhereOptions>; @@ -93,13 +93,15 @@ TestModel.init({ jsonbAttr: { type: DataTypes.JSONB }, aliasedJsonbAttr: { type: DataTypes.JSONB, field: 'aliased_jsonb' }, }), + + uuidAttr: DataTypes.UUID, }, { sequelize }); describe(getTestDialectTeaser('SQL'), () => { describe('whereQuery', () => { it('prefixes its output with WHERE when it is not empty', () => { expectsql( - sql.whereQuery({ firstName: 'abc' }), + queryGen.whereQuery({ firstName: 'abc' }), { default: `WHERE [firstName] = 'abc'`, mssql: `WHERE [firstName] = N'abc'`, @@ -109,7 +111,7 @@ describe(getTestDialectTeaser('SQL'), () => { it('returns an empty string if the input results in an empty query', () => { expectsql( - sql.whereQuery({ firstName: { [Op.notIn]: [] } }), + queryGen.whereQuery({ firstName: { [Op.notIn]: [] } }), { default: '', }, @@ -170,7 +172,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: `[intAttr1] ${sqlOperator} NOW()`, }); - testSql.skip({ intAttr1: { [operator]: fn('SUM', { [Op.col]: 'intAttr2' }) } }, { + testSql({ intAttr1: { [operator]: fn('SUM', { [Op.col]: 'intAttr2' }) } }, { default: `[intAttr1] ${sqlOperator} SUM([intAttr2])`, }); @@ -178,7 +180,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: `[intAttr1] ${sqlOperator} CAST([intAttr2] AS STRING)`, }); - testSql.skip({ intAttr1: { [operator]: cast({ [Op.col]: 'intAttr2' }, 'string') } }, { + testSql({ intAttr1: { [operator]: cast({ [Op.col]: 'intAttr2' }, 'string') } }, { default: `[intAttr1] ${sqlOperator} CAST([intAttr2] AS STRING)`, }); @@ -212,11 +214,13 @@ describe(getTestDialectTeaser('SQL'), () => { * @param operator * @param sqlOperator * @param testWithValues + * @param attributeName */ function testSupportsAnyAll( operator: OperatorsSupportingAnyAll, sqlOperator: string, testWithValues: TestWithValue[], + attributeName: AttributeNames = 'intAttr1', ) { if (!dialectSupportsArray()) { return; @@ -226,19 +230,19 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.any, 'ANY'], [Op.all, 'ALL'], ]; + for (const [arrayOperator, arraySqlOperator] of arrayOperators) { - // doesn't work at all - testSql.skip({ intAttr1: { [operator]: { [arrayOperator]: testWithValues } } }, { - default: `[intAttr1] ${sqlOperator} ${arraySqlOperator} (ARRAY[${testWithValues.map(v => util.inspect(v)).join(',')}])`, + testSql({ [attributeName]: { [operator]: { [arrayOperator]: testWithValues } } }, { + default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (ARRAY[${testWithValues.map(v => util.inspect(v)).join(',')}])`, }); - testSql({ intAttr1: { [operator]: { [arrayOperator]: literal('literal') } } }, { - default: `[intAttr1] ${sqlOperator} ${arraySqlOperator} (literal)`, + testSql({ [attributeName]: { [operator]: { [arrayOperator]: literal('literal') } } }, { + default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (literal)`, }); // e.g. "col" LIKE ANY (VALUES ("col2")) - testSql.skip({ - intAttr1: { + testSql({ + [attributeName]: { [operator]: { [arrayOperator]: { [Op.values]: [ @@ -246,23 +250,21 @@ describe(getTestDialectTeaser('SQL'), () => { fn('UPPER', col('col2')), col('col3'), cast(col('col'), 'string'), - 'abc', - 12, + testWithValues[0], ], }, }, }, }, { - default: `[intAttr1] ${sqlOperator} ${arraySqlOperator} (VALUES (literal), (UPPER("col2")), ("col3"), (CAST("col" AS STRING)), ('abc'), (12))`, - mssql: `[intAttr1] ${sqlOperator} ${arraySqlOperator} (VALUES (literal), (UPPER("col2")), ("col3"), (CAST("col" AS STRING)), (N'abc'), (12))`, + default: `[${attributeName}] ${sqlOperator} ${arraySqlOperator} (VALUES (literal), (UPPER("col2")), ("col3"), (CAST("col" AS STRING)), (${util.inspect(testWithValues[0])}))`, }); } } const testSql = createTester( - (it, whereObj: TestModelWhere, expectations: Expectations, options?: Options) => { + (it, whereObj: TestModelWhere, expectations: Expectations, options?: FormatWhereOptions) => { it(util.inspect(whereObj, { depth: 10 }) + (options ? `, ${util.inspect(options)}` : ''), () => { - const sqlOrError = attempt(() => sql.whereItemsQuery(whereObj, { + const sqlOrError = attempt(() => queryGen.whereItemsQuery(whereObj, { ...options, model: TestModel, })); @@ -272,6 +274,11 @@ describe(getTestDialectTeaser('SQL'), () => { }, ); + // "where" is typically optional. If the user sets it to undefined, we treat is as if the option was not set. + testSql(undefined, { + default: '', + }); + testSql({}, { default: '', }); @@ -281,53 +288,33 @@ describe(getTestDialectTeaser('SQL'), () => { }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip(10, { - default: new Error('Unexpected value "10" received. Expected an object, array or a literal()'), + testSql(null, { + default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. +Value: null +Caused by: Invalid Query: expected a plain object, an array or a sequelize SQL method but got null`), }); - testSql({ intAttr1: undefined }, { - default: new Error('WHERE parameter "intAttr1" has invalid "undefined" value'), + // @ts-expect-error -- not supported, testing that it throws + testSql(10, { + default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. +Value: 10 +Caused by: Invalid Query: expected a plain object, an array or a sequelize SQL method but got 10`), }); - // @ts-expect-error -- user does not exist - testSql({ intAttr1: 1, user: undefined }, { - default: new Error('WHERE parameter "user" has invalid "undefined" value'), + testSql({ intAttr1: undefined }, { + default: new Error(`Invalid value received for the "where" option. Refer to the sequelize documentation to learn which values the "where" option accepts. +Value: { intAttr1: undefined } +Caused by: "undefined" cannot be escaped`), }); // @ts-expect-error -- user does not exist - testSql({ intAttr1: 1, user: undefined }, { - default: new Error('WHERE parameter "user" has invalid "undefined" value'), - }, { type: QueryTypes.SELECT }); - - // @ts-expect-error -- user does not exist - testSql({ intAttr1: 1, user: undefined }, { - default: new Error('WHERE parameter "user" has invalid "undefined" value'), - }, { type: QueryTypes.BULKDELETE }); - - // @ts-expect-error -- user does not exist - testSql({ intAttr1: 1, user: undefined }, { - default: new Error('WHERE parameter "user" has invalid "undefined" value'), - }, { type: QueryTypes.BULKUPDATE }); + testSql({ intAttr1: 1, user: undefined }, { default: new Error('"undefined" cannot be escaped') }); testSql({ intAttr1: 1 }, { default: '[User].[intAttr1] = 1', - }, { prefix: 'User' }); + }, { mainAlias: 'User' }); - testSql({ dateAttr: { $gte: '2022-11-06' } }, { - default: new Error(`{ '$gte': '2022-11-06' } is not a valid date`), - }); - - it('{ id: 1 }, { prefix: literal(sql.quoteTable.call(sequelize.dialect.queryGenerator, {schema: \'yolo\', tableName: \'User\'})) }', () => { - expectsql(sql.whereItemsQuery({ id: 1 }, { - prefix: literal(sql.quoteTable.call(sequelize.dialect.queryGenerator, { - schema: 'yolo', - tableName: 'User', - })), - }), { - default: '[yolo].[User].[id] = 1', - sqlite: '`yolo.User`.`id` = 1', - }); - }); + testSql({ dateAttr: { $gte: '2022-11-06' } }, { default: new Error(`{ '$gte': '2022-11-06' } is not a valid date`) }); testSql(literal('raw sql'), { default: 'raw sql', @@ -401,11 +388,9 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] IN (N'1', N'2')`, }); - testSql({ intAttr1: ['not-an-int'] }, { - default: new Error(`'not-an-int' is not a valid integer`), - }); + testSql({ intAttr1: ['not-an-int'] }, { default: new Error(`'not-an-int' is not a valid integer`) }); - testSql.skip({ 'stringAttr::integer': 1 }, { + testSql({ 'stringAttr::integer': 1 }, { default: 'CAST([stringAttr] AS INTEGER) = 1', }); @@ -413,7 +398,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[intAttr1] = 1', }); - testSql.skip({ '$stringAttr$::integer': 1 }, { + testSql({ '$stringAttr$::integer': 1 }, { default: 'CAST([stringAttr] AS INTEGER) = 1', }); @@ -421,7 +406,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[association].[attribute] = 1', }); - testSql.skip({ '$association.attribute$::integer': 1 }, { + testSql({ '$association.attribute$::integer': 1 }, { default: 'CAST([association].[attribute] AS INTEGER) = 1', }); @@ -460,11 +445,11 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[intAttr1] = [intAttr2]', }); - testSql.skip({ intAttr1: col('intAttr2') }, { + testSql({ intAttr1: col('intAttr2') }, { default: '[intAttr1] = [intAttr2]', }); - testSql.skip({ intAttr1: literal('literal') }, { + testSql({ intAttr1: literal('literal') }, { default: '[intAttr1] = literal', }); @@ -472,26 +457,26 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[stringAttr] = UPPER([stringAttr])', }); - testSql({ stringAttr: fn('UPPER', { [Op.col]: col('stringAttr') }) }, { + testSql({ stringAttr: fn('UPPER', { [Op.col]: 'stringAttr' }) }, { default: '[stringAttr] = UPPER([stringAttr])', }); - testSql.skip({ stringAttr: cast(col('intAttr1'), 'string') }, { + testSql({ stringAttr: cast(col('intAttr1'), 'string') }, { default: '[stringAttr] = CAST([intAttr1] AS STRING)', }); - testSql.skip({ stringAttr: cast({ [Op.col]: 'intAttr1' }, 'string') }, { + testSql({ stringAttr: cast({ [Op.col]: 'intAttr1' }, 'string') }, { default: '[stringAttr] = CAST([intAttr1] AS STRING)', }); - testSql.skip({ stringAttr: cast('abc', 'string') }, { + testSql({ stringAttr: cast('abc', 'string') }, { default: `[stringAttr] = CAST('abc' AS STRING)`, mssql: `[stringAttr] = CAST(N'abc' AS STRING)`, }); if (dialectSupportsArray()) { testSql({ intArrayAttr: [1, 2] }, { - default: `[intArrayAttr] = ARRAY[1,2]::INTEGER[]`, + default: `[intArrayAttr] = ARRAY[1,2]`, }); testSql({ intArrayAttr: [] }, { @@ -500,11 +485,9 @@ describe(getTestDialectTeaser('SQL'), () => { // when using arrays, Op.in is never included // @ts-expect-error -- Omitting the operator with an array attribute is always Op.eq, never Op.in - testSql.skip({ intArrayAttr: [[1, 2]] }, { - default: new Error(`"intArrayAttr" cannot be compared to [[1, 2]], did you mean to use Op.in?`), - }); + testSql({ intArrayAttr: [[1, 2]] }, { default: new Error('[ 1, 2 ] is not a valid integer') }); - testSql.skip({ intAttr1: { [Op.any]: [2, 3, 4] } }, { + testSql({ intAttr1: { [Op.any]: [2, 3, 4] } }, { default: '[intAttr1] = ANY (ARRAY[2,3,4])', }); @@ -516,7 +499,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[intAttr1] = ANY (VALUES ([col]))', }); - testSql.skip({ intAttr1: { [Op.all]: [2, 3, 4] } }, { + testSql({ intAttr1: { [Op.all]: [2, 3, 4] } }, { default: '[intAttr1] = ALL (ARRAY[2,3,4])', }); @@ -537,14 +520,12 @@ describe(getTestDialectTeaser('SQL'), () => { fn('UPPER', col('col2')), col('col3'), cast(col('col'), 'string'), - 'abc', 1, ], }, }, }, { - default: `[intAttr1] = ANY (VALUES (literal), (UPPER([col2])), ([col3]), (CAST([col] AS STRING)), ('abc'), (1))`, - mssql: `[intAttr1] = ANY (VALUES (literal), (UPPER([col2])), ([col3]), (CAST([col] AS STRING)), (N'abc'), (1))`, + default: `[intAttr1] = ANY (VALUES (literal), (UPPER([col2])), ([col3]), (CAST([col] AS STRING)), (1))`, }); } }); @@ -554,7 +535,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[intAttr1] = 1', }); - testSql.skip({ 'intAttr1::integer': { [Op.eq]: 1 } }, { + testSql({ 'intAttr1::integer': { [Op.eq]: 1 } }, { default: 'CAST([intAttr1] AS INTEGER) = 1', }); @@ -562,7 +543,7 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[intAttr1] = 1', }); - testSql.skip({ '$intAttr1$::integer': { [Op.eq]: 1 } }, { + testSql({ '$intAttr1$::integer': { [Op.eq]: 1 } }, { default: 'CAST([intAttr1] AS INTEGER) = 1', }); @@ -570,8 +551,8 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[association].[attribute] = 1', }); - testSql.skip({ '$association.attribute$::integer': { [Op.eq]: 1 } }, { - default: 'CAST([association].[attribute] AS INTEGER) = 1', + testSql({ '$association.attribute$::integer': { [Op.eq]: 1 } }, { + default: `CAST([association].[attribute] AS INTEGER) = 1`, }); if (dialectSupportsArray()) { @@ -579,7 +560,7 @@ describe(getTestDialectTeaser('SQL'), () => { const ignore: TestModelWhere = { intAttr1: { [Op.eq]: [1, 2] } }; testSql({ intArrayAttr: { [Op.eq]: [1, 2] } }, { - default: '[intArrayAttr] = ARRAY[1,2]::INTEGER[]', + default: '[intArrayAttr] = ARRAY[1,2]', }); } @@ -595,9 +576,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ booleanAttr: { [Op.eq]: true } }, { default: '[booleanAttr] = true', - mssql: '[booleanAttr] = 1', - sqlite: '`booleanAttr` = 1', - ibmi: '"booleanAttr" = 1', + 'mssql sqlite ibmi': '[booleanAttr] = 1', }); testSequelizeValueMethods(Op.eq, '='); @@ -611,7 +590,7 @@ describe(getTestDialectTeaser('SQL'), () => { if (dialectSupportsArray()) { testSql({ intArrayAttr: { [Op.ne]: [1, 2] } }, { - default: '[intArrayAttr] != ARRAY[1,2]::INTEGER[]', + default: '[intArrayAttr] != ARRAY[1,2]', }); } @@ -621,9 +600,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ booleanAttr: { [Op.ne]: true } }, { default: '[booleanAttr] != true', - mssql: '[booleanAttr] != 1', - ibmi: '"booleanAttr" != 1', - sqlite: '`booleanAttr` != 1', + 'mssql ibmi sqlite': '[booleanAttr] != 1', }); testSequelizeValueMethods(Op.ne, '!='); @@ -647,98 +624,119 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ booleanAttr: { [Op.is]: false } }, { default: '[booleanAttr] IS false', - mssql: '[booleanAttr] IS 0', - ibmi: '"booleanAttr" IS 0', - sqlite: '`booleanAttr` IS 0', + 'mssql ibmi sqlite': '[booleanAttr] IS 0', }); testSql({ booleanAttr: { [Op.is]: true } }, { default: '[booleanAttr] IS true', - mssql: '[booleanAttr] IS 1', - ibmi: '"booleanAttr" IS 1', - sqlite: '`booleanAttr` IS 1', + 'mssql ibmi sqlite': '[booleanAttr] IS 1', }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: 1 } }, { - default: new Error('Op.is expected a boolean or null, but received 1'), + testSql({ intAttr1: { [Op.is]: 1 } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: { [Op.col]: 'intAttr2' } } }, { - default: new Error('column references are not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: { [Op.col]: 'intAttr2' } } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: col('intAttr2') } }, { - default: new Error('column references are not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: col('intAttr2') } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); - testSql({ intAttr1: { [Op.is]: literal('literal') } }, { - default: '[intAttr1] IS literal', + testSql({ intAttr1: { [Op.is]: literal('UNKNOWN') } }, { + default: '[intAttr1] IS UNKNOWN', }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: fn('UPPER', col('intAttr2')) } }, { - default: new Error('SQL functions are not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: fn('UPPER', col('intAttr2')) } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: cast(col('intAttr2'), 'boolean') } }, { - default: new Error('CAST is not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: cast(col('intAttr2'), 'boolean') } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); if (dialectSupportsArray()) { // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: { [Op.any]: [2, 3] } } }, { - default: new Error('Op.any is not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: { [Op.any]: [2, 3] } } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); // @ts-expect-error -- not supported, testing that it throws - testSql.skip({ intAttr1: { [Op.is]: { [Op.all]: [2, 3, 4] } } }, { - default: new Error('Op.all is not supported by Op.is'), + testSql({ intAttr1: { [Op.is]: { [Op.all]: [2, 3, 4] } } }, { + default: new Error('Operators Op.is and Op.isNot can only be used with null, true, false or a literal.'), }); } }); + describe('Op.isNot', () => { + testSql({ nullableIntAttr: { [Op.isNot]: null } }, { + default: '[nullableIntAttr] IS NOT NULL', + }); + + testSql({ booleanAttr: { [Op.isNot]: false } }, { + default: '[booleanAttr] IS NOT false', + 'mssql ibmi sqlite': '[booleanAttr] IS NOT 0', + }); + + testSql({ booleanAttr: { [Op.isNot]: true } }, { + default: '[booleanAttr] IS NOT true', + 'mssql ibmi sqlite': '[booleanAttr] IS NOT 1', + }); + }); + describe('Op.not', () => { testSql({ [Op.not]: {} }, { - default: '0 = 1', + default: '', + }); + + testSql({ + [Op.not]: { + [Op.not]: {}, + }, + }, { + default: '', }); testSql({ [Op.not]: [] }, { - default: '0 = 1', + default: '', + }); + + testSql({ nullableIntAttr: { [Op.not]: {} } }, { + default: '', }); testSql({ nullableIntAttr: { [Op.not]: null } }, { - default: '[nullableIntAttr] IS NOT NULL', + default: 'NOT ([nullableIntAttr] IS NULL)', }); testSql({ booleanAttr: { [Op.not]: false } }, { - default: '[booleanAttr] IS NOT false', - mssql: '[booleanAttr] IS NOT 0', - ibmi: '"booleanAttr" IS NOT 0', - sqlite: '`booleanAttr` IS NOT 0', + default: 'NOT ([booleanAttr] = false)', + mssql: 'NOT ([booleanAttr] = 0)', + ibmi: 'NOT ("booleanAttr" = 0)', + sqlite: 'NOT (`booleanAttr` = 0)', }); testSql({ booleanAttr: { [Op.not]: true } }, { - default: '[booleanAttr] IS NOT true', - mssql: '[booleanAttr] IS NOT 1', - ibmi: '"booleanAttr" IS NOT 1', - sqlite: '`booleanAttr` IS NOT 1', + default: 'NOT ([booleanAttr] = true)', + mssql: 'NOT ([booleanAttr] = 1)', + ibmi: 'NOT ("booleanAttr" = 1)', + sqlite: 'NOT (`booleanAttr` = 1)', }); testSql({ intAttr1: { [Op.not]: 1 } }, { - default: '[intAttr1] != 1', + default: 'NOT ([intAttr1] = 1)', }); testSql({ intAttr1: { [Op.not]: [1, 2] } }, { - default: '[intAttr1] NOT IN (1, 2)', + default: 'NOT ([intAttr1] IN (1, 2))', }); - testSequelizeValueMethods(Op.not, '!='); - testSupportsAnyAll(Op.not, '!=', [2, 3, 4]); - { // @ts-expect-error -- not a valid query: attribute does not exist. const ignore: TestModelWhere = { [Op.not]: { doesNotExist: 5 } }; @@ -752,15 +750,21 @@ describe(getTestDialectTeaser('SQL'), () => { default: 'NOT ([intAttr1] > 5)', }); - testSql.skip({ [Op.not]: where(col('intAttr1'), Op.eq, '5') }, { - default: 'NOT ([intAttr1] = 5)', + testSql({ [Op.not]: where(col('intAttr1'), Op.eq, '5') }, { + default: `NOT ([intAttr1] = '5')`, + mssql: `NOT ([intAttr1] = N'5')`, }); - testSql.skip({ [Op.not]: json('data.key', 10) }, { - default: 'NOT (([data]#>>\'{key}\') = 10)', - }); + if (dialectSupportsJsonOperations()) { + testSql({ [Op.not]: json('data.key', 10) }, { + postgres: `NOT ("data"->'key' = '10')`, + sqlite: `NOT (json_extract(\`data\`,'$.key') = '10')`, + mariadb: `NOT (json_compact(json_extract(\`data\`,'$.key')) = '10')`, + mysql: `NOT (json_extract(\`data\`,'$.key') = CAST('10' AS JSON))`, + }); + } - testSql.skip({ intAttr1: { [Op.not]: { [Op.gt]: 5 } } }, { + testSql({ intAttr1: { [Op.not]: { [Op.gt]: 5 } } }, { default: 'NOT ([intAttr1] > 5)', }); }); @@ -796,13 +800,13 @@ describe(getTestDialectTeaser('SQL'), () => { if (dialectSupportsArray()) { const ignore: TestModelWhere = { intArrayAttr: { [Op.gt]: [1, 2] } }; testSql({ intArrayAttr: { [operator]: [1, 2] } }, { - default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2]::INTEGER[]`, + default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2]`, }); } expectTypeOf({ intAttr1: { [Op.gt]: null } }).not.toMatchTypeOf(); - testSql.skip({ intAttr1: { [operator]: null } }, { - default: new Error(`Op.${operator.description} cannot be used with null`), + testSql({ intAttr1: { [operator]: null } }, { + default: `[intAttr1] ${sqlOperator} NULL`, }); testSequelizeValueMethods(operator, sqlOperator); @@ -846,8 +850,8 @@ describe(getTestDialectTeaser('SQL'), () => { // @ts-expect-error -- must pass exactly 2 items const ignoreWrong2: TestModelWhere = { intAttr1: { [Op.between]: [1] } }; - testSql.skip({ intAttr1: { [operator]: [1] } }, { - default: new Error(`Op.${operator.description} expects an array of exactly 2 items.`), + testSql({ intAttr1: { [operator]: [1] } }, { + default: new Error('Operators Op.between and Op.notBetween must be used with an array of two values, or a literal.'), }); // @ts-expect-error -- must pass exactly 2 items @@ -858,7 +862,7 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intArrayAttr: { [Op.between]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { - default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2]::INTEGER[] AND ARRAY[3,4]::INTEGER[]`, + default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2] AND ARRAY[3,4]`, }); } @@ -891,8 +895,8 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }; - testSql.skip({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { - default: `[intAttr1] ${sqlOperator} "col1" AND "col2"`, + testSql({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { + default: `[intAttr1] ${sqlOperator} [col1] AND [col2]`, }); } @@ -905,8 +909,8 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intAttr1: { [Op.between]: literal('literal1 AND literal2') } }; - testSql.skip({ intAttr1: { [operator]: literal('literal1 AND literal2') } }, { - default: `[intAttr1] ${sqlOperator} BETWEEN literal1 AND literal2`, + testSql({ intAttr1: { [operator]: literal('literal1 AND literal2') } }, { + default: `[intAttr1] ${sqlOperator} literal1 AND literal2`, }); } }); @@ -939,7 +943,7 @@ describe(getTestDialectTeaser('SQL'), () => { // valid const ignore: TestModelWhere = { intArrayAttr: { [Op.in]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { - default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2]::INTEGER[], ARRAY[3,4]::INTEGER[])`, + default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2], ARRAY[3,4])`, }); } @@ -947,7 +951,7 @@ describe(getTestDialectTeaser('SQL'), () => { // @ts-expect-error -- intAttr1 is not an array const ignore: TestModelWhere = { intAttr1: { [Op.in]: [[1, 2], [3, 4]] } }; testSql({ intArrayAttr: { [operator]: [[1, 2], [3, 4]] } }, { - default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2]::INTEGER[], ARRAY[3,4]::INTEGER[])`, + default: `[intArrayAttr] ${sqlOperator} (ARRAY[1,2], ARRAY[3,4])`, }); } } @@ -960,16 +964,16 @@ describe(getTestDialectTeaser('SQL'), () => { { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intAttr1: { [Op.in]: 1 } }; - testSql.skip({ intAttr1: { [operator]: 1 } }, { - default: new Error(`Op.${operator.description} expects an array.`), + testSql({ intAttr1: { [operator]: 1 } }, { + default: new Error('Operators Op.in and Op.notIn must be called with an array of values, or a literal'), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intAttr1: { [Op.in]: col('col2') } }; - testSql.skip({ intAttr1: { [operator]: col('col1') } }, { - default: new Error(`Op.${operator.description} expects an array.`), + testSql({ intAttr1: { [operator]: col('col1') } }, { + default: new Error('Operators Op.in and Op.notIn must be called with an array of values, or a literal'), }); } @@ -996,8 +1000,8 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intAttr1: { [Op.in]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }; - testSql.skip({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { - default: `[intAttr1] ${sqlOperator} ("col1", "col2")`, + testSql({ intAttr1: { [operator]: [{ [Op.col]: 'col1' }, { [Op.col]: 'col2' }] } }, { + default: `[intAttr1] ${sqlOperator} ([col1], [col2])`, }); } @@ -1055,8 +1059,14 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] ${sqlOperator} N'%id'`, }); + // This test checks that the right data type is used to stringify the right operand + testSql({ 'intAttr1::text': { [operator]: '%id' } }, { + default: `CAST([intAttr1] AS TEXT) ${sqlOperator} '%id'`, + mssql: `CAST([intAttr1] AS TEXT) ${sqlOperator} N'%id'`, + }); + testSequelizeValueMethods(operator, sqlOperator); - testSupportsAnyAll(operator, sqlOperator, ['a', 'b', 'c']); + testSupportsAnyAll(operator, sqlOperator, ['a', 'b', 'c'], 'stringAttr'); }); } @@ -1078,7 +1088,7 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intArrayAttr: { [Op.overlap]: [1, 2, 3] } }; testSql({ intArrayAttr: { [operator]: [1, 2, 3] } }, { - default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2,3]::INTEGER[]`, + default: `[intArrayAttr] ${sqlOperator} ARRAY[1,2,3]`, }); } @@ -1089,56 +1099,32 @@ describe(getTestDialectTeaser('SQL'), () => { { // @ts-expect-error -- cannot compare an array with a range! const ignore: TestModelWhere = { intArrayAttr: { [Op.overlap]: [1, { value: 2, inclusive: true }] } }; - testSql.skip({ intArrayAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { - default: new Error('"intArrayAttr" is an array and cannot be compared to a [1, { value: 2, inclusive: true }]'), - }); - } - - { - // @ts-expect-error -- not supported, testing that it throws - const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [col('col')] } }; - testSql.skip({ intArrayAttr: { [operator]: [col('col')] } }, { - default: new Error(`Op.${operator.description} does not support arrays of cols`), + testSql({ intArrayAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { + default: new Error('{ value: 2, inclusive: true } is not a valid integer'), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [col('col')] } }; - testSql.skip({ intArrayAttr: { [operator]: [col('col')] } }, { - default: new Error(`Op.${operator.description} does not support arrays of cols`), + testSql({ intArrayAttr: { [operator]: [col('col')] } }, { + default: new Error(`Col { identifiers: [ 'col' ] } is not a valid integer`), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [{ [Op.col]: 'col' }] } }; - testSql.skip({ intArrayAttr: { [operator]: [{ [Op.col]: 'col' }] } }, { - default: new Error(`Op.${operator.description} does not support arrays of cols`), + testSql({ intArrayAttr: { [operator]: [{ [Op.col]: 'col' }] } }, { + default: new Error(`{ [Symbol(col)]: 'col' } is not a valid integer`), }); } { // @ts-expect-error -- not supported, testing that it throws const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [literal('literal')] } }; - testSql.skip({ intArrayAttr: { [operator]: [literal('literal')] } }, { - default: new Error(`Op.${operator.description} does not support arrays of literals`), - }); - } - - { - // @ts-expect-error -- not supported, testing that it throws - const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [fn('NOW')] } }; - testSql.skip({ intArrayAttr: { [operator]: [fn('NOW')] } }, { - default: new Error(`Op.${operator.description} does not support arrays of fn`), - }); - } - - { - // @ts-expect-error -- not supported, testing that it throws - const ignoreWrong: TestModelWhere = { intArrayAttr: { [Op.overlap]: [cast(col('col'), 'string')] } }; - testSql.skip({ intArrayAttr: { [operator]: [cast(col('col'), 'string')] } }, { - default: new Error(`Op.${operator.description} does not support arrays of cast`), + testSql({ intArrayAttr: { [operator]: [literal('literal')] } }, { + default: new Error(`Literal { val: [ 'literal' ] } is not a valid integer`), }); } }); @@ -1149,7 +1135,7 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2] } }; testSql({ intRangeAttr: { [operator]: [1, 2] } }, { - default: `[intRangeAttr] ${sqlOperator} '[1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '[1,2)'::int4range`, }); } @@ -1157,14 +1143,14 @@ describe(getTestDialectTeaser('SQL'), () => { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, { value: 2, inclusive: true }] } }; testSql({ intRangeAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { // used 'postgres' because otherwise range is transformed to "1,2" - postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'`, + postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.overlap]: [{ value: 1, inclusive: false }, 2] } }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, 2] } }, { - default: `[intRangeAttr] ${sqlOperator} '(1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } @@ -1173,7 +1159,7 @@ describe(getTestDialectTeaser('SQL'), () => { intRangeAttr: { [Op.overlap]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] }, }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] } }, { - default: `[intRangeAttr] ${sqlOperator} '(1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } @@ -1183,7 +1169,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [10, null] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[10,)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[10,)'::int4range`, }); } @@ -1193,7 +1179,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [null, 10] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[,10)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[,10)'::int4range`, }); } @@ -1203,7 +1189,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [null, null] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[,)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[,)'::int4range`, }); } @@ -1217,7 +1203,7 @@ describe(getTestDialectTeaser('SQL'), () => { [operator]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY], }, }, { - postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'`, + postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'::tstzrange`, }); } @@ -1228,15 +1214,15 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ dateRangeAttr: { [operator]: [] }, }, { - postgres: `"dateRangeAttr" ${sqlOperator} 'empty'`, + postgres: `"dateRangeAttr" ${sqlOperator} 'empty'::tstzrange`, }); } { // @ts-expect-error -- 'intRangeAttr' is a range, but right-hand side is a regular Array const ignore: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2, 3] } }; - testSql.skip({ intRangeAttr: { [operator]: [1, 2, 3] } }, { - default: new Error('"intRangeAttr" is a range and cannot be compared to array [1, 2, 3]'), + testSql({ intRangeAttr: { [operator]: [1, 2, 3] } }, { + default: new Error('A range must either be an array with two elements, or an empty array for the empty range. Got [ 1, 2, 3 ].'), }); } @@ -1258,8 +1244,8 @@ describe(getTestDialectTeaser('SQL'), () => { }); // @ts-expect-error -- `ARRAY Op.contains ELEMENT` is not a valid query - testSql.skip({ intArrayAttr: { [Op.contains]: 1 } }, { - default: new Error(`Op.contains doesn't support comparing with a non-array value.`), + testSql({ intArrayAttr: { [Op.contains]: 1 } }, { + default: new Error('1 is not a valid array'), }); }); } @@ -1267,22 +1253,26 @@ describe(getTestDialectTeaser('SQL'), () => { describeOverlapSuite(Op.contained, '<@'); describe('ELEMENT Op.contained RANGE', () => { - testSql.skip({ + if (!dialectSupportsRange()) { + return; + } + + testSql({ intAttr1: { [Op.contained]: [1, 2] }, }, { - postgres: '"intAttr1" <@ \'[1,2)\'::int4range', + postgres: `"intAttr1" <@ '[1,2)'::int4range`, }); - testSql.skip({ + testSql({ bigIntAttr: { [Op.contained]: [1, 2] }, }, { - postgres: '"intAttr1" <@ \'[1,2)\'::int8range', + postgres: `"bigIntAttr" <@ '[1,2)'::int8range`, }); - testSql.skip({ + testSql({ dateAttr: { [Op.contained]: [new Date('2020-01-01T00:00:00Z'), new Date('2021-01-01T00:00:00Z')] }, }, { - postgres: '"intAttr1" <@ \'["2020-01-01 00:00:00.000 +00:00", "2021-01-01 00:00:00.000 +00:00")\'::tstzrange', + postgres: `"dateAttr" <@ '[2020-01-01 00:00:00.000 +00:00,2021-01-01 00:00:00.000 +00:00)'::tstzrange`, }); /* @@ -1295,6 +1285,13 @@ describe(getTestDialectTeaser('SQL'), () => { }); describe('Op.startsWith', () => { + // TODO: use implementation not based on "LIKE" + // mysql, mariadb: locate() + // postgres:, ^@ + // snowflake, ibmi, db2: position() + // mssql: CHARINDEX() + // sqlite: INSTR() + testSql({ stringAttr: { [Op.startsWith]: 'swagger', @@ -1325,22 +1322,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] LIKE N'sql\%injection%' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.startsWith]: literal('swagger'), - }, - }, { - default: `[stringAttr] LIKE 'swagger%'`, - mssql: `[stringAttr] LIKE N'swagger%'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.startsWith]: literal('$bind'), }, @@ -1349,25 +1331,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT($bind, N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.startsWith]: col('username'), }, }, { - default: `[stringAttr] LIKE CONCAT("username", '%')`, - mssql: `[stringAttr] LIKE CONCAT("username", N'%')`, + default: `[stringAttr] LIKE CONCAT([username], '%')`, + mssql: `[stringAttr] LIKE CONCAT([username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.startsWith]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] LIKE CONCAT("username", '%')`, - mssql: `[stringAttr] LIKE CONCAT("username", N'%')`, + default: `[stringAttr] LIKE CONCAT([username], '%')`, + mssql: `[stringAttr] LIKE CONCAT([username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.startsWith]: fn('NOW'), }, @@ -1376,34 +1358,24 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT(NOW(), N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.startsWith]: cast(fn('NOW'), 'string'), }, }, { - default: `[username] LIKE CONCAT(CAST(NOW() AS STRING), '%')`, - mssql: `[username] LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, + default: `[stringAttr] LIKE CONCAT(CAST(NOW() AS STRING), '%')`, + mssql: `[stringAttr] LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPe is '\') // @ts-expect-error -- startsWith is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.startsWith]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.startsWith is not compatible with Op.any'), + testSql({ stringAttr: { [Op.startsWith]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.startsWith]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.startsWith is not compatible with Op.all'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.startsWith]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.startsWith is not compatible with Op.any'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.startsWith]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.startsWith is not compatible with Op.all'), + testSql({ stringAttr: { [Op.startsWith]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); @@ -1438,22 +1410,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] LIKE N'%sql\%injection' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.endsWith]: literal('swagger'), - }, - }, { - default: `[stringAttr] LIKE '%swagger'`, - mssql: `[stringAttr] LIKE N'%swagger'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.endsWith]: literal('$bind'), }, @@ -1462,25 +1419,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT(N'%', $bind)`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.endsWith]: col('username'), }, }, { - default: `[stringAttr] LIKE CONCAT('%', "username")`, - mssql: `[stringAttr] LIKE CONCAT(N'%', "username")`, + default: `[stringAttr] LIKE CONCAT('%', [username])`, + mssql: `[stringAttr] LIKE CONCAT(N'%', [username])`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.endsWith]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] LIKE CONCAT('%', "username")`, - mssql: `[stringAttr] LIKE CONCAT(N'%', "username")`, + default: `[stringAttr] LIKE CONCAT('%', [username])`, + mssql: `[stringAttr] LIKE CONCAT(N'%', [username])`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.endsWith]: fn('NOW'), }, @@ -1489,7 +1446,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT(N'%', NOW())`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.endsWith]: cast(fn('NOW'), 'string'), }, @@ -1500,27 +1457,24 @@ describe(getTestDialectTeaser('SQL'), () => { // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- startsWith is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.endsWith]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.endsWith is not compatible with Op.any'), + testSql({ stringAttr: { [Op.endsWith]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.endsWith]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.endsWith is not compatible with Op.all'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.endsWith]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.endsWith is not compatible with Op.any'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.endsWith]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.endsWith is not compatible with Op.all'), + testSql({ stringAttr: { [Op.endsWith]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); describe('Op.substring', () => { + // TODO: use implementation not based on "LIKE" + // mysql, mariadb: locate() + // postgres:, position() + // snowflake, ibmi, db2: position() + // mssql: CHARINDEX() + // sqlite: INSTR() + testSql({ stringAttr: { [Op.substring]: 'swagger', @@ -1551,22 +1505,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] LIKE N'%sql\%injection%' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.substring]: literal('swagger'), - }, - }, { - default: `[stringAttr] LIKE '%swagger%'`, - mssql: `[stringAttr] LIKE N'%swagger%'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.substring]: literal('$bind'), }, @@ -1575,25 +1514,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT(N'%', $bind, N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.substring]: col('username'), }, }, { - default: `[stringAttr] LIKE CONCAT('%', "username", '%')`, - mssql: `[stringAttr] LIKE CONCAT(N'%', "username", N'%')`, + default: `[stringAttr] LIKE CONCAT('%', [username], '%')`, + mssql: `[stringAttr] LIKE CONCAT(N'%', [username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.substring]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] LIKE CONCAT('%', "username", '%')`, - mssql: `[stringAttr] LIKE CONCAT(N'%', "username", N'%')`, + default: `[stringAttr] LIKE CONCAT('%', [username], '%')`, + mssql: `[stringAttr] LIKE CONCAT(N'%', [username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.substring]: fn('NOW'), }, @@ -1602,7 +1541,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] LIKE CONCAT(N'%', NOW(), N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.substring]: cast(fn('NOW'), 'string'), }, @@ -1613,23 +1552,13 @@ describe(getTestDialectTeaser('SQL'), () => { // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- startsWith is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.substring]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.substring is not compatible with Op.any'), + testSql({ stringAttr: { [Op.substring]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- startsWith is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.substring]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.substring is not compatible with Op.all'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.substring]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.substring is not compatible with Op.any'), - }); - - // @ts-expect-error -- startsWith is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.substring]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.substring is not compatible with Op.all'), + testSql({ stringAttr: { [Op.substring]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); @@ -1664,22 +1593,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] NOT LIKE N'sql\%injection%' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.notStartsWith]: literal('swagger'), - }, - }, { - default: `[stringAttr] NOT LIKE 'swagger%'`, - mssql: `[stringAttr] NOT LIKE N'swagger%'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.notStartsWith]: literal('$bind'), }, @@ -1688,25 +1602,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT($bind, N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notStartsWith]: col('username'), }, }, { - default: `[stringAttr] NOT LIKE CONCAT("username", '%')`, - mssql: `[stringAttr] NOT LIKE CONCAT("username", N'%')`, + default: `[stringAttr] NOT LIKE CONCAT([username], '%')`, + mssql: `[stringAttr] NOT LIKE CONCAT([username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notStartsWith]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] NOT LIKE CONCAT("username", '%')`, - mssql: `[stringAttr] NOT LIKE CONCAT("username", N'%')`, + default: `[stringAttr] NOT LIKE CONCAT([username], '%')`, + mssql: `[stringAttr] NOT LIKE CONCAT([username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notStartsWith]: fn('NOW'), }, @@ -1715,38 +1629,28 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT(NOW(), N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notStartsWith]: cast(fn('NOW'), 'string'), }, }, { - default: `[username] NOT LIKE CONCAT(CAST(NOW() AS STRING), '%')`, - mssql: `[username] NOT LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, + default: `[stringAttr] NOT LIKE CONCAT(CAST(NOW() AS STRING), '%')`, + mssql: `[stringAttr] NOT LIKE CONCAT(CAST(NOW() AS STRING), N'%')`, }); // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPe is '\') // @ts-expect-error -- notStartsWith is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.notStartsWith]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.notStartsWith is not compatible with Op.any'), + testSql({ stringAttr: { [Op.notStartsWith]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notStartsWith is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.notStartsWith]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.notStartsWith is not compatible with Op.all'), - }); - - // @ts-expect-error -- notStartsWith is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.notStartsWith]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notStartsWith is not compatible with Op.any'), - }); - - // @ts-expect-error -- notStartsWith is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.notStartsWith]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notStartsWith is not compatible with Op.all'), + testSql({ stringAttr: { [Op.notStartsWith]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); - describe.skip('Op.notEndsWith', () => { + describe('Op.notEndsWith', () => { testSql({ stringAttr: { [Op.notEndsWith]: 'swagger', @@ -1777,22 +1681,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] NOT LIKE N'%sql\%injection' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.notEndsWith]: literal('swagger'), - }, - }, { - default: `[stringAttr] NOT LIKE '%swagger'`, - mssql: `[stringAttr] NOT LIKE N'%swagger'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.notEndsWith]: literal('$bind'), }, @@ -1801,25 +1690,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT(N'%', $bind)`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notEndsWith]: col('username'), }, }, { - default: `[stringAttr] NOT LIKE CONCAT('%', "username")`, - mssql: `[stringAttr] NOT LIKE CONCAT(N'%', "username")`, + default: `[stringAttr] NOT LIKE CONCAT('%', [username])`, + mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username])`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notEndsWith]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] NOT LIKE CONCAT('%', "username")`, - mssql: `[stringAttr] NOT LIKE CONCAT(N'%', "username")`, + default: `[stringAttr] NOT LIKE CONCAT('%', [username])`, + mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username])`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notEndsWith]: fn('NOW'), }, @@ -1828,7 +1717,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT(N'%', NOW())`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notEndsWith]: cast(fn('NOW'), 'string'), }, @@ -1839,27 +1728,17 @@ describe(getTestDialectTeaser('SQL'), () => { // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- notEndsWith is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.notEndsWith]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.notEndsWith is not compatible with Op.any'), + testSql({ stringAttr: { [Op.notEndsWith]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notEndsWith is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.notEndsWith]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.notEndsWith is not compatible with Op.all'), - }); - - // @ts-expect-error -- notEndsWith is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.notEndsWith]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notEndsWith is not compatible with Op.any'), - }); - - // @ts-expect-error -- notEndsWith is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.notEndsWith]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notEndsWith is not compatible with Op.all'), + testSql({ stringAttr: { [Op.notEndsWith]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); - describe.skip('Op.notSubstring', () => { + describe('Op.notSubstring', () => { testSql({ stringAttr: { [Op.notSubstring]: 'swagger', @@ -1890,22 +1769,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: String.raw`[stringAttr] NOT LIKE N'%sql\%injection%' ESCAPE '\'`, }); - // TODO: remove this test in v7 (breaking change) testSql({ - stringAttr: { - [Op.notSubstring]: literal('swagger'), - }, - }, { - default: `[stringAttr] NOT LIKE '%swagger%'`, - mssql: `[stringAttr] NOT LIKE N'%swagger%'`, - }); - - // TODO: in v7: support `col`, `literal`, and others - // TODO: these would require escaping LIKE values in SQL itself - // output should be something like: - // `LIKE CONCAT(ESCAPE($bind, '%', '\\%'), '%') ESCAPE '\\'` - // with missing special characters. - testSql.skip({ stringAttr: { [Op.notSubstring]: literal('$bind'), }, @@ -1914,25 +1778,25 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT(N'%', $bind, N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notSubstring]: col('username'), }, }, { - default: `[stringAttr] NOT LIKE CONCAT('%', "username", '%')`, - mssql: `[stringAttr] NOT LIKE CONCAT(N'%', "username", N'%')`, + default: `[stringAttr] NOT LIKE CONCAT('%', [username], '%')`, + mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notSubstring]: { [Op.col]: 'username' }, }, }, { - default: `[stringAttr] NOT LIKE CONCAT('%', "username", '%')`, - mssql: `[stringAttr] NOT LIKE CONCAT(N'%', "username", N'%')`, + default: `[stringAttr] NOT LIKE CONCAT('%', [username], '%')`, + mssql: `[stringAttr] NOT LIKE CONCAT(N'%', [username], N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notSubstring]: fn('NOW'), }, @@ -1941,7 +1805,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `[stringAttr] NOT LIKE CONCAT(N'%', NOW(), N'%')`, }); - testSql.skip({ + testSql({ stringAttr: { [Op.notSubstring]: cast(fn('NOW'), 'string'), }, @@ -1952,23 +1816,13 @@ describe(getTestDialectTeaser('SQL'), () => { // these cannot be compatible because it's not possible to provide a ESCAPE clause (although the default ESCAPE is '\') // @ts-expect-error -- notSubstring is not compatible with Op.any - testSql.skip({ stringAttr: { [Op.notSubstring]: { [Op.any]: ['test'] } } }, { - default: new Error('Op.notSubstring is not compatible with Op.any'), + testSql({ stringAttr: { [Op.notSubstring]: { [Op.any]: ['test'] } } }, { + default: new Error(`{ [Symbol(any)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); // @ts-expect-error -- notSubstring is not compatible with Op.all - testSql.skip({ stringAttr: { [Op.notSubstring]: { [Op.all]: ['test'] } } }, { - default: new Error('Op.notSubstring is not compatible with Op.all'), - }); - - // @ts-expect-error -- notSubstring is not compatible with Op.any + Op.values - testSql.skip({ stringAttr: { [Op.notSubstring]: { [Op.any]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notSubstring is not compatible with Op.any'), - }); - - // @ts-expect-error -- notSubstring is not compatible with Op.all + Op.values - testSql.skip({ stringAttr: { [Op.notSubstring]: { [Op.all]: { [Op.values]: ['test'] } } } }, { - default: new Error('Op.notSubstring is not compatible with Op.all'), + testSql({ stringAttr: { [Op.notSubstring]: { [Op.all]: ['test'] } } }, { + default: new Error(`{ [Symbol(all)]: [ 'test' ] } is not a valid string. Only the string type is accepted for non-binary strings.`), }); }); @@ -1996,7 +1850,7 @@ describe(getTestDialectTeaser('SQL'), () => { }); testSequelizeValueMethods(operator, sqlOperator); - testSupportsAnyAll(operator, sqlOperator, ['^a$', '^b$']); + testSupportsAnyAll(operator, sqlOperator, ['^a$', '^b$'], 'stringAttr'); }); } @@ -2017,7 +1871,8 @@ describe(getTestDialectTeaser('SQL'), () => { }); testSequelizeValueMethods(Op.match, '@@'); - testSupportsAnyAll(Op.match, '@@', [fn('to_tsvector', 'a'), fn('to_tsvector', 'b')]); + // TODO + // testSupportsAnyAll(Op.match, '@@', [fn('to_tsvector', 'a'), fn('to_tsvector', 'b')]); }); } @@ -2039,7 +1894,7 @@ describe(getTestDialectTeaser('SQL'), () => { { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [1, 2] } }; testSql({ intRangeAttr: { [operator]: [1, 2] } }, { - default: `[intRangeAttr] ${sqlOperator} '[1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '[1,2)'::int4range`, }); } @@ -2047,14 +1902,14 @@ describe(getTestDialectTeaser('SQL'), () => { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [1, { value: 2, inclusive: true }] } }; testSql({ intRangeAttr: { [operator]: [1, { value: 2, inclusive: true }] } }, { // used 'postgres' because otherwise range is transformed to "1,2" - postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'`, + postgres: `"intRangeAttr" ${sqlOperator} '[1,2]'::int4range`, }); } { const ignoreRight: TestModelWhere = { intRangeAttr: { [Op.adjacent]: [{ value: 1, inclusive: false }, 2] } }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, 2] } }, { - default: `[intRangeAttr] ${sqlOperator} '(1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } @@ -2063,7 +1918,7 @@ describe(getTestDialectTeaser('SQL'), () => { intRangeAttr: { [Op.adjacent]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] }, }; testSql({ intRangeAttr: { [operator]: [{ value: 1, inclusive: false }, { value: 2, inclusive: false }] } }, { - default: `[intRangeAttr] ${sqlOperator} '(1,2)'`, + default: `[intRangeAttr] ${sqlOperator} '(1,2)'::int4range`, }); } @@ -2073,7 +1928,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [10, null] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[10,)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[10,)'::int4range`, }); } @@ -2083,7 +1938,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [null, 10] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[,10)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[,10)'::int4range`, }); } @@ -2093,7 +1948,7 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ intRangeAttr: { [operator]: [null, null] }, }, { - postgres: `"intRangeAttr" ${sqlOperator} '[,)'`, + postgres: `"intRangeAttr" ${sqlOperator} '[,)'::int4range`, }); } @@ -2107,7 +1962,7 @@ describe(getTestDialectTeaser('SQL'), () => { [operator]: [Number.NEGATIVE_INFINITY, Number.POSITIVE_INFINITY], }, }, { - postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'`, + postgres: `"dateRangeAttr" ${sqlOperator} '[-infinity,infinity)'::tstzrange`, }); } @@ -2118,15 +1973,15 @@ describe(getTestDialectTeaser('SQL'), () => { testSql({ dateRangeAttr: { [operator]: [] }, }, { - postgres: `"dateRangeAttr" ${sqlOperator} 'empty'`, + postgres: `"dateRangeAttr" ${sqlOperator} 'empty'::tstzrange`, }); } { // @ts-expect-error -- 'intRangeAttr' is a range, but right-hand side is a regular Array const ignore: TestModelWhere = { intRangeAttr: { [Op.overlap]: [1, 2, 3] } }; - testSql.skip({ intRangeAttr: { [operator]: [1, 2, 3] } }, { - default: new Error('"intRangeAttr" is a range and cannot be compared to array [1, 2, 3]'), + testSql({ intRangeAttr: { [operator]: [1, 2, 3] } }, { + default: new Error('A range must either be an array with two elements, or an empty array for the empty range. Got [ 1, 2, 3 ].'), }); } }); @@ -2150,110 +2005,202 @@ describe(getTestDialectTeaser('SQL'), () => { const ignore: TestModelWhere = { '$doesNotExist$.nested': 'value' }; } + testSql({ jsonAttr: 'value' }, { + default: `[jsonAttr] = '"value"'`, + mysql: `\`jsonAttr\` = CAST('"value"' AS JSON)`, + }); + + testSql({ 'jsonAttr.nested': 'value' }, { + postgres: `"jsonAttr"->'nested' = '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, + }); + + testSql(where('value', Op.eq, attribute('jsonAttr.nested')), { + postgres: `'"value"' = "jsonAttr"->'nested'`, + sqlite: `'"value"' = json_extract(\`jsonAttr\`,'$.nested')`, + mariadb: `'"value"' = json_compact(json_extract(\`jsonAttr\`,'$.nested'))`, + mysql: `CAST('"value"' AS JSON) = json_extract(\`jsonAttr\`,'$.nested')`, + }); + + testSql({ 'jsonAttr.nested.twice': 'value' }, { + postgres: `"jsonAttr"#>ARRAY['nested','twice'] = '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested.twice') = '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.twice')) = '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested.twice') = CAST('"value"' AS JSON)`, + }); + testSql({ - 'jsonAttr.nested': { - attribute: 'value', - }, + jsonAttr: { nested: 'value' }, }, { - mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$.nested.attribute')) = 'value'`, - mysql: `json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\".\\"attribute\\"')) = 'value'`, - postgres: `("jsonAttr"#>>'{nested,attribute}') = 'value'`, - sqlite: `json_extract(\`jsonAttr\`,'$.nested.attribute') = 'value'`, + postgres: `"jsonAttr"->'nested' = '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); - testSql.skip({ - '$jsonAttr$.nested': { - [Op.eq]: 'value', - }, + testSql({ + 'jsonAttr.nested': { twice: 'value' }, }, { - mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$.nested')) = 'value'`, - mysql: `json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\"')) = 'value'`, - postgres: `("jsonAttr"#>>'{nested}') = 'value'`, - sqlite: `json_extract(\`jsonAttr\`,'$.nested') = 'value'`, + postgres: `"jsonAttr"#>ARRAY['nested','twice'] = '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested.twice') = '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.twice')) = '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested.twice') = CAST('"value"' AS JSON)`, }); - testSql.skip({ - '$jsonAttr$.nested': { - attribute: 'value', - }, + testSql({ + jsonAttr: { [Op.eq]: { key: 'value' } }, }, { - mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$.nested.attribute')) = 'value'`, - mysql: `json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\".\\"attribute\\"')) = 'value'`, - postgres: `("jsonAttr"#>>'{nested,attribute}') = 'value'`, - sqlite: `json_extract(\`jsonAttr\`,'$.nested.attribute') = 'value'`, + default: `[jsonAttr] = '{"key":"value"}'`, + mysql: `\`jsonAttr\` = CAST('{"key":"value"}' AS JSON)`, }); - testSql.skip({ - '$association.jsonAttr$.nested': { - attribute: 'value', - }, + testSql({ + 'jsonAttr.nested': { [Op.ne]: 'value' }, }, { - mariadb: `json_unquote(json_extract(\`association\`.\`jsonAttr\`,'$.nested.attribute')) = 'value'`, - mysql: `json_unquote(json_extract(\`association\`.\`jsonAttr\`,'$.\\"nested\\".\\"attribute\\"')) = 'value'`, - postgres: `("association"."jsonAttr"#>>'{nested,attribute}') = 'value'`, - sqlite: `json_extract(\`association\`.\`jsonAttr\`,'$.nested.attribute') = 'value'`, + postgres: `"jsonAttr"->'nested' != '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested') != '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) != '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested') != CAST('"value"' AS JSON)`, }); testSql({ - 'jsonAttr.nested::STRING': 'value', + '$jsonAttr$.nested': 'value', }, { - mariadb: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, - mysql: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\"')) AS STRING) = 'value'`, - postgres: `CAST(("jsonAttr"#>>'{nested}') AS STRING) = 'value'`, - sqlite: `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, + postgres: `"jsonAttr"->'nested' = '"value"'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested') = '"value"'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested')) = '"value"'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); - testSql.skip({ - '$jsonAttr$.nested::STRING': 'value', + testSql({ + '$association.jsonAttr$.nested': 'value', }, { - mariadb: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, - mysql: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\"')) AS STRING) = 'value'`, - postgres: `CAST(("jsonAttr"#>>'{nested}') AS STRING) = 'value'`, - sqlite: `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, + postgres: `"association"."jsonAttr"->'nested' = '"value"'`, + sqlite: `json_extract(\`association\`.\`jsonAttr\`,'$.nested') = '"value"'`, + mariadb: `json_compact(json_extract(\`association\`.\`jsonAttr\`,'$.nested')) = '"value"'`, + mysql: `json_extract(\`association\`.\`jsonAttr\`,'$.nested') = CAST('"value"' AS JSON)`, }); - testSql.skip({ + testSql({ + 'jsonAttr.nested::STRING': 'value', + }, { + // with the left value cast to a string, we serialize the right value as a string, not as a JSON value + postgres: `CAST("jsonAttr"->'nested' AS STRING) = 'value'`, + mariadb: `CAST(json_compact(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, + 'sqlite mysql': `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, + }); + + testSql({ '$association.jsonAttr$.nested::STRING': { attribute: 'value', }, - }, { - mariadb: `CAST(json_unquote(json_extract(\`association\`.\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, - mysql: `CAST(json_unquote(json_extract(\`association\`.\`jsonAttr\`,'$.\\"nested\\"')) AS STRING) = 'value'`, - postgres: `CAST(("association"."jsonAttr"#>>'{nested}') AS STRING) = 'value'`, - sqlite: `CAST(json_extract(\`association\`.\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, - }); + }, { default: new Error(`Could not guess type of value { attribute: 'value' }`) }); - testSql.skip({ - $jsonAttr$: { nested: 'value' }, + testSql({ + '$association.jsonAttr$.nested.deep::STRING': 'value', }, { - mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$.nested.attribute')) = 'value'`, - mysql: `json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\".\\"attribute\\"')) = 'value'`, - postgres: `("jsonAttr"#>>'{nested,attribute}') = 'value'`, - sqlite: `json_extract(\`jsonAttr\`,'$.nested.attribute') = 'value'`, + postgres: `CAST("association"."jsonAttr"#>ARRAY['nested','deep'] AS STRING) = 'value'`, + mariadb: `CAST(json_compact(json_extract(\`association\`.\`jsonAttr\`,'$.nested.deep')) AS STRING) = 'value'`, + 'sqlite mysql': `CAST(json_extract(\`association\`.\`jsonAttr\`,'$.nested.deep') AS STRING) = 'value'`, }); - testSql.skip({ + testSql({ $jsonAttr$: { 'nested::string': 'value' }, }, { - mariadb: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, - mysql: `CAST(json_unquote(json_extract(\`jsonAttr\`,'$.\\"nested\\"')) AS STRING) = 'value'`, - postgres: `CAST(("jsonAttr"#>>'{nested}') AS STRING) = 'value'`, - sqlite: `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, + postgres: `CAST("jsonAttr"->'nested' AS STRING) = 'value'`, + mariadb: `CAST(json_compact(json_extract(\`jsonAttr\`,'$.nested')) AS STRING) = 'value'`, + 'sqlite mysql': `CAST(json_extract(\`jsonAttr\`,'$.nested') AS STRING) = 'value'`, }); testSql({ 'jsonAttr.nested.attribute': 4 }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonAttr`,\'$.nested.attribute\')) AS DECIMAL) = 4', - mysql: 'CAST(json_unquote(json_extract(`jsonAttr`,\'$.\\"nested\\".\\"attribute\\"\')) AS DECIMAL) = 4', - postgres: 'CAST(("jsonAttr"#>>\'{nested,attribute}\') AS DOUBLE PRECISION) = 4', - sqlite: 'CAST(json_extract(`jsonAttr`,\'$.nested.attribute\') AS DOUBLE PRECISION) = 4', + postgres: `"jsonAttr"#>ARRAY['nested','attribute'] = '4'`, + sqlite: `json_extract(\`jsonAttr\`,'$.nested.attribute') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$.nested.attribute')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$.nested.attribute') = CAST('4' AS JSON)`, }); - // aliases correctly - testSql.skip({ 'aliasedJsonAttr.nested.attribute': 4 }, { - mariadb: 'CAST(json_unquote(json_extract(`aliased_json`,\'$.nested.attribute\')) AS DECIMAL) = 4', - mysql: 'CAST(json_unquote(json_extract(`aliased_json`,\'$.\\"nested\\".\\"attribute\\"\')) AS DECIMAL) = 4', - postgres: 'CAST(("aliased_json"#>>\'{nested,attribute}\') AS DOUBLE PRECISION) = 4', - sqlite: 'CAST(json_extract(`aliased_json`,\'$.nested.attribute\') AS DOUBLE PRECISION) = 4', + // 0 is treated as a string key here, not an array index + testSql({ 'jsonAttr.0': 4 }, { + postgres: `"jsonAttr"->'0' = '4'`, + sqlite: `json_extract(\`jsonAttr\`,'$."0"') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."0"')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$."0"') = CAST('4' AS JSON)`, + }); + + // 0 is treated as an index here, not a string key + testSql({ 'jsonAttr[0]': 4 }, { + postgres: `"jsonAttr"->0 = '4'`, + + // these tests cannot be deduplicated because [0] will be replaced by `0` by expectsql + sqlite: `json_extract(\`jsonAttr\`,'$[0]') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$[0]')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$[0]') = CAST('4' AS JSON)`, + }); + + testSql({ 'jsonAttr.0.attribute': 4 }, { + postgres: `"jsonAttr"#>ARRAY['0','attribute'] = '4'`, + sqlite: `json_extract(\`jsonAttr\`,'$."0".attribute') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."0".attribute')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$."0".attribute') = CAST('4' AS JSON)`, + }); + + // Regression test: https://github.com/sequelize/sequelize/issues/8718 + testSql({ jsonAttr: { 'hyphenated-key': 4 } }, { + postgres: `"jsonAttr"->'hyphenated-key' = '4'`, + sqlite: `json_extract(\`jsonAttr\`,'$."hyphenated-key"') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."hyphenated-key"')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$."hyphenated-key"') = CAST('4' AS JSON)`, + }); + + // SQL injection test + testSql({ jsonAttr: { '"a\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "': 1 } }, { + postgres: `"jsonAttr"->'a'')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- ' = '1'`, + mysql: `json_extract(\`jsonAttr\`,'$."a\\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "') = CAST('1' AS JSON)`, + sqlite: `json_extract(\`jsonAttr\`,'$."a'')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "') = '1'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$."a\\')) AS DECIMAL) = 1 DELETE YOLO INJECTIONS; -- "')) = '1'`, + }); + + testSql({ 'jsonAttr[0].nested.attribute': 4 }, { + postgres: `"jsonAttr"#>ARRAY['0','nested','attribute'] = '4'`, + + // these tests cannot be deduplicated because [0] will be replaced by `0` by expectsql + sqlite: `json_extract(\`jsonAttr\`,'$[0].nested.attribute') = '4'`, + mariadb: `json_compact(json_extract(\`jsonAttr\`,'$[0].nested.attribute')) = '4'`, + mysql: `json_extract(\`jsonAttr\`,'$[0].nested.attribute') = CAST('4' AS JSON)`, + }); + + // aliases attribute -> column correctly + testSql({ 'aliasedJsonAttr.nested.attribute': 4 }, { + postgres: `"aliased_json"#>ARRAY['nested','attribute'] = '4'`, + sqlite: `json_extract(\`aliased_json\`,'$.nested.attribute') = '4'`, + mariadb: `json_compact(json_extract(\`aliased_json\`,'$.nested.attribute')) = '4'`, + mysql: `json_extract(\`aliased_json\`,'$.nested.attribute') = CAST('4' AS JSON)`, + }); + + testSql({ 'jsonAttr:unquote': 0 }, { + postgres: `"jsonAttr"#>>ARRAY[]::TEXT[] = 0`, + 'sqlite mysql mariadb': `json_unquote([jsonAttr]) = 0`, + }); + + testSql({ 'jsonAttr.key:unquote': 0 }, { + postgres: `"jsonAttr"->>'key' = 0`, + 'sqlite mysql mariadb': `json_unquote(json_extract([jsonAttr],'$.key')) = 0`, + }); + + testSql({ 'jsonAttr.nested.key:unquote': 0 }, { + postgres: `"jsonAttr"#>>ARRAY['nested','key'] = 0`, + 'sqlite mysql mariadb': `json_unquote(json_extract([jsonAttr],'$.nested.key')) = 0`, + }); + + testSql({ 'jsonAttr[0]:unquote': 0 }, { + postgres: `"jsonAttr"->>0 = 0`, + + // must be separate because [0] will be replaced by `0` by expectsql + sqlite: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, + mysql: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, + mariadb: `json_unquote(json_extract(\`jsonAttr\`,'$[0]')) = 0`, }); }); } @@ -2265,7 +2212,7 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.anyKeyExists]: ['a', 'b'], }, }, { - default: `[jsonbAttr] ?| ARRAY['a', 'b']`, + default: `[jsonbAttr] ?| ARRAY['a','b']`, }); testSql({ @@ -2273,7 +2220,7 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.allKeysExist]: ['a', 'b'], }, }, { - default: `[jsonbAttr] ?& ARRAY['a', 'b']`, + default: `[jsonbAttr] ?& ARRAY['a','b']`, }); testSql({ @@ -2292,22 +2239,6 @@ describe(getTestDialectTeaser('SQL'), () => { default: `[jsonbAttr] ?& ARRAY(SELECT jsonb_array_elements_text('ARRAY["a","b"]'))`, }); - testSql({ - jsonbAttr: { - [Op.anyKeyExists]: [literal(`"gamer"`)], - }, - }, { - default: `[jsonbAttr] ?| ARRAY["gamer"]`, - }); - - testSql({ - jsonbAttr: { - [Op.allKeysExist]: [literal(`"gamer"`)], - }, - }, { - default: `[jsonbAttr] ?& ARRAY["gamer"]`, - }); - testSql({ jsonbAttr: { [Op.anyKeyExists]: col('label'), @@ -2345,7 +2276,7 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.anyKeyExists]: [], }, }, { - default: `[jsonbAttr] ?| ARRAY[]::text[]`, + default: `[jsonbAttr] ?| ARRAY[]::TEXT[]`, }); testSql({ @@ -2353,7 +2284,7 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.allKeysExist]: [], }, }, { - default: `[jsonbAttr] ?& ARRAY[]::text[]`, + default: `[jsonbAttr] ?& ARRAY[]::TEXT[]`, }); testSql({ @@ -2374,32 +2305,20 @@ describe(getTestDialectTeaser('SQL'), () => { // @ts-expect-error -- typings for `json` are broken, but `json()` is deprecated testSql({ id: { [Op.eq]: json('profile.id') } }, { - default: '"id" = ("profile"#>>\'{id}\')', + default: `"id" = "profile"->'id'`, }); // @ts-expect-error -- typings for `json` are broken, but `json()` is deprecated testSql(json('profile.id', cast('12346-78912', 'text')), { - postgres: '("profile"#>>\'{id}\') = CAST(\'12346-78912\' AS TEXT)', - sqlite: 'json_extract(`profile`,\'$.id\') = CAST(\'12346-78912\' AS TEXT)', - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\')) = CAST(\'12346-78912\' AS CHAR)', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\')) = CAST(\'12346-78912\' AS CHAR)', + postgres: `"User"."profile"->'id' = CAST('12346-78912' AS TEXT)`, }, { - field: { - type: new DataTypes.JSONB(), - }, - prefix: 'User', + mainAlias: 'User', }); testSql(json({ profile: { id: '12346-78912', name: 'test' } }), { - postgres: '("profile"#>>\'{id}\') = \'12346-78912\' AND ("profile"#>>\'{name}\') = \'test\'', - sqlite: 'json_extract(`profile`,\'$.id\') = \'12346-78912\' AND json_extract(`profile`,\'$.name\') = \'test\'', - mariadb: 'json_unquote(json_extract(`profile`,\'$.id\')) = \'12346-78912\' AND json_unquote(json_extract(`profile`,\'$.name\')) = \'test\'', - mysql: 'json_unquote(json_extract(`profile`,\'$.\\"id\\"\')) = \'12346-78912\' AND json_unquote(json_extract(`profile`,\'$.\\"name\\"\')) = \'test\'', + postgres: `"User"."profile"->'id' = '"12346-78912"' AND "User"."profile"->'name' = '"test"'`, }, { - field: { - type: new DataTypes.JSONB(), - }, - prefix: 'User', + mainAlias: 'User', }); testSql({ @@ -2409,12 +2328,9 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - mariadb: 'json_unquote(json_extract(`User`.`jsonbAttr`,\'$.nested.attribute\')) = \'value\'', - mysql: 'json_unquote(json_extract(`User`.`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) = \'value\'', - postgres: '("User"."jsonbAttr"#>>\'{nested,attribute}\') = \'value\'', - sqlite: 'json_extract(`User`.`jsonbAttr`,\'$.nested.attribute\') = \'value\'', + postgres: `"User"."jsonbAttr"#>ARRAY['nested','attribute'] = '"value"'`, }, { - prefix: 'User', + mainAlias: 'User', }); testSql({ @@ -2424,59 +2340,25 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.nested\')) AS DECIMAL) IN (1, 2)', - mysql: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\"\')) AS DECIMAL) IN (1, 2)', - postgres: 'CAST(("jsonbAttr"#>>\'{nested}\') AS DOUBLE PRECISION) IN (1, 2)', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested\') AS DOUBLE PRECISION) IN (1, 2)', + postgres: `"jsonbAttr"->'nested' IN ('1', '2')`, }); testSql({ - jsonbAttr: { - nested: { - [Op.between]: [1, 2], - }, + 'jsonbAttr.nested.attribute': { + [Op.in]: [3, 7], }, }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.nested\')) AS DECIMAL) BETWEEN 1 AND 2', - mysql: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\"\')) AS DECIMAL) BETWEEN 1 AND 2', - postgres: 'CAST(("jsonbAttr"#>>\'{nested}\') AS DOUBLE PRECISION) BETWEEN 1 AND 2', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested\') AS DOUBLE PRECISION) BETWEEN 1 AND 2', + postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] IN ('3', '7')`, }); testSql({ jsonbAttr: { nested: { - attribute: 'value', - prop: { - [Op.ne]: 'None', - }, - }, - }, - }, { - mariadb: '(json_unquote(json_extract(`User`.`jsonbAttr`,\'$.nested.attribute\')) = \'value\' AND json_unquote(json_extract(`User`.`jsonbAttr`,\'$.nested.prop\')) != \'None\')', - mysql: '(json_unquote(json_extract(`User`.`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) = \'value\' AND json_unquote(json_extract(`User`.`jsonbAttr`,\'$.\\"nested\\".\\"prop\\"\')) != \'None\')', - postgres: '(("User"."jsonbAttr"#>>\'{nested,attribute}\') = \'value\' AND ("User"."jsonbAttr"#>>\'{nested,prop}\') != \'None\')', - sqlite: '(json_extract(`User`.`jsonbAttr`,\'$.nested.attribute\') = \'value\' AND json_extract(`User`.`jsonbAttr`,\'$.nested.prop\') != \'None\')', - }, { - prefix: literal(sql.quoteTable.call(sequelize.dialect.queryGenerator, { tableName: 'User' })), - }); - - testSql({ - jsonbAttr: { - name: { - last: 'Simpson', - }, - employment: { - [Op.ne]: 'None', + [Op.between]: [1, 2], }, }, }, { - mariadb: '(json_unquote(json_extract(`User`.`jsonbAttr`,\'$.name.last\')) = \'Simpson\' AND json_unquote(json_extract(`User`.`jsonbAttr`,\'$.employment\')) != \'None\')', - mysql: '(json_unquote(json_extract(`User`.`jsonbAttr`,\'$.\\"name\\".\\"last\\"\')) = \'Simpson\' AND json_unquote(json_extract(`User`.`jsonbAttr`,\'$.\\"employment\\"\')) != \'None\')', - postgres: '(("User"."jsonbAttr"#>>\'{name,last}\') = \'Simpson\' AND ("User"."jsonbAttr"#>>\'{employment}\') != \'None\')', - sqlite: '(json_extract(`User`.`jsonbAttr`,\'$.name.last\') = \'Simpson\' AND json_extract(`User`.`jsonbAttr`,\'$.employment\') != \'None\')', - }, { - prefix: 'User', + postgres: `"jsonbAttr"->'nested' BETWEEN '1' AND '2'`, }); testSql({ @@ -2485,54 +2367,26 @@ describe(getTestDialectTeaser('SQL'), () => { name: 'Product', }, }, { - mariadb: '(CAST(json_unquote(json_extract(`jsonbAttr`,\'$.price\')) AS DECIMAL) = 5 AND json_unquote(json_extract(`jsonbAttr`,\'$.name\')) = \'Product\')', - mysql: '(CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"price\\"\')) AS DECIMAL) = 5 AND json_unquote(json_extract(`jsonbAttr`,\'$.\\"name\\"\')) = \'Product\')', - postgres: '(CAST(("jsonbAttr"#>>\'{price}\') AS DOUBLE PRECISION) = 5 AND ("jsonbAttr"#>>\'{name}\') = \'Product\')', - sqlite: '(CAST(json_extract(`jsonbAttr`,\'$.price\') AS DOUBLE PRECISION) = 5 AND json_extract(`jsonbAttr`,\'$.name\') = \'Product\')', - }); - - testSql({ - 'jsonbAttr.nested.attribute': { - [Op.in]: [3, 7], - }, - }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.nested.attribute\')) AS DECIMAL) IN (3, 7)', - mysql: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) AS DECIMAL) IN (3, 7)', - postgres: 'CAST(("jsonbAttr"#>>\'{nested,attribute}\') AS DOUBLE PRECISION) IN (3, 7)', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested.attribute\') AS DOUBLE PRECISION) IN (3, 7)', + postgres: `"jsonbAttr"->'price' = '5' AND "jsonbAttr"->'name' = '"Product"'`, }); testSql({ jsonbAttr: { - nested: { - attribute: { - [Op.gt]: 2, - }, + name: { + last: 'Simpson', }, - }, - }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.nested.attribute\')) AS DECIMAL) > 2', - mysql: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) AS DECIMAL) > 2', - postgres: 'CAST(("jsonbAttr"#>>\'{nested,attribute}\') AS DOUBLE PRECISION) > 2', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested.attribute\') AS DOUBLE PRECISION) > 2', - }); - - testSql({ - jsonbAttr: { - nested: { - 'attribute::integer': { - [Op.gt]: 2, - }, + employment: { + [Op.ne]: 'None', }, }, }, { - mariadb: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.nested.attribute\')) AS DECIMAL) > 2', - mysql: 'CAST(json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) AS DECIMAL) > 2', - postgres: 'CAST(("jsonbAttr"#>>\'{nested,attribute}\') AS INTEGER) > 2', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested.attribute\') AS INTEGER) > 2', + postgres: `"User"."jsonbAttr"#>ARRAY['name','last'] = '"Simpson"' AND "User"."jsonbAttr"->'employment' != '"None"'`, + }, { + mainAlias: 'User', }); const dt = new Date(); + const jsonDt = JSON.stringify(dt); testSql({ jsonbAttr: { nested: { @@ -2542,10 +2396,7 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - mariadb: `CAST(json_unquote(json_extract(\`jsonbAttr\`,'$.nested.attribute')) AS DATETIME) > ${sql.escape(dt)}`, - mysql: `CAST(json_unquote(json_extract(\`jsonbAttr\`,'$.\\"nested\\".\\"attribute\\"')) AS DATETIME) > ${sql.escape(dt)}`, - postgres: `CAST(("jsonbAttr"#>>'{nested,attribute}') AS TIMESTAMPTZ) > ${sql.escape(dt)}`, - sqlite: `json_extract(\`jsonbAttr\`,'$.nested.attribute') > ${sql.escape(dt.toISOString())}`, + postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] > ${queryGen.escape(jsonDt)}`, }); testSql({ @@ -2555,17 +2406,7 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - mariadb: 'json_unquote(json_extract(`jsonbAttr`,\'$.nested.attribute\')) = \'true\'', - mysql: 'json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) = \'true\'', - postgres: 'CAST(("jsonbAttr"#>>\'{nested,attribute}\') AS BOOLEAN) = true', - sqlite: 'CAST(json_extract(`jsonbAttr`,\'$.nested.attribute\') AS BOOLEAN) = 1', - }); - - testSql({ 'jsonbAttr.nested.attribute': 'value' }, { - mariadb: 'json_unquote(json_extract(`jsonbAttr`,\'$.nested.attribute\')) = \'value\'', - mysql: 'json_unquote(json_extract(`jsonbAttr`,\'$.\\"nested\\".\\"attribute\\"\')) = \'value\'', - postgres: '("jsonbAttr"#>>\'{nested,attribute}\') = \'value\'', - sqlite: 'json_extract(`jsonbAttr`,\'$.nested.attribute\') = \'value\'', + postgres: `"jsonbAttr"#>ARRAY['nested','attribute'] = 'true'`, }); testSql({ @@ -2573,16 +2414,13 @@ describe(getTestDialectTeaser('SQL'), () => { [Op.contains]: { company: 'Magnafone' }, }, }, { - default: '[jsonbAttr] @> \'{"company":"Magnafone"}\'', + default: `[jsonbAttr] @> '{"company":"Magnafone"}'`, }); // aliases correctly - testSql.skip({ aliasedJsonbAttr: { key: 'value' } }, { - mariadb: 'json_unquote(json_extract(`aliased_jsonb`,\'$.key\')) = \'value\'', - mysql: 'json_unquote(json_extract(`aliased_jsonb`,\'$.\\"key\\"\')) = \'value\'', - postgres: '("aliased_jsonb"#>>\'{key}\') = \'value\'', - sqlite: 'json_extract(`aliased_jsonb`,\'$.key\') = \'value\'', + testSql({ aliasedJsonbAttr: { key: 'value' } }, { + postgres: `"aliased_jsonb"->'key' = '"value"'`, }); }); } @@ -2603,6 +2441,14 @@ describe(getTestDialectTeaser('SQL'), () => { expect(util.inspect(and('a', 'b'))).to.deep.equal(util.inspect({ [Op.and]: ['a', 'b'] })); }); + testSql(and([]), { + default: '', + }); + + testSql(and({}), { + default: '', + }); + // by default: it already is Op.and testSql({ intAttr1: 1, intAttr2: 2 }, { default: `[intAttr1] = 1 AND [intAttr2] = 2`, @@ -2610,7 +2456,7 @@ describe(getTestDialectTeaser('SQL'), () => { // top-level array is Op.and testSql([{ intAttr1: 1 }, { intAttr1: 2 }], { - default: `([intAttr1] = 1 AND [intAttr1] = 2)`, + default: `[intAttr1] = 1 AND [intAttr1] = 2`, }); // $intAttr1$ doesn't override intAttr1 @@ -2620,23 +2466,23 @@ describe(getTestDialectTeaser('SQL'), () => { // can pass a simple object testSql({ [Op.and]: { intAttr1: 1, intAttr2: 2 } }, { - default: `([intAttr1] = 1 AND [intAttr2] = 2)`, + default: `[intAttr1] = 1 AND [intAttr2] = 2`, }); // can pass an array testSql({ [Op.and]: [{ intAttr1: 1, intAttr2: 2 }, { stringAttr: '' }] }, { - default: `(([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = '')`, - mssql: `(([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = N'')`, + default: `([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = ''`, + mssql: `([intAttr1] = 1 AND [intAttr2] = 2) AND [stringAttr] = N''`, }); // can be used on attribute testSql({ intAttr1: { [Op.and]: [1, { [Op.gt]: 1 }] } }, { - default: `([intAttr1] = 1 AND [intAttr1] > 1)`, + default: `[intAttr1] = 1 AND [intAttr1] > 1`, }); // @ts-expect-error -- cannot be used after operator - testSql.skip({ intAttr1: { [Op.gt]: { [Op.and]: [1, 2] } } }, { - default: new Error('Op.and cannot be used inside Op.gt'), + testSql({ intAttr1: { [Op.gt]: { [Op.and]: [1, 2] } } }, { + default: new Error(`{ [Symbol(and)]: [ 1, 2 ] } is not a valid integer`), }); }); @@ -2646,32 +2492,32 @@ describe(getTestDialectTeaser('SQL'), () => { }); testSql(or([]), { - default: '0 = 1', + default: '', }); testSql(or({}), { - default: '0 = 1', + default: '', }); // can pass a simple object testSql({ [Op.or]: { intAttr1: 1, intAttr2: 2 } }, { - default: `([intAttr1] = 1 OR [intAttr2] = 2)`, + default: `[intAttr1] = 1 OR [intAttr2] = 2`, }); // can pass an array testSql({ [Op.or]: [{ intAttr1: 1, intAttr2: 2 }, { stringAttr: '' }] }, { - default: `(([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = '')`, - mssql: `(([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = N'')`, + default: `([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = ''`, + mssql: `([intAttr1] = 1 AND [intAttr2] = 2) OR [stringAttr] = N''`, }); // can be used on attribute testSql({ intAttr1: { [Op.or]: [1, { [Op.gt]: 1 }] } }, { - default: `([intAttr1] = 1 OR [intAttr1] > 1)`, + default: `[intAttr1] = 1 OR [intAttr1] > 1`, }); // @ts-expect-error -- cannot be used after operator - testSql.skip({ intAttr1: { [Op.gt]: { [Op.or]: [1, 2] } } }, { - default: new Error('Op.or cannot be used inside Op.gt'), + testSql({ intAttr1: { [Op.gt]: { [Op.or]: [1, 2] } } }, { + default: new Error(`{ [Symbol(or)]: [ 1, 2 ] } is not a valid integer`), }); testSql({ @@ -2682,7 +2528,7 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - default: '([intAttr1] IN (1, 3) OR [intAttr2] IN (2, 4))', + default: '[intAttr1] IN (1, 3) OR [intAttr2] IN (2, 4)', }); }); @@ -2702,7 +2548,7 @@ describe(getTestDialectTeaser('SQL'), () => { { intAttr1: 3 }, ], }, { - default: '((([intAttr1] = 1 AND [intAttr1] = 2)) OR [intAttr1] = 3)', + default: '([intAttr1] = 1 AND [intAttr1] = 2) OR [intAttr1] = 3', }); // can be nested *after* attribute @@ -2716,7 +2562,7 @@ describe(getTestDialectTeaser('SQL'), () => { ], }, }, { - default: '([intAttr1] = 1 AND [intAttr1] = 2 AND ([intAttr1] = 3 OR [intAttr1] = 4) AND [intAttr1] != 5 AND [intAttr1] IN (6, 7))', + default: '[intAttr1] = 1 AND [intAttr1] = 2 AND ([intAttr1] = 3 OR [intAttr1] = 4) AND NOT ([intAttr1] = 5) AND [intAttr1] IN (6, 7)', }); // can be nested @@ -2732,7 +2578,7 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - default: 'NOT (((([intAttr1] = 1 AND [intAttr2] = 2))))', + default: 'NOT ([intAttr1] = 1 AND [intAttr2] = 2)', }); // Op.not, Op.and, Op.or can reside on the same object as attributes @@ -2752,7 +2598,7 @@ describe(getTestDialectTeaser('SQL'), () => { }, }, }, { - default: 'NOT (((([intAttr1] = 5 AND [intAttr2] = 6) OR [intAttr1] = 4) AND [intAttr1] = 3) AND [intAttr1] = 2) AND [intAttr1] = 1', + default: '(NOT (((([intAttr1] = 5 AND [intAttr2] = 6) OR [intAttr1] = 4) AND [intAttr1] = 3) AND [intAttr1] = 2)) AND [intAttr1] = 1', }); }); @@ -2762,7 +2608,11 @@ describe(getTestDialectTeaser('SQL'), () => { const ignore: TestModelWhere = { intAttr1: where(fn('lower', col('name')), null) }; } - testSql.skip({ booleanAttr: where(fn('lower', col('name')), null) }, { + testSql({ booleanAttr: where(fn('lower', col('name')), null) }, { + default: `[booleanAttr] = (lower([name]) IS NULL)`, + }); + + testSql({ booleanAttr: where(fn('lower', col('name')), null) }, { default: `[booleanAttr] = (lower([name]) IS NULL)`, }); @@ -2780,51 +2630,49 @@ describe(getTestDialectTeaser('SQL'), () => { // some dialects support having a filter inside aggregate functions, but require casting: // https://github.com/sequelize/sequelize/issues/6666 testSql(where(fn('sum', cast({ id: 1 }, 'int')), Op.eq, 1), { - default: 'sum(CAST([id] = 1 AS INT)) = 1', + default: 'sum(CAST(([id] = 1) AS INT)) = 1', }); // comparing the output of `where` to `where` - testSql.skip( + testSql( where( where(col('col'), Op.eq, '1'), Op.eq, where(col('col'), Op.eq, '2'), ), { - default: '([col] = 1) = ([col] = 2)', + default: `([col] = '1') = ([col] = '2')`, + mssql: `([col] = N'1') = ([col] = N'2')`, + }, + ); + + testSql( + where(1, Op.eq, 2), + { + default: '1 = 2', }, ); - // TODO: v7 - // comparing literals - // testSql( - // // @ts-expect-error -- not yet supported - // where(1, Op.eq, 2), - // { - // default: '1 = 2', - // }, - // ); - - // testSql.skip(where(1, Op.eq, col('col')), { - // default: '1 = [col]', - // }); - // - // testSql.skip(where('string', Op.eq, col('col')), { - // default: `'string' = [col]`, - // }); - - testSql.skip( - // @ts-expect-error -- not yet supported + testSql(where(1, Op.eq, col('col')), { + default: '1 = [col]', + }); + + testSql(where('string', Op.eq, col('col')), { + default: `'string' = [col]`, + mssql: `N'string' = [col]`, + }); + + testSql( where('a', Op.eq, 'b'), { - default: `N'a' = N'b'`, + default: `'a' = 'b'`, + mssql: `N'a' = N'b'`, }, ); - // TODO: remove support for string operators. - // They're inconsistent. It's better to use a literal or a supported Operator. - testSql(where(fn('SUM', col('hours')), '>', 0), { - default: 'SUM([hours]) > 0', + it('does not allow string operators', () => { + // @ts-expect-error -- testing that this errors + expect(() => where(fn('SUM', col('hours')), '>', 0)).to.throw('where(left, operator, right) does not accept a string as the operator'); }); testSql(where(fn('SUM', col('hours')), Op.gt, 0), { @@ -2835,7 +2683,12 @@ describe(getTestDialectTeaser('SQL'), () => { default: 'lower([name]) IS NOT NULL', }); + // @ts-expect-error -- While these are supported for backwards compatibility, they are not documented. Users should use isNot testSql(where(fn('lower', col('name')), Op.not, null), { + default: 'NOT (lower([name]) IS NULL)', + }); + + testSql(where(fn('lower', col('name')), Op.isNot, null), { default: 'lower([name]) IS NOT NULL', }); @@ -2847,15 +2700,15 @@ describe(getTestDialectTeaser('SQL'), () => { default: '[hours] NOT BETWEEN 0 AND 5', }); - testSql.skip(where({ [Op.col]: 'hours' }, Op.notBetween, [0, 5]), { + testSql(where({ [Op.col]: 'hours' }, Op.notBetween, [0, 5]), { default: '[hours] NOT BETWEEN 0 AND 5', }); - testSql.skip(where(cast({ [Op.col]: 'hours' }, 'integer'), Op.notBetween, [0, 5]), { + testSql(where(cast({ [Op.col]: 'hours' }, 'integer'), Op.notBetween, [0, 5]), { default: 'CAST([hours] AS INTEGER) NOT BETWEEN 0 AND 5', }); - testSql.skip(where(fn('SUM', { [Op.col]: 'hours' }), Op.notBetween, [0, 5]), { + testSql(where(fn('SUM', { [Op.col]: 'hours' }), Op.notBetween, [0, 5]), { default: 'SUM([hours]) NOT BETWEEN 0 AND 5', }); @@ -2864,18 +2717,7 @@ describe(getTestDialectTeaser('SQL'), () => { mssql: `'hours' = N'hours'`, }); - // TODO: remove support for this: - // - it only works as the first argument of where when 3 parameters are used. - // - it's inconsistent with other ways to reference attributes. - // - the following variant does not work: where(TestModel.getAttributes().intAttr1, { [Op.eq]: 1 }) - // to be replaced with Sequelize.attr() - testSql(where(TestModel.getAttributes().intAttr1, Op.eq, 1), { - default: '[TestModel].[intAttr1] = 1', - }); - - testSql.skip(where(col('col'), Op.eq, { [Op.in]: [1, 2] }), { - default: new Error('Unexpected operator Op.in'), - }); + testSql(where(col('col'), Op.eq, { [Op.in]: [1, 2] }), { default: new Error('Could not guess type of value { [Symbol(in)]: [ 1, 2 ] }') }); }); describe('where(leftOperand, whereAttributeHashValue)', () => { @@ -2891,22 +2733,17 @@ describe(getTestDialectTeaser('SQL'), () => { default: 'abc = 10', }); - testSql.skip( + testSql( where(col('name'), { [Op.eq]: '123', [Op.not]: { [Op.eq]: '456' } }), - { default: `[name] = '123' AND NOT ([name] = '456')` }, - ); - - testSql.skip( - where(col('name'), or({ [Op.eq]: '123', [Op.not]: { [Op.eq]: '456' } })), - { default: `[name] = '123' OR NOT ([name] = '456')` }, + { + default: `[name] = '123' AND NOT ([name] = '456')`, + mssql: `[name] = N'123' AND NOT ([name] = N'456')`, + }, ); testSql( - where(col('name'), { [Op.not]: '123' }), - { - default: `[name] != '123'`, - mssql: `[name] != N'123'`, - }, + where(col('name'), or({ [Op.eq]: '123', [Op.not]: { [Op.eq]: '456' } })), + { default: `[name] = '123' OR NOT ([name] = '456')`, mssql: `[name] = N'123' OR NOT ([name] = N'456')` }, ); testSql( @@ -2928,13 +2765,17 @@ describe(getTestDialectTeaser('SQL'), () => { ); testSql(where(col('col'), { [Op.and]: [1, 2] }), { - default: '([col] = 1 AND [col] = 2)', + default: '[col] = 1 AND [col] = 2', }); - // TODO: Either allow json.path.syntax here, or remove WhereAttributeHash from what this version of where() accepts. - testSql.skip(where(col('col'), { jsonPath: 'value' }), { - default: new Error('Unexpected key "nested" found, expected an operator.'), - }); + if (dialectSupportsJsonOperations()) { + testSql(where(col('col'), { jsonPath: 'value' }), { + postgres: `"col"->'jsonPath' = '"value"'`, + sqlite: `json_extract(\`col\`,'$.jsonPath') = '"value"'`, + mariadb: `json_compact(json_extract(\`col\`,'$.jsonPath')) = '"value"'`, + mysql: `json_extract(\`col\`,'$.jsonPath') = CAST('"value"' AS JSON)`, + }); + } }); }); }); diff --git a/packages/core/test/unit/utils/attribute-syntax.test.ts b/packages/core/test/unit/utils/attribute-syntax.test.ts new file mode 100644 index 000000000000..d060e70d7d54 --- /dev/null +++ b/packages/core/test/unit/utils/attribute-syntax.test.ts @@ -0,0 +1,132 @@ +import { expect } from 'chai'; +import { sql, AssociationPath, Attribute } from '@sequelize/core'; +import { Unquote } from '@sequelize/core/_non-semver-use-at-your-own-risk_/expression-builders/dialect-aware-fn.js'; +import { parseNestedJsonKeySyntax, parseAttributeSyntax } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/attribute-syntax.js'; + +describe('parseAttributeSyntax', () => { + it('parses simple attributes', () => { + expect(parseAttributeSyntax('foo')).to.deep.eq(new Attribute('foo')); + }); + + it('parses simple associations', () => { + expect(parseAttributeSyntax('$bar$')).to.deep.eq( + new Attribute('bar'), + ); + + expect(parseAttributeSyntax('$foo.bar$')).to.deep.eq( + new AssociationPath(['foo'], 'bar'), + ); + + expect(parseAttributeSyntax('$foo.zzz.bar$')).to.deep.eq( + new AssociationPath(['foo', 'zzz'], 'bar'), + ); + }); + + it('throws for unbalanced association syntax', () => { + // The error points at the erroneous character each time, but we only test the first one + expect(() => parseAttributeSyntax('foo$')).to.throwWithCause(`Failed to parse syntax of attribute. Parse error at index 3: +foo$ + ^`); + + expect(() => parseAttributeSyntax('$foo')).to.throwWithCause(`Failed to parse syntax of attribute. Parse error at index 0:`); + }); + + it('parses cast syntax', () => { + expect(parseAttributeSyntax('foo::bar')).to.deep.eq( + sql.cast(new Attribute('foo'), 'bar'), + ); + }); + + it('parses consecutive casts', () => { + expect(parseAttributeSyntax('foo::bar::baz')).to.deep.eq( + sql.cast(sql.cast(new Attribute('foo'), 'bar'), 'baz'), + ); + }); + + it('parses modifier syntax', () => { + expect(parseAttributeSyntax('foo:unquote')).to.deep.eq( + sql.unquote(new Attribute('foo')), + ); + }); + + it('parses consecutive modifiers', () => { + expect(parseAttributeSyntax('foo:unquote:unquote')).to.deep.eq( + sql.unquote(sql.unquote(new Attribute('foo'))), + ); + }); + + it('parses casts and modifiers', () => { + expect(parseAttributeSyntax('textAttr::json:unquote::integer')).to.deep.eq( + sql.cast(sql.unquote(sql.cast(new Attribute('textAttr'), 'json')), 'integer'), + ); + }); + + it('treats everything after ::/: as a cast/modifier', () => { + // "json.property" is treated as a cast, not a JSON path + // but it's not a valid cast, so it will throw + expect(() => parseAttributeSyntax('textAttr::json.property')).to.throwWithCause(`Failed to parse syntax of attribute. Parse error at index 14: +textAttr::json.property + ^`); + + // "json.property" is treated as a modifier (which does not exist and will throw), not a JSON path + expect(() => parseAttributeSyntax('textAttr:json.property')).to.throwWithCause(`Failed to parse syntax of attribute. Parse error at index 13: +textAttr:json.property + ^`); + }); + + it('parses JSON paths', () => { + expect(parseAttributeSyntax('foo.bar')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['bar']), + ); + + expect(parseAttributeSyntax('foo."bar"')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['bar']), + ); + + expect(parseAttributeSyntax('foo."bar\\""')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['bar"']), + ); + + expect(parseAttributeSyntax('foo."bar\\\\"')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['bar\\']), + ); + + expect(parseAttributeSyntax('foo[123]')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), [123]), + ); + + expect(parseAttributeSyntax('foo."123"')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['123']), + ); + + expect(parseAttributeSyntax('foo.abc[0]."def"[1]')).to.deep.eq( + sql.jsonPath(new Attribute('foo'), ['abc', 0, 'def', 1]), + ); + }); +}); + +describe('parseNestedJsonKeySyntax', () => { + it('parses JSON paths', () => { + expect(parseNestedJsonKeySyntax('foo.bar')).to.deep.eq( + { pathSegments: ['foo', 'bar'], castsAndModifiers: [] }, + ); + + expect(parseNestedJsonKeySyntax('abc-def.ijk-lmn')).to.deep.eq( + { pathSegments: ['abc-def', 'ijk-lmn'], castsAndModifiers: [] }, + ); + + expect(parseNestedJsonKeySyntax('"foo"."bar"')).to.deep.eq( + { pathSegments: ['foo', 'bar'], castsAndModifiers: [] }, + ); + + expect(parseNestedJsonKeySyntax('[0]')).to.deep.eq( + { pathSegments: [0], castsAndModifiers: [] }, + ); + }); + + it('parses casts and modifiers', () => { + expect(parseNestedJsonKeySyntax('[0]:unquote::text:unquote::text')).to.deep.eq( + { pathSegments: [0], castsAndModifiers: [Unquote, 'text', Unquote, 'text'] }, + ); + }); +}); diff --git a/packages/core/test/unit/utils/sql.test.ts b/packages/core/test/unit/utils/sql.test.ts index 793378839986..c79070293642 100644 --- a/packages/core/test/unit/utils/sql.test.ts +++ b/packages/core/test/unit/utils/sql.test.ts @@ -1,4 +1,5 @@ import { expect } from 'chai'; +import { sql as sqlTag } from '@sequelize/core'; import { injectReplacements, mapBindParameters } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/sql.js'; import { createSequelizeInstance, @@ -9,6 +10,8 @@ import { toMatchSql, } from '../../support'; +const { list } = sqlTag; + const dialect = sequelize.dialect; const supportsNamedParameters = dialect.name === 'sqlite' || dialect.name === 'mssql'; @@ -797,11 +800,13 @@ SELECT * FROM users WHERE id = '\\\\\\' ?' OR id = ?`), expect(injectReplacements('foo = ?', dialect, [0])).to.equal('foo = 0'); }); - it('formats arrays as an expression instead of an ARRAY data type', async () => { - const sql = injectReplacements('INSERT INTO users (username, email, created_at, updated_at) VALUES ?;', dialect, [[ - ['john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'], - ['michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10'], - ]]); + it('formats arrays as an expression when they are wrapped with list(), instead of an ARRAY data type', async () => { + const sql = injectReplacements('INSERT INTO users (username, email, created_at, updated_at) VALUES ?;', dialect, [ + [ + list(['john', 'john@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10']), + list(['michael', 'michael@gmail.com', '2012-01-01 10:10:10', '2012-01-01 10:10:10']), + ], + ]); expectsql(sql, { default: ` diff --git a/packages/core/test/unit/utils/utils.test.ts b/packages/core/test/unit/utils/utils.test.ts index d9e1fe2d4c57..602723226fa3 100644 --- a/packages/core/test/unit/utils/utils.test.ts +++ b/packages/core/test/unit/utils/utils.test.ts @@ -1,13 +1,14 @@ import { expect } from 'chai'; -import { cast, col, DataTypes, fn, Op, Where, Json } from '@sequelize/core'; -import type { AbstractQueryGenerator } from '@sequelize/core'; +import { col, DataTypes, Where } from '@sequelize/core'; import { canTreatArrayAsAnd } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/check.js'; import { toDefaultValue } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/dialect.js'; -import { mapFinderOptions, mapOptionFieldNames } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/format.js'; +import { mapFinderOptions } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/format.js'; import { defaults, merge, cloneDeep, flattenObjectDeep } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/object.js'; import { underscoredIf, camelizeIf, pluralize, singularize } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/string.js'; import { parseConnectionString } from '@sequelize/core/_non-semver-use-at-your-own-risk_/utils/url.js'; -import { sequelize, getTestDialect, expectsql } from '../../support'; +import { sequelize } from '../../support'; + +const dialect = sequelize.dialect; describe('Utils', () => { describe('underscore', () => { @@ -79,43 +80,6 @@ describe('Utils', () => { }); }); - if (getTestDialect() === 'postgres') { - describe('json', () => { - let queryGenerator: AbstractQueryGenerator; - beforeEach(() => { - queryGenerator = sequelize.getQueryInterface().queryGenerator; - }); - - it('successfully parses a complex nested condition hash', () => { - const conditions = { - metadata: { - language: 'icelandic', - pg_rating: { dk: 'G' }, - }, - another_json_field: { x: 1 }, - }; - const expected = '("metadata"#>>\'{language}\') = \'icelandic\' AND ("metadata"#>>\'{pg_rating,dk}\') = \'G\' AND ("another_json_field"#>>\'{x}\') = \'1\''; - expect(queryGenerator.handleSequelizeMethod(new Json(conditions))).to.deep.equal(expected); - }); - - it('successfully parses a string using dot notation', () => { - const path = 'metadata.pg_rating.dk'; - expect(queryGenerator.handleSequelizeMethod(new Json(path))).to.equal('("metadata"#>>\'{pg_rating,dk}\')'); - }); - - it('allows postgres json syntax', () => { - const path = 'metadata->pg_rating->>dk'; - expect(queryGenerator.handleSequelizeMethod(new Json(path))).to.equal(path); - }); - - it('can take a value to compare against', () => { - const path = 'metadata.pg_rating.is'; - const value = 'U'; - expect(queryGenerator.handleSequelizeMethod(new Json(path, value))).to.equal('("metadata"#>>\'{pg_rating,is}\') = \'U\''); - }); - }); - } - describe('inflection', () => { it('should pluralize/singularize words correctly', () => { expect(pluralize('buy')).to.equal('buys'); @@ -197,17 +161,14 @@ describe('Utils', () => { }); describe('toDefaultValue', () => { - it('return plain data types', () => { - expect(() => toDefaultValue(DataTypes.UUIDV4)).to.throw(); - }); it('return uuid v1', () => { - expect(/^[\da-z-]{36}$/.test(toDefaultValue(DataTypes.UUIDV1()) as string)).to.be.equal(true); + expect(/^[\da-z-]{36}$/.test(toDefaultValue(new DataTypes.UUIDV1().toDialectDataType(dialect)) as string)).to.be.equal(true); }); it('return uuid v4', () => { - expect(/^[\da-z-]{36}/.test(toDefaultValue(DataTypes.UUIDV4()) as string)).to.be.equal(true); + expect(/^[\da-z-]{36}/.test(toDefaultValue(new DataTypes.UUIDV4().toDialectDataType(dialect)) as string)).to.be.equal(true); }); it('return now', () => { - expect(Object.prototype.toString.call(toDefaultValue(DataTypes.NOW()))).to.be.equal('[object Date]'); + expect(Object.prototype.toString.call(toDefaultValue(new DataTypes.NOW().toDialectDataType(dialect)))).to.be.equal('[object Date]'); }); it('return plain string', () => { expect(toDefaultValue('Test')).to.equal('Test'); @@ -293,126 +254,4 @@ describe('Utils', () => { ]); }); }); - - describe('mapOptionFieldNames', () => { - it('plain where', () => { - expect(mapOptionFieldNames({ - where: { - firstName: 'Paul', - lastName: 'Atreides', - }, - }, sequelize.define('User', { - firstName: { - type: DataTypes.STRING, - field: 'first_name', - }, - lastName: { - type: DataTypes.STRING, - field: 'last_name', - }, - }))).to.eql({ - where: { - first_name: 'Paul', - last_name: 'Atreides', - }, - }); - }); - - it('Op.or where', () => { - expect(mapOptionFieldNames({ - where: { - [Op.or]: { - firstName: 'Paul', - lastName: 'Atreides', - }, - }, - }, sequelize.define('User', { - firstName: { - type: DataTypes.STRING, - field: 'first_name', - }, - lastName: { - type: DataTypes.STRING, - field: 'last_name', - }, - }))).to.eql({ - where: { - [Op.or]: { - first_name: 'Paul', - last_name: 'Atreides', - }, - }, - }); - }); - - it('Op.or[] where', () => { - expect(mapOptionFieldNames({ - where: { - [Op.or]: [ - { firstName: 'Paul' }, - { lastName: 'Atreides' }, - ], - }, - }, sequelize.define('User', { - firstName: { - type: DataTypes.STRING, - field: 'first_name', - }, - lastName: { - type: DataTypes.STRING, - field: 'last_name', - }, - }))).to.eql({ - where: { - [Op.or]: [ - { first_name: 'Paul' }, - { last_name: 'Atreides' }, - ], - }, - }); - }); - - it('$and where', () => { - expect(mapOptionFieldNames({ - where: { - [Op.and]: { - firstName: 'Paul', - lastName: 'Atreides', - }, - }, - }, sequelize.define('User', { - firstName: { - type: DataTypes.STRING, - field: 'first_name', - }, - lastName: { - type: DataTypes.STRING, - field: 'last_name', - }, - }))).to.eql({ - where: { - [Op.and]: { - first_name: 'Paul', - last_name: 'Atreides', - }, - }, - }); - }); - }); - - describe('Sequelize.cast', () => { - const generator = sequelize.queryInterface.queryGenerator; - - it('accepts condition object (auto casting)', () => { - expectsql(() => generator.handleSequelizeMethod(fn('SUM', cast({ - [Op.or]: { - foo: 'foo', - bar: 'bar', - }, - }, 'int'))), { - default: `SUM(CAST(([foo] = 'foo' OR [bar] = 'bar') AS INT))`, - mssql: `SUM(CAST(([foo] = N'foo' OR [bar] = N'bar') AS INT))`, - }); - }); - }); }); diff --git a/yarn.lock b/yarn.lock index 9c27a476bf9e..b6474ebe02dc 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2041,6 +2041,11 @@ bn.js@^4.0.0: resolved "https://registry.yarnpkg.com/bn.js/-/bn.js-4.12.0.tgz#775b3f278efbb9718eec7361f483fb36fbbfea88" integrity sha512-c98Bf3tPniI+scsdk237ku1Dc3ujXQTSgyiPUDEOe7tRkhrqridvh8klBv0HCEso1OLOYcHuCv/cS6DNxKH+ZA== +bnf-parser@^3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/bnf-parser/-/bnf-parser-3.1.1.tgz#4726c4d415bb63b65eff3a10f6cbee1ce46ad0ac" + integrity sha512-Eu/NvBzIYcqejCPbKfoWKl37dENtsZKznzLTHTGthENyuQuPBrQGrILxDjyARQNke2DADv14gn3mremhOUaDxA== + brace-expansion@^1.1.7: version "1.1.11" resolved "https://registry.yarnpkg.com/brace-expansion/-/brace-expansion-1.1.11.tgz#3c7fcbf529d87226f3d2f52b966ff5271eb441dd"