Import other modules into monorepo
Some checks failed
continuous-integration/drone/push Build is failing
Some checks failed
continuous-integration/drone/push Build is failing
This commit is contained in:
parent
26d54033af
commit
9be9c44a32
12
package.json
12
package.json
@ -8,17 +8,25 @@
|
||||
"lib": "lib"
|
||||
},
|
||||
"dependencies": {
|
||||
"@extollo/di": "git+https://code.garrettmills.dev/extollo/di",
|
||||
"@extollo/util": "git+https://code.garrettmills.dev/extollo/util",
|
||||
"@types/busboy": "^0.2.3",
|
||||
"@types/mkdirp": "^1.0.1",
|
||||
"@types/negotiator": "^0.6.1",
|
||||
"@types/node": "^14.14.37",
|
||||
"@types/pg": "^8.6.0",
|
||||
"@types/pluralize": "^0.0.29",
|
||||
"@types/pug": "^2.0.4",
|
||||
"@types/rimraf": "^3.0.0",
|
||||
"@types/ssh2": "^0.5.46",
|
||||
"busboy": "^0.3.1",
|
||||
"colors": "^1.4.0",
|
||||
"dotenv": "^8.2.0",
|
||||
"mkdirp": "^1.0.4",
|
||||
"negotiator": "^0.6.2",
|
||||
"pg": "^8.6.0",
|
||||
"pluralize": "^8.0.0",
|
||||
"pug": "^3.0.2",
|
||||
"rimraf": "^3.0.2",
|
||||
"ssh2": "^1.1.0",
|
||||
"ts-node": "^9.1.1",
|
||||
"typescript": "^4.2.3"
|
||||
},
|
||||
|
275
pnpm-lock.yaml
275
pnpm-lock.yaml
@ -1,15 +1,23 @@
|
||||
dependencies:
|
||||
'@extollo/di': code.garrettmills.dev/extollo/di/902c31997016d24c735448bbf1e58f06e94f4139
|
||||
'@extollo/util': code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf
|
||||
'@types/busboy': 0.2.3
|
||||
'@types/mkdirp': 1.0.1
|
||||
'@types/negotiator': 0.6.1
|
||||
'@types/node': 14.14.37
|
||||
'@types/pg': 8.6.0
|
||||
'@types/pluralize': 0.0.29
|
||||
'@types/pug': 2.0.4
|
||||
'@types/rimraf': 3.0.0
|
||||
'@types/ssh2': 0.5.46
|
||||
busboy: 0.3.1
|
||||
colors: 1.4.0
|
||||
dotenv: 8.2.0
|
||||
mkdirp: 1.0.4
|
||||
negotiator: 0.6.2
|
||||
pg: 8.6.0
|
||||
pluralize: 8.0.0
|
||||
pug: 3.0.2
|
||||
rimraf: 3.0.2
|
||||
ssh2: 1.1.0
|
||||
ts-node: 9.1.1_typescript@4.2.3
|
||||
typescript: 4.2.3
|
||||
lockfileVersion: 5.2
|
||||
@ -42,7 +50,7 @@ packages:
|
||||
/@types/glob/7.1.3:
|
||||
dependencies:
|
||||
'@types/minimatch': 3.0.4
|
||||
'@types/node': 14.14.37
|
||||
'@types/node': 14.17.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-SEYeGAIQIQX8NN6LDKprLjbrd5dARM5EXsd8GI/A5l0apYI1fGMWgPHSe4ZKL4eozlAyI+doUE9XbYS4xCkQ1w==
|
||||
@ -52,7 +60,7 @@ packages:
|
||||
integrity: sha512-1z8k4wzFnNjVK/tlxvrWuK5WMt6mydWWP7+zvH5eFep4oj+UkrfiJTRtjCeBXNpwaA/FYqqtb4/QS4ianFpIRA==
|
||||
/@types/mkdirp/1.0.1:
|
||||
dependencies:
|
||||
'@types/node': 14.14.37
|
||||
'@types/node': 14.17.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-HkGSK7CGAXncr8Qn/0VqNtExEE+PHMWb+qlR1faHMao7ng6P3tAaoWWBMdva0gL5h4zprjIO89GJOLXsMcDm1Q==
|
||||
@ -64,6 +72,22 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-XYmBiy+ohOR4Lh5jE379fV2IU+6Jn4g5qASinhitfyO71b/sCo6MKsMLF5tc7Zf2CE8hViVQyYSobJNke8OvUw==
|
||||
/@types/node/14.17.1:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-/tpUyFD7meeooTRwl3sYlihx2BrJE7q9XF71EguPFIySj9B7qgnRtHsHTho+0AUm4m1SvWGm6uSncrR94q6Vtw==
|
||||
/@types/pg/8.6.0:
|
||||
dependencies:
|
||||
'@types/node': 14.17.1
|
||||
pg-protocol: 1.5.0
|
||||
pg-types: 2.2.0
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-3JXFrsl8COoqVB1+2Pqelx6soaiFVXzkT3fkuSNe7GB40ysfT0FHphZFPiqIXpMyTHSFRdLTyZzrFBrJRPAArA==
|
||||
/@types/pluralize/0.0.29:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-BYOID+l2Aco2nBik+iYS4SZX0Lf20KPILP5RGmM1IgzdwNdTs0eebiFriOPcej1sX9mLnSoiNte5zcFxssgpGA==
|
||||
/@types/pug/2.0.4:
|
||||
dev: false
|
||||
resolution:
|
||||
@ -71,27 +95,23 @@ packages:
|
||||
/@types/rimraf/3.0.0:
|
||||
dependencies:
|
||||
'@types/glob': 7.1.3
|
||||
'@types/node': 14.14.37
|
||||
'@types/node': 14.17.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-7WhJ0MdpFgYQPXlF4Dx+DhgvlPCfz/x5mHaeDQAKhcenvQP1KCpLQ18JklAqeGMYSAT2PxLpzd0g2/HE7fj7hQ==
|
||||
/@types/ssh2-streams/0.1.8:
|
||||
dependencies:
|
||||
'@types/node': 14.14.37
|
||||
'@types/node': 14.17.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-I7gixRPUvVIyJuCEvnmhr3KvA2dC0639kKswqD4H5b4/FOcnPtNU+qWLiXdKIqqX9twUvi5j0U1mwKE5CUsrfA==
|
||||
/@types/ssh2/0.5.46:
|
||||
dependencies:
|
||||
'@types/node': 14.14.37
|
||||
'@types/node': 14.17.1
|
||||
'@types/ssh2-streams': 0.1.8
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-1pC8FHrMPYdkLoUOwTYYifnSEPzAFZRsp3JFC/vokQ+dRrVI+hDBwz0SNmQ3pL6h39OSZlPs0uCG7wKJkftnaA==
|
||||
/@types/uuid/8.3.0:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-eQ9qFW/fhfGJF8WKHGEHZEyVWfZxrT+6CLIJGBcZPfxUh/+BnEj+UCGYMlr9qZuX/2AltsvwrGqp0LhEW8D0zQ==
|
||||
/acorn/7.4.1:
|
||||
dev: false
|
||||
engines:
|
||||
@ -125,10 +145,10 @@ packages:
|
||||
node: '>= 10.0.0'
|
||||
resolution:
|
||||
integrity: sha512-GAwkz0AihzY5bkwIY5QDR+LvsRQgB/B+1foMPvi0FZPMl5fjD7ICiznUiBdLYMH1QYe6vqu4gWYytZOccLouFw==
|
||||
/balanced-match/1.0.0:
|
||||
/balanced-match/1.0.2:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha1-ibTRmasr7kneFk6gK4nORi1xt2c=
|
||||
integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==
|
||||
/bcrypt-pbkdf/1.0.2:
|
||||
dependencies:
|
||||
tweetnacl: 0.14.5
|
||||
@ -137,7 +157,7 @@ packages:
|
||||
integrity: sha1-pDAdOJtqQ/m2f/PKEaP2Y342Dp4=
|
||||
/brace-expansion/1.1.11:
|
||||
dependencies:
|
||||
balanced-match: 1.0.0
|
||||
balanced-match: 1.0.2
|
||||
concat-map: 0.0.1
|
||||
dev: false
|
||||
resolution:
|
||||
@ -146,6 +166,12 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-MQcXEUbCKtEo7bhqEs6560Hyd4XaovZlO/k9V3hjVUF/zwW7KBVdSK4gIt/bzwS9MbR5qob+F5jusZsb0YQK2A==
|
||||
/buffer-writer/2.0.0:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=4'
|
||||
resolution:
|
||||
integrity: sha512-a7ZpuTZU1TRtnwyCNW3I5dc0wWNC3VR9S++Ewyk2HHZdrO3CQJqSpd+95Us590V6AL7JqUAH2IwZ/398PmNFgw==
|
||||
/busboy/0.3.1:
|
||||
dependencies:
|
||||
dicer: 0.3.0
|
||||
@ -184,6 +210,16 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-vCrqcSIq4//Gx74TXXCGnHpulY1dskqLTFGDmhrGxzeXL8lF8kvXv6mpNWlJj1uD4DW23D4ljAqbY4RRaaUZIw==
|
||||
/cpu-features/0.0.2:
|
||||
dependencies:
|
||||
nan: 2.14.2
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=8.0.0'
|
||||
optional: true
|
||||
requiresBuild: true
|
||||
resolution:
|
||||
integrity: sha512-/2yieBqvMcRj8McNzkycjW2v3OIUOibBfd2dLEJ0nWts8NobAxwiyw9phVNS6oDL8x8tz9F7uNVFEVpJncQpeA==
|
||||
/create-require/1.1.1:
|
||||
dev: false
|
||||
resolution:
|
||||
@ -228,7 +264,7 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-kWZrnVM42QCiEA2Ig1bG8zjoIMOgxWwYCEeNdwY6Tv/cOSeGpcoX4pXHfKUxNKVoArnrEr2e9srnAxxGIraS9Q==
|
||||
/glob/7.1.6:
|
||||
/glob/7.1.7:
|
||||
dependencies:
|
||||
fs.realpath: 1.0.0
|
||||
inflight: 1.0.6
|
||||
@ -238,7 +274,7 @@ packages:
|
||||
path-is-absolute: 1.0.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-LwaxwyZ72Lk7vZINtNNrywX0ZuLyStrdDtabefZKAY5ZGJhVtgdznluResxNmPitE0SAO+O26sWTHeKSI2wMBA==
|
||||
integrity: sha512-OvD9ENzPLbegENnYP5UUfJIirTg4+XwMWGaQfQTY0JenxNvvIKP3U3/tAQSPIu/lHxXYSZmpXlUHeqAIdKzBLQ==
|
||||
/has-symbols/1.0.2:
|
||||
dev: false
|
||||
engines:
|
||||
@ -322,6 +358,11 @@ packages:
|
||||
hasBin: true
|
||||
resolution:
|
||||
integrity: sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==
|
||||
/nan/2.14.2:
|
||||
dev: false
|
||||
optional: true
|
||||
resolution:
|
||||
integrity: sha512-M2ufzIiINKCuDfBSAUr1vWQ+vuVcA9kqx8JJUsbQi6yf1uGRyb7HfpdfUr5qLXf3B/t8dPvcjhKMmlfnP47EzQ==
|
||||
/negotiator/0.6.2:
|
||||
dev: false
|
||||
engines:
|
||||
@ -340,6 +381,10 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha1-WDsap3WWHUsROsF9nFC6753Xa9E=
|
||||
/packet-reader/1.0.0:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-HAKu/fG3HpHFO0AA8WE8q2g+gBJaZ9MG7fcKk+IJPLTGAD6Psw4443l+9DGRbOIh3/aXr7Phy0TjilYivJo5XQ==
|
||||
/path-is-absolute/1.0.1:
|
||||
dev: false
|
||||
engines:
|
||||
@ -350,6 +395,97 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-GSmOT2EbHrINBf9SR7CDELwlJ8AENk3Qn7OikK4nFYAu3Ote2+JYNVvkpAEQm3/TLNEJFD/xZJjzyxg3KBWOzw==
|
||||
/pg-connection-string/2.5.0:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-r5o/V/ORTA6TmUnyWZR9nCj1klXCO2CEKNRlVuJptZe85QuhFayC7WeMic7ndayT5IRIR0S0xFxFi2ousartlQ==
|
||||
/pg-int8/1.0.1:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=4.0.0'
|
||||
resolution:
|
||||
integrity: sha512-WCtabS6t3c8SkpDBUlb1kjOs7l66xsGdKpIPZsg4wR+B3+u9UAum2odSsF9tnvxg80h4ZxLWMy4pRjOsFIqQpw==
|
||||
/pg-pool/3.3.0_pg@8.6.0:
|
||||
dependencies:
|
||||
pg: 8.6.0
|
||||
dev: false
|
||||
peerDependencies:
|
||||
pg: '>=8.0'
|
||||
resolution:
|
||||
integrity: sha512-0O5huCql8/D6PIRFAlmccjphLYWC+JIzvUhSzXSpGaf+tjTZc4nn+Lr7mLXBbFJfvwbP0ywDv73EiaBsxn7zdg==
|
||||
/pg-protocol/1.5.0:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-muRttij7H8TqRNu/DxrAJQITO4Ac7RmX3Klyr/9mJEOBeIpgnF8f9jAfRz5d3XwQZl5qBjF9gLsUtMPJE0vezQ==
|
||||
/pg-types/2.2.0:
|
||||
dependencies:
|
||||
pg-int8: 1.0.1
|
||||
postgres-array: 2.0.0
|
||||
postgres-bytea: 1.0.0
|
||||
postgres-date: 1.0.7
|
||||
postgres-interval: 1.2.0
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=4'
|
||||
resolution:
|
||||
integrity: sha512-qTAAlrEsl8s4OiEQY69wDvcMIdQN6wdz5ojQiOy6YRMuynxenON0O5oCpJI6lshc6scgAY8qvJ2On/p+CXY0GA==
|
||||
/pg/8.6.0:
|
||||
dependencies:
|
||||
buffer-writer: 2.0.0
|
||||
packet-reader: 1.0.0
|
||||
pg-connection-string: 2.5.0
|
||||
pg-pool: 3.3.0_pg@8.6.0
|
||||
pg-protocol: 1.5.0
|
||||
pg-types: 2.2.0
|
||||
pgpass: 1.0.4
|
||||
dev: false
|
||||
engines:
|
||||
node: '>= 8.0.0'
|
||||
peerDependencies:
|
||||
pg-native: '>=2.0.0'
|
||||
peerDependenciesMeta:
|
||||
pg-native:
|
||||
optional: true
|
||||
resolution:
|
||||
integrity: sha512-qNS9u61lqljTDFvmk/N66EeGq3n6Ujzj0FFyNMGQr6XuEv4tgNTXvJQTfJdcvGit5p5/DWPu+wj920hAJFI+QQ==
|
||||
/pgpass/1.0.4:
|
||||
dependencies:
|
||||
split2: 3.2.2
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-YmuA56alyBq7M59vxVBfPJrGSozru8QAdoNlWuW3cz8l+UX3cWge0vTvjKhsSHSJpo3Bom8/Mm6hf0TR5GY0+w==
|
||||
/pluralize/8.0.0:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=4'
|
||||
resolution:
|
||||
integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==
|
||||
/postgres-array/2.0.0:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=4'
|
||||
resolution:
|
||||
integrity: sha512-VpZrUqU5A69eQyW2c5CA1jtLecCsN2U/bD6VilrFDWq5+5UIEVO7nazS3TEcHf1zuPYO/sqGvUvW62g86RXZuA==
|
||||
/postgres-bytea/1.0.0:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=0.10.0'
|
||||
resolution:
|
||||
integrity: sha1-AntTPAqokOJtFy1Hz5zOzFIazTU=
|
||||
/postgres-date/1.0.7:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=0.10.0'
|
||||
resolution:
|
||||
integrity: sha512-suDmjLVQg78nMK2UZ454hAG+OAW+HQPZ6n++TNDUX+L0+uUlLywnoxJKDou51Zm+zTCjrCl0Nq6J9C5hP9vK/Q==
|
||||
/postgres-interval/1.2.0:
|
||||
dependencies:
|
||||
xtend: 4.0.2
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=0.10.0'
|
||||
resolution:
|
||||
integrity: sha512-9ZhXKM/rw350N1ovuWHbGxnGh/SNJ4cnxHiM0rxE4VN41wsg8P8zWn9hv/buK00RP4WvlOyr/RBDiptyxVbkZQ==
|
||||
/promise/7.3.1:
|
||||
dependencies:
|
||||
asap: 2.0.6
|
||||
@ -447,10 +583,16 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-bp0I/hiK1D1vChHh6EfDxtndHji55XP/ZJKwsRqrz6lRia6ZC2OZbdAymlxdVFwd1L70ebrVJw4/eZ79skrIaw==
|
||||
/reflect-metadata/0.1.13:
|
||||
/readable-stream/3.6.0:
|
||||
dependencies:
|
||||
inherits: 2.0.4
|
||||
string_decoder: 1.3.0
|
||||
util-deprecate: 1.0.2
|
||||
dev: false
|
||||
engines:
|
||||
node: '>= 6'
|
||||
resolution:
|
||||
integrity: sha512-Ts1Y/anZELhSsjMcU605fU9RE4Oi3p5ORujwbIKXfWa+0Zxs510Qrmrce5/Jowq3cHSZSJqBjypxmHarc+vEWg==
|
||||
integrity: sha512-BViHy7LKeTz4oNnkcLJ+lVSL6vpiFeX6/d3oSH8zCW7UxP2onchk+vTGB143xuFjHS3deTgkKoXXymXqymiIdA==
|
||||
/resolve/1.20.0:
|
||||
dependencies:
|
||||
is-core-module: 2.2.0
|
||||
@ -460,11 +602,15 @@ packages:
|
||||
integrity: sha512-wENBPt4ySzg4ybFQW2TT1zMQucPK95HSh/nq2CFTZVOGut2+pQvSsgtda4d26YrYcr067wjbmzOG8byDPBX63A==
|
||||
/rimraf/3.0.2:
|
||||
dependencies:
|
||||
glob: 7.1.6
|
||||
glob: 7.1.7
|
||||
dev: false
|
||||
hasBin: true
|
||||
resolution:
|
||||
integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==
|
||||
/safe-buffer/5.2.1:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==
|
||||
/safer-buffer/2.1.2:
|
||||
dev: false
|
||||
resolution:
|
||||
@ -482,30 +628,37 @@ packages:
|
||||
node: '>=0.10.0'
|
||||
resolution:
|
||||
integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==
|
||||
/ssh2-streams/0.4.10:
|
||||
/split2/3.2.2:
|
||||
dependencies:
|
||||
readable-stream: 3.6.0
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-9NThjpgZnifTkJpzTZ7Eue85S49QwpNhZTq6GRJwObb6jnLFNGB7Qm73V5HewTROPyxD0C29xqmaI68bQtV+hg==
|
||||
/ssh2/1.1.0:
|
||||
dependencies:
|
||||
asn1: 0.2.4
|
||||
bcrypt-pbkdf: 1.0.2
|
||||
streamsearch: 0.1.2
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=5.2.0'
|
||||
node: '>=10.16.0'
|
||||
optionalDependencies:
|
||||
cpu-features: 0.0.2
|
||||
nan: 2.14.2
|
||||
requiresBuild: true
|
||||
resolution:
|
||||
integrity: sha512-8pnlMjvnIZJvmTzUIIA5nT4jr2ZWNNVHwyXfMGdRJbug9TpI3kd99ffglgfSWqujVv/0gxwMsDn9j9RVst8yhQ==
|
||||
/ssh2/0.8.9:
|
||||
dependencies:
|
||||
ssh2-streams: 0.4.10
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=5.2.0'
|
||||
resolution:
|
||||
integrity: sha512-GmoNPxWDMkVpMFa9LVVzQZHF6EW3WKmBwL+4/GeILf2hFmix5Isxm7Amamo8o7bHiU0tC+wXsGcUXOxp8ChPaw==
|
||||
integrity: sha512-CidQLG2ZacoT0Z7O6dOyisj4JdrOrLVJ4KbHjVNz9yI1vO08FAYQPcnkXY9BP8zeYo+J/nBgY6Gg4R7w4WFWtg==
|
||||
/streamsearch/0.1.2:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=0.8.0'
|
||||
resolution:
|
||||
integrity: sha1-gIudDlb8Jz2Am6VzOOkpkZoanxo=
|
||||
/string_decoder/1.3.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==
|
||||
/to-fast-properties/2.0.0:
|
||||
dev: false
|
||||
engines:
|
||||
@ -544,11 +697,10 @@ packages:
|
||||
hasBin: true
|
||||
resolution:
|
||||
integrity: sha512-qOcYwxaByStAWrBf4x0fibwZvMRG+r4cQoTjbPtUlrWjBHbmCAww1i448U0GJ+3cNNEtebDteo/cHOR3xJ4wEw==
|
||||
/uuid/8.3.2:
|
||||
/util-deprecate/1.0.2:
|
||||
dev: false
|
||||
hasBin: true
|
||||
resolution:
|
||||
integrity: sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==
|
||||
integrity: sha1-RQ1Nyfpw3nMnYvvS1KKJgUGaDM8=
|
||||
/void-elements/3.1.0:
|
||||
dev: false
|
||||
engines:
|
||||
@ -570,60 +722,37 @@ packages:
|
||||
dev: false
|
||||
resolution:
|
||||
integrity: sha1-tSQ9jz7BqjXxNkYFvA0QNuMKtp8=
|
||||
/xtend/4.0.2:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=0.4'
|
||||
resolution:
|
||||
integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==
|
||||
/yn/3.1.1:
|
||||
dev: false
|
||||
engines:
|
||||
node: '>=6'
|
||||
resolution:
|
||||
integrity: sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q==
|
||||
code.garrettmills.dev/extollo/di/902c31997016d24c735448bbf1e58f06e94f4139:
|
||||
dependencies:
|
||||
'@extollo/util': code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf
|
||||
'@types/node': 14.14.37
|
||||
reflect-metadata: 0.1.13
|
||||
typescript: 4.2.3
|
||||
dev: false
|
||||
name: '@extollo/di'
|
||||
prepare: true
|
||||
requiresBuild: true
|
||||
resolution:
|
||||
commit: 902c31997016d24c735448bbf1e58f06e94f4139
|
||||
repo: https://code.garrettmills.dev/extollo/di
|
||||
type: git
|
||||
version: 0.4.4
|
||||
code.garrettmills.dev/extollo/util/131e0c93ee7ea67d771053fbd69fbf7f6612fadf:
|
||||
dependencies:
|
||||
'@types/mkdirp': 1.0.1
|
||||
'@types/node': 14.14.37
|
||||
'@types/rimraf': 3.0.0
|
||||
'@types/ssh2': 0.5.46
|
||||
'@types/uuid': 8.3.0
|
||||
colors: 1.4.0
|
||||
mkdirp: 1.0.4
|
||||
rimraf: 3.0.2
|
||||
ssh2: 0.8.9
|
||||
typescript: 4.2.3
|
||||
uuid: 8.3.2
|
||||
dev: false
|
||||
name: '@extollo/util'
|
||||
prepare: true
|
||||
requiresBuild: true
|
||||
resolution:
|
||||
commit: 131e0c93ee7ea67d771053fbd69fbf7f6612fadf
|
||||
repo: https://code.garrettmills.dev/extollo/util
|
||||
type: git
|
||||
version: 0.3.2
|
||||
specifiers:
|
||||
'@extollo/di': git+https://code.garrettmills.dev/extollo/di
|
||||
'@extollo/util': git+https://code.garrettmills.dev/extollo/util
|
||||
'@types/busboy': ^0.2.3
|
||||
'@types/mkdirp': ^1.0.1
|
||||
'@types/negotiator': ^0.6.1
|
||||
'@types/node': ^14.14.37
|
||||
'@types/pg': ^8.6.0
|
||||
'@types/pluralize': ^0.0.29
|
||||
'@types/pug': ^2.0.4
|
||||
'@types/rimraf': ^3.0.0
|
||||
'@types/ssh2': ^0.5.46
|
||||
busboy: ^0.3.1
|
||||
colors: ^1.4.0
|
||||
dotenv: ^8.2.0
|
||||
mkdirp: ^1.0.4
|
||||
negotiator: ^0.6.2
|
||||
pg: ^8.6.0
|
||||
pluralize: ^8.0.0
|
||||
pug: ^3.0.2
|
||||
rimraf: ^3.0.2
|
||||
ssh2: ^1.1.0
|
||||
ts-node: ^9.1.1
|
||||
typescript: ^4.2.3
|
||||
|
443
src/cli/Directive.ts
Normal file
443
src/cli/Directive.ts
Normal file
@ -0,0 +1,443 @@
|
||||
import {Injectable, Inject} from "../di"
|
||||
import {infer, ErrorWithContext} from "../util"
|
||||
import {CLIOption} from "./directive/options/CLIOption"
|
||||
import {PositionalOption} from "./directive/options/PositionalOption";
|
||||
import {FlagOption} from "./directive/options/FlagOption";
|
||||
import {AppClass} from "../lifecycle/AppClass";
|
||||
import {Logging} from "../service/Logging";
|
||||
|
||||
/**
|
||||
* Type alias for a definition of a command-line option.
|
||||
*
|
||||
* This can be either an instance of CLIOption or a string describing an option.
|
||||
*
|
||||
* @example
|
||||
* Some examples of positional/flag options defined by strings:
|
||||
* `'{file name} | canonical name of the resource to create'`
|
||||
*
|
||||
* `'--push -p {value} | the value to be pushed'`
|
||||
*
|
||||
* `'--force -f | do a force push'`
|
||||
*/
|
||||
export type OptionDefinition = CLIOption<any> | string
|
||||
|
||||
/**
|
||||
* An error thrown when an invalid option was detected.
|
||||
*/
|
||||
export class OptionValidationError extends ErrorWithContext {}
|
||||
|
||||
/**
|
||||
* A base class representing a sub-command in the command-line utility.
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class Directive extends AppClass {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** Parsed option values. */
|
||||
private _optionValues: any
|
||||
|
||||
/**
|
||||
* Get the keyword or array of keywords that will specify this directive.
|
||||
*
|
||||
* @example
|
||||
* If this returns `['up', 'start']`, the directive can be run by either of:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex up
|
||||
* ./ex start
|
||||
* ```
|
||||
*/
|
||||
public abstract getKeywords(): string | string[]
|
||||
|
||||
/**
|
||||
* Get the usage description of this directive. Should be brief (1 sentence).
|
||||
*/
|
||||
public abstract getDescription(): string
|
||||
|
||||
/**
|
||||
* Optionally, specify a longer usage text that is shown on the directive's `--help` page.
|
||||
*/
|
||||
public getHelpText(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of options defined for this command.
|
||||
*/
|
||||
public getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the directive is run from the command line.
|
||||
*
|
||||
* The raw arguments are provided as `argv`, but you are encouraged to use
|
||||
* `getOptions()` and `option()` helpers to access the parsed options instead.
|
||||
*
|
||||
* @param argv
|
||||
*/
|
||||
public abstract handle(argv: string[]): void | Promise<void>
|
||||
|
||||
/**
|
||||
* Sets the parsed option values.
|
||||
* @param optionValues
|
||||
* @private
|
||||
*/
|
||||
private _setOptionValues(optionValues: any) {
|
||||
this._optionValues = optionValues;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of a parsed option. If none exists, return `defaultValue`.
|
||||
* @param name
|
||||
* @param defaultValue
|
||||
*/
|
||||
public option(name: string, defaultValue?: any) {
|
||||
if ( name in this._optionValues ) {
|
||||
return this._optionValues[name]
|
||||
}
|
||||
|
||||
return defaultValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Invoke this directive with the specified arguments.
|
||||
*
|
||||
* If usage was requested (see `didRequestUsage()`), it prints the extended usage info.
|
||||
*
|
||||
* Otherwise, it parses the options from `argv` and calls `handle()`.
|
||||
*
|
||||
* @param argv
|
||||
*/
|
||||
async invoke(argv: string[]) {
|
||||
const options = this.getResolvedOptions()
|
||||
|
||||
if ( this.didRequestUsage(argv) ) {
|
||||
// @ts-ignore
|
||||
const positionalArguments: PositionalOption<any>[] = options.filter(opt => opt instanceof PositionalOption)
|
||||
|
||||
// @ts-ignore
|
||||
const flagArguments: FlagOption<any>[] = options.filter(opt => opt instanceof FlagOption)
|
||||
|
||||
const positionalDisplay: string = positionalArguments.map(x => `<${x.getArgumentName()}>`).join(' ')
|
||||
const flagDisplay: string = flagArguments.length ? ' [...flags]' : ''
|
||||
|
||||
console.log([
|
||||
'',
|
||||
`DIRECTIVE: ${this.getMainKeyword()} - ${this.getDescription()}`,
|
||||
'',
|
||||
`USAGE: ${this.getMainKeyword()} ${positionalDisplay}${flagDisplay}`,
|
||||
].join('\n'))
|
||||
|
||||
if ( positionalArguments.length ) {
|
||||
console.log([
|
||||
'',
|
||||
`POSITIONAL ARGUMENTS:`,
|
||||
...(positionalArguments.map(arg => {
|
||||
return ` ${arg.getArgumentName()}${arg.message ? ' - ' + arg.message : ''}`
|
||||
})),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
if ( flagArguments.length ) {
|
||||
console.log([
|
||||
'',
|
||||
`FLAGS:`,
|
||||
...(flagArguments.map(arg => {
|
||||
return ` ${arg.shortFlag ? arg.shortFlag + ', ' : ''}${arg.longFlag}${arg.argumentDescription ? ' {' + arg.argumentDescription + '}' : ''}${arg.message ? ' - ' + arg.message : ''}`
|
||||
})),
|
||||
].join('\n'))
|
||||
}
|
||||
|
||||
const help = this.getHelpText()
|
||||
if ( help ) {
|
||||
console.log('\n' + help)
|
||||
}
|
||||
|
||||
console.log('\n')
|
||||
} else {
|
||||
try {
|
||||
const optionValues = this.parseOptions(options, argv)
|
||||
this._setOptionValues(optionValues)
|
||||
await this.handle(argv)
|
||||
} catch (e) {
|
||||
console.error(e.message)
|
||||
if ( e instanceof OptionValidationError ) {
|
||||
// expecting, value, requirements
|
||||
if ( e.context.expecting ) {
|
||||
console.error(` - Expecting: ${e.context.expecting}`)
|
||||
}
|
||||
|
||||
if ( e.context.requirements && Array.isArray(e.context.requirements) ) {
|
||||
for ( const req of e.context.requirements ) {
|
||||
console.error(` - ${req}`)
|
||||
}
|
||||
}
|
||||
|
||||
if ( e.context.value ) {
|
||||
console.error(` - ${e.context.value}`)
|
||||
}
|
||||
}
|
||||
console.error('\nUse --help for more info.')
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the array of option definitions to CLIOption instances.
|
||||
* Of note, this resolves the string-form definitions to actual CLIOption instances.
|
||||
*/
|
||||
public getResolvedOptions(): CLIOption<any>[] {
|
||||
return this.getOptions().map(option => {
|
||||
if ( typeof option === 'string' ) {
|
||||
return this.instantiateOptionFromString(option)
|
||||
} else {
|
||||
return option
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the main keyword displayed for this directive.
|
||||
* @example
|
||||
* If `getKeywords()` returns `['up', 'start']`, this will return `'up'`.
|
||||
*/
|
||||
public getMainKeyword(): string {
|
||||
const kws = this.getKeywords()
|
||||
|
||||
if ( Array.isArray(kws) ) {
|
||||
return kws[0]
|
||||
}
|
||||
|
||||
return kws
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given keyword should invoke this directive.
|
||||
* @param name
|
||||
*/
|
||||
public matchesKeyword(name: string) {
|
||||
let kws = this.getKeywords()
|
||||
if ( !Array.isArray(kws) ) kws = [kws]
|
||||
return kws.includes(name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as success text.
|
||||
* @param output
|
||||
*/
|
||||
success(output: any) {
|
||||
this.logging.success(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as error text.
|
||||
* @param output
|
||||
*/
|
||||
error(output: any) {
|
||||
this.logging.error(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as warning text.
|
||||
* @param output
|
||||
*/
|
||||
warn(output: any) {
|
||||
this.logging.warn(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as info text.
|
||||
* @param output
|
||||
*/
|
||||
info(output: any) {
|
||||
this.logging.info(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as debugging text.
|
||||
* @param output
|
||||
*/
|
||||
debug(output: any) {
|
||||
this.logging.debug(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Print the given output to the log as verbose text.
|
||||
* @param output
|
||||
*/
|
||||
verbose(output: any) {
|
||||
this.logging.verbose(output, true)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the flag option that signals help. Usually, this is named 'help'
|
||||
* and supports the flags '--help' and '-?'.
|
||||
*/
|
||||
getHelpOption() {
|
||||
return new FlagOption('--help', '-?', 'usage information about this directive')
|
||||
}
|
||||
|
||||
/**
|
||||
* Process the raw CLI arguments using an array of option class instances to build
|
||||
* a mapping of option names to provided values.
|
||||
*/
|
||||
parseOptions(options: CLIOption<any>[], args: string[]) {
|
||||
// @ts-ignore
|
||||
let positionalArguments: PositionalOption<any>[] = options.filter(cls => cls instanceof PositionalOption)
|
||||
|
||||
// @ts-ignore
|
||||
const flagArguments: FlagOption<any>[] = options.filter(cls => cls instanceof FlagOption)
|
||||
const optionValue: any = {}
|
||||
|
||||
flagArguments.push(this.getHelpOption())
|
||||
|
||||
let expectingFlagArgument = false
|
||||
let positionalFlagName = ''
|
||||
for ( const value of args ) {
|
||||
if ( value.startsWith('--') ) {
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Unexpected flag argument. Expecting argument for flag: ${positionalFlagName}`, {
|
||||
expecting: positionalFlagName,
|
||||
})
|
||||
} else {
|
||||
const flagArgument = flagArguments.filter(x => x.longFlag === value)
|
||||
if ( flagArgument.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
value,
|
||||
})
|
||||
} else {
|
||||
if ( flagArgument[0].argumentDescription ) {
|
||||
positionalFlagName = flagArgument[0].getArgumentName()
|
||||
expectingFlagArgument = true
|
||||
} else {
|
||||
optionValue[flagArgument[0].getArgumentName()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( value.startsWith('-') ) {
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
expecting: positionalFlagName,
|
||||
})
|
||||
} else {
|
||||
const flagArgument = flagArguments.filter(x => x.shortFlag === value)
|
||||
if ( flagArgument.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown flag argument: ${value}`, {
|
||||
value
|
||||
})
|
||||
} else {
|
||||
if ( flagArgument[0].argumentDescription ) {
|
||||
positionalFlagName = flagArgument[0].getArgumentName()
|
||||
expectingFlagArgument = true
|
||||
} else {
|
||||
optionValue[flagArgument[0].getArgumentName()] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if ( expectingFlagArgument ) {
|
||||
const inferredValue = infer(value)
|
||||
const optionInstance = flagArguments.filter(x => x.getArgumentName() === positionalFlagName)[0]
|
||||
if ( !optionInstance.validate(inferredValue) ) {
|
||||
throw new OptionValidationError(`Invalid value for argument: ${positionalFlagName}`, {
|
||||
requirements: optionInstance.getRequirementDisplays(),
|
||||
})
|
||||
}
|
||||
|
||||
optionValue[positionalFlagName] = inferredValue
|
||||
expectingFlagArgument = false
|
||||
} else {
|
||||
if ( positionalArguments.length < 1 ) {
|
||||
throw new OptionValidationError(`Unknown positional argument: ${value}`, {
|
||||
value
|
||||
})
|
||||
} else {
|
||||
const inferredValue = infer(value)
|
||||
if ( !positionalArguments[0].validate(inferredValue) ) {
|
||||
throw new OptionValidationError(`Invalid value for argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||
requirements: positionalArguments[0].getRequirementDisplays(),
|
||||
})
|
||||
}
|
||||
|
||||
optionValue[positionalArguments[0].getArgumentName()] = infer(value)
|
||||
positionalArguments = positionalArguments.slice(1)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if ( expectingFlagArgument ) {
|
||||
throw new OptionValidationError(`Missing argument for flag: ${positionalFlagName}`, {
|
||||
expecting: positionalFlagName
|
||||
})
|
||||
}
|
||||
|
||||
if ( positionalArguments.length > 0 ) {
|
||||
throw new OptionValidationError(`Missing required argument: ${positionalArguments[0].getArgumentName()}`, {
|
||||
expecting: positionalArguments[0].getArgumentName()
|
||||
})
|
||||
}
|
||||
|
||||
return optionValue
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of CLIOption based on a string definition of a particular format.
|
||||
*
|
||||
* e.g. '{file name} | canonical name of the resource to create'
|
||||
* e.g. '--push -p {value} | the value to be pushed'
|
||||
* e.g. '--force -f | do a force push'
|
||||
*
|
||||
* @param string
|
||||
*/
|
||||
protected instantiateOptionFromString(string: string): CLIOption<any> {
|
||||
if ( string.startsWith('{') ) {
|
||||
// The string is a positional argument
|
||||
const stringParts = string.split('|').map(x => x.trim())
|
||||
const name = stringParts[0].replace(/\{|\}/g, '')
|
||||
return stringParts.length > 1 ? (new PositionalOption(name, stringParts[1])) : (new PositionalOption(name))
|
||||
} else {
|
||||
// The string is a flag argument
|
||||
const stringParts = string.split('|').map(x => x.trim())
|
||||
|
||||
// Parse the flag parts first
|
||||
const hasArgument = stringParts[0].indexOf('{') >= 0
|
||||
const flagString = hasArgument ? stringParts[0].substr(0, stringParts[0].indexOf('{')).trim() : stringParts[0].trim()
|
||||
const flagParts = flagString.split(' ')
|
||||
|
||||
let longFlag = flagParts[0].startsWith('--') ? flagParts[0] : undefined
|
||||
if ( !longFlag && flagParts.length > 1 ) {
|
||||
if ( flagParts[1].startsWith('--') ) {
|
||||
longFlag = flagParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
let shortFlag = flagParts[0].length === 2 ? flagParts[0] : undefined
|
||||
if ( !shortFlag && flagParts.length > 1 ) {
|
||||
if ( flagParts[1].length === 2 ) {
|
||||
shortFlag = flagParts[1]
|
||||
}
|
||||
}
|
||||
|
||||
const argumentDescription = hasArgument ? stringParts[0].substring(stringParts[0].indexOf('{')+1, stringParts[0].indexOf('}')) : undefined
|
||||
const description = stringParts.length > 1 ? stringParts[1] : undefined
|
||||
|
||||
return new FlagOption(longFlag, shortFlag, description, argumentDescription)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Determines if, at any point in the arguments, the help option's short or long flag appears.
|
||||
* @returns {boolean} - true if the help flag appeared
|
||||
*/
|
||||
didRequestUsage(argv: string[]) {
|
||||
const help_option = this.getHelpOption()
|
||||
for ( const arg of argv ) {
|
||||
if ( arg.trim() === help_option.longFlag || arg.trim() === help_option.shortFlag ) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
64
src/cli/Template.ts
Normal file
64
src/cli/Template.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import {UniversalPath} from '../util'
|
||||
|
||||
/**
|
||||
* Interface defining a template that can be generated using the TemplateDirective.
|
||||
*/
|
||||
export interface Template {
|
||||
/**
|
||||
* The name of the template as it will be specified from the command line.
|
||||
*
|
||||
* @example
|
||||
* If this is `'mytemplate'`, then the template will be created with:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex new mytemplate some:path
|
||||
* ```
|
||||
*/
|
||||
name: string,
|
||||
|
||||
/**
|
||||
* The suffix of the file generated by this template.
|
||||
* @example `.mytemplate.ts`
|
||||
* @example `.controller.ts`
|
||||
*/
|
||||
fileSuffix: string,
|
||||
|
||||
/**
|
||||
* Brief description of the template displayed on the --help page for the TemplateDirective.
|
||||
* Should be brief (1 sentence).
|
||||
*/
|
||||
description: string,
|
||||
|
||||
/**
|
||||
* Array of path-strings that are resolved relative to the base `app` directory.
|
||||
* @example `['http', 'controllers']`
|
||||
* @example `['units']`
|
||||
*/
|
||||
baseAppPath: string[],
|
||||
|
||||
/**
|
||||
* Render the given template to a string which will be written to the file.
|
||||
* Note: this method should NOT write the contents to `targetFilePath`.
|
||||
*
|
||||
* @example
|
||||
* If the user enters:
|
||||
*
|
||||
* ```shell
|
||||
* ./ex new mytemplate path:to:NewInstance
|
||||
* ```
|
||||
*
|
||||
* Then, the following params are:
|
||||
* ```typescript
|
||||
* {
|
||||
* name: 'NewInstance',
|
||||
* fullCanonicalPath: 'path:to:NewInstance',
|
||||
* targetFilePath: UniversalPath { }
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param name - the singular name of the resource
|
||||
* @param fullCanonicalName - the full canonical name of the resource
|
||||
* @param targetFilePath - the UniversalPath where the file will be written
|
||||
*/
|
||||
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => string | Promise<string>
|
||||
}
|
31
src/cli/directive/RunDirective.ts
Normal file
31
src/cli/directive/RunDirective.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {Directive} from "../Directive"
|
||||
import {CommandLineApplication} from "../service"
|
||||
import {Injectable} from "../../di"
|
||||
import {ErrorWithContext} from "../../util"
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
|
||||
/**
|
||||
* A directive that starts the framework's final target normally.
|
||||
* In most cases, this runs the HTTP server, which would have been replaced
|
||||
* by the CommandLineApplication unit.
|
||||
*/
|
||||
@Injectable()
|
||||
export class RunDirective extends Directive {
|
||||
getDescription(): string {
|
||||
return 'run the application normally'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['run', 'up']
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
if ( !CommandLineApplication.getReplacement() ) {
|
||||
throw new ErrorWithContext(`Cannot run application: no run target specified.`)
|
||||
}
|
||||
|
||||
const unit = <Unit> this.make(CommandLineApplication.getReplacement())
|
||||
await this.app().startUnit(unit)
|
||||
await this.app().stopUnit(unit)
|
||||
}
|
||||
}
|
47
src/cli/directive/ShellDirective.ts
Normal file
47
src/cli/directive/ShellDirective.ts
Normal file
@ -0,0 +1,47 @@
|
||||
import {Directive} from "../Directive"
|
||||
import * as colors from "colors/safe"
|
||||
import * as repl from 'repl'
|
||||
import {DependencyKey} from "../../di";
|
||||
|
||||
/**
|
||||
* Launch an interactive REPL shell from within the application.
|
||||
* This is very useful for debugging and testing things during development.
|
||||
*/
|
||||
export class ShellDirective extends Directive {
|
||||
protected options: any = {
|
||||
welcome: `powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills\nAccess your application using the "app" global.`,
|
||||
prompt: `${colors.blue('(')}extollo${colors.blue(') ➤ ')}`,
|
||||
}
|
||||
|
||||
/**
|
||||
* The created Node.js REPL server.
|
||||
* @protected
|
||||
*/
|
||||
protected repl?: repl.REPLServer
|
||||
|
||||
getDescription(): string {
|
||||
return 'launch an interactive shell inside your application'
|
||||
}
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['shell']
|
||||
}
|
||||
|
||||
getHelpText(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
async handle(): Promise<void> {
|
||||
const state: any = {
|
||||
app: this.app(),
|
||||
make: (target: DependencyKey, ...parameters: any[]) => this.make(target, ...parameters),
|
||||
}
|
||||
|
||||
await new Promise<void>(res => {
|
||||
console.log(this.options.welcome)
|
||||
this.repl = repl.start(this.options.prompt)
|
||||
Object.assign(this.repl.context, state)
|
||||
this.repl.on('exit', () => res())
|
||||
})
|
||||
}
|
||||
}
|
90
src/cli/directive/TemplateDirective.ts
Normal file
90
src/cli/directive/TemplateDirective.ts
Normal file
@ -0,0 +1,90 @@
|
||||
import {Directive, OptionDefinition} from "../Directive"
|
||||
import {PositionalOption} from "./options/PositionalOption"
|
||||
import {CommandLine} from "../service"
|
||||
import {Inject, Injectable} from "../../di";
|
||||
import {ErrorWithContext} from "../../util";
|
||||
|
||||
/**
|
||||
* Create a new file based on a template registered with the CommandLine service.
|
||||
*/
|
||||
@Injectable()
|
||||
export class TemplateDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
getKeywords(): string | string[] {
|
||||
return ['new', 'make']
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return 'create a new file from a registered template'
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
const registeredTemplates = this.cli.getTemplates()
|
||||
const template = new PositionalOption('template_name', 'the template to base the new file on (e.g. model, controller)')
|
||||
template.whitelist(...registeredTemplates.pluck('name').all())
|
||||
|
||||
const destination = new PositionalOption('file_name', 'canonical name of the file to create (e.g. auth:Group, dash:Activity)')
|
||||
|
||||
return [template, destination]
|
||||
}
|
||||
|
||||
getHelpText(): string {
|
||||
const registeredTemplates = this.cli.getTemplates()
|
||||
|
||||
return [
|
||||
'Modules in Extollo register templates that can be used to quickly create common file types.',
|
||||
'',
|
||||
'For example, you can create a new model from @extollo/orm using the "model" template:',
|
||||
'',
|
||||
'./ex new model auth:Group',
|
||||
'',
|
||||
'This would create a new Group model in the ./src/app/models/auth/Group.model.ts file.',
|
||||
'',
|
||||
'AVAILABLE TEMPLATES:',
|
||||
'',
|
||||
...(registeredTemplates.map(template => {
|
||||
return ` - ${template.name}: ${template.description}`
|
||||
}).all())
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
async handle(argv: string[]) {
|
||||
const templateName: string = this.option('template_name')
|
||||
const destinationName: string = this.option('file_name')
|
||||
|
||||
if ( destinationName.includes('/') || destinationName.includes('\\') ) {
|
||||
this.error(`The destination should be a canonical name, not a file path.`)
|
||||
this.error(`Reference sub-directories using the : character instead.`)
|
||||
this.error(`Did you mean ${destinationName.replace(/\/|\\/g, ':')}?`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
const template = this.cli.getTemplate(templateName)
|
||||
if ( !template ) {
|
||||
throw new ErrorWithContext(`Unable to find template supposedly registered with name: ${templateName}`, {
|
||||
templateName,
|
||||
destinationName,
|
||||
})
|
||||
}
|
||||
|
||||
const name = destinationName.split(':').reverse()[0]
|
||||
const path = this.app().path('..', 'src', 'app', ...template.baseAppPath, ...(`${destinationName}${template.fileSuffix}`).split(':'))
|
||||
|
||||
if ( await path.exists() ) {
|
||||
this.error(`File already exists: ${path}`)
|
||||
process.exitCode = 1
|
||||
return
|
||||
}
|
||||
|
||||
// Make sure the parent direction exists
|
||||
await path.concat('..').mkdir()
|
||||
|
||||
const contents = await template.render(name, destinationName, path.clone())
|
||||
await path.write(contents)
|
||||
|
||||
this.success(`Created new ${template.name} in ${path}`)
|
||||
}
|
||||
}
|
54
src/cli/directive/UsageDirective.ts
Normal file
54
src/cli/directive/UsageDirective.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import {Directive} from "../Directive"
|
||||
import {Injectable, Inject} from "../../di"
|
||||
import {padRight} from "../../util"
|
||||
import {CommandLine} from "../service"
|
||||
|
||||
/**
|
||||
* Directive that prints the help message and usage information about
|
||||
* directives registered with the command line utility.
|
||||
*/
|
||||
@Injectable()
|
||||
export class UsageDirective extends Directive {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
public getKeywords(): string | string[] {
|
||||
return 'help'
|
||||
}
|
||||
|
||||
public getDescription(): string {
|
||||
return 'print information about available commands'
|
||||
}
|
||||
|
||||
public handle(argv: string[]): void | Promise<void> {
|
||||
const directiveStrings = this.cli.getDirectives()
|
||||
.map(cls => this.make<Directive>(cls))
|
||||
.map<[string, string]>(dir => {
|
||||
return [dir.getMainKeyword(), dir.getDescription()]
|
||||
})
|
||||
|
||||
const maxLen = directiveStrings.max<number>(x => x[0].length)
|
||||
|
||||
const printStrings = directiveStrings.map(grp => {
|
||||
return [padRight(grp[0], maxLen + 1), grp[1]]
|
||||
})
|
||||
.map(grp => {
|
||||
return ` ${grp[0]}: ${grp[1]}`
|
||||
})
|
||||
.toArray()
|
||||
|
||||
console.log(this.cli.getASCIILogo())
|
||||
console.log([
|
||||
'',
|
||||
'Welcome to Extollo! Specify a command to get started.',
|
||||
'',
|
||||
`USAGE: ex <directive> [..options]`,
|
||||
'',
|
||||
...printStrings,
|
||||
'',
|
||||
'For usage information about a particular command, pass the --help flag.',
|
||||
'-------------------------------------------',
|
||||
`powered by Extollo, © ${(new Date).getFullYear()} Garrett Mills`,
|
||||
].join('\n'))
|
||||
}
|
||||
}
|
244
src/cli/directive/options/CLIOption.ts
Normal file
244
src/cli/directive/options/CLIOption.ts
Normal file
@ -0,0 +1,244 @@
|
||||
/**
|
||||
* A CLI option. Supports basic comparative, and set-based validation.
|
||||
* @class
|
||||
*/
|
||||
export abstract class CLIOption<T> {
|
||||
|
||||
/**
|
||||
* Do we use the whitelist?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _useWhitelist: boolean = false
|
||||
|
||||
/**
|
||||
* Do we use the blacklist?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _useBlacklist: boolean = false
|
||||
|
||||
/**
|
||||
* Do we use the less-than comparison?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _useLessThan: boolean = false
|
||||
|
||||
/**
|
||||
* Do we use the greater-than comparison?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _useGreaterThan: boolean = false
|
||||
|
||||
/**
|
||||
* Do we use the equality operator?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _useEquality: boolean = false
|
||||
|
||||
/**
|
||||
* Is this option optional?
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _optional: boolean = false
|
||||
|
||||
/**
|
||||
* Whitelisted values.
|
||||
* @type {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
protected _whitelist: T[] = []
|
||||
|
||||
/**
|
||||
* Blacklisted values.
|
||||
* @type {Array<*>}
|
||||
* @private
|
||||
*/
|
||||
protected _blacklist: T[] = []
|
||||
|
||||
/**
|
||||
* Value to be compared in less than.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected _lessThanValue?: T
|
||||
|
||||
/**
|
||||
* If true, the less than will be less than or equal to.
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _lessThanBit: boolean = false
|
||||
|
||||
/**
|
||||
* Value to be compared in greater than.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected _greaterThanValue?: T
|
||||
|
||||
/**
|
||||
* If true, the greater than will be greater than or equal to.
|
||||
* @type {boolean}
|
||||
* @private
|
||||
*/
|
||||
protected _greateerThanBit: boolean = false
|
||||
|
||||
/**
|
||||
* The value to be used to check equality.
|
||||
* @type {*}
|
||||
* @private
|
||||
*/
|
||||
protected _equalityValue?: T
|
||||
|
||||
/**
|
||||
* Whitelist the specified item or items and enable the whitelist.
|
||||
* @param {...*} items - the items to whitelist
|
||||
*/
|
||||
whitelist(...items: T[]) {
|
||||
this._useWhitelist = true
|
||||
items.forEach(item => this._whitelist.push(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* Blacklist the specified item or items and enable the blacklist.
|
||||
* @param {...*} items - the items to blacklist
|
||||
*/
|
||||
blacklist(...items: T[]) {
|
||||
this._useBlacklist = true
|
||||
items.forEach(item => this._blacklist.push(item))
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in less-than comparison and enables less-than comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
lessThan(value: T) {
|
||||
this._useLessThan = true
|
||||
this._lessThanValue = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in less-than or equal-to comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
lessThanOrEqualTo(value: T) {
|
||||
this._lessThanBit = true
|
||||
this.lessThan(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in greater-than comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
greaterThan(value: T) {
|
||||
this._useGreaterThan = true
|
||||
this._greaterThanValue = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in greater-than or equal-to comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
greaterThanOrEqualTo(value: T) {
|
||||
this._greateerThanBit = true
|
||||
this.greaterThan(value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Specifies the value to be used in equality comparison and enables that comparison.
|
||||
* @param {*} value
|
||||
*/
|
||||
equals(value: T) {
|
||||
this._useEquality = true
|
||||
this._equalityValue = value
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the specified value passes the configured comparisons.
|
||||
* @param {*} value
|
||||
* @returns {boolean}
|
||||
*/
|
||||
validate(value: any) {
|
||||
let is_valid = true
|
||||
if ( this._useEquality ) {
|
||||
is_valid = is_valid && (this._equalityValue === value)
|
||||
}
|
||||
|
||||
if ( this._useLessThan && typeof this._lessThanValue !== 'undefined' ) {
|
||||
if ( this._lessThanBit ) {
|
||||
is_valid = is_valid && (value <= this._lessThanValue)
|
||||
} else {
|
||||
is_valid = is_valid && (value < this._lessThanValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ( this._useGreaterThan && typeof this._greaterThanValue !== 'undefined' ) {
|
||||
if ( this._greateerThanBit ) {
|
||||
is_valid = is_valid && (value >= this._greaterThanValue)
|
||||
} else {
|
||||
is_valid = is_valid && (value > this._greaterThanValue)
|
||||
}
|
||||
}
|
||||
|
||||
if ( this._useWhitelist ) {
|
||||
is_valid = is_valid && this._whitelist.some(x => {
|
||||
return x === value
|
||||
})
|
||||
}
|
||||
|
||||
if ( this._useBlacklist ) {
|
||||
is_valid = is_valid && !(this._blacklist.some(x => x === value))
|
||||
}
|
||||
|
||||
return is_valid
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the Option as optional.
|
||||
*/
|
||||
optional() {
|
||||
this._optional = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the argument name. Should be overridden by child classes.
|
||||
* @returns {string}
|
||||
*/
|
||||
abstract getArgumentName(): string
|
||||
|
||||
/**
|
||||
* Get an array of strings denoting the human-readable requirements for this option to be valid.
|
||||
* @returns {Array<string>}
|
||||
*/
|
||||
getRequirementDisplays() {
|
||||
const clauses = []
|
||||
|
||||
if ( this._useBlacklist ) {
|
||||
clauses.push(`must not be one of: ${this._blacklist.map(x => String(x)).join(', ')}`)
|
||||
}
|
||||
|
||||
if ( this._useWhitelist ) {
|
||||
clauses.push(`must be one of: ${this._whitelist.map(x => String(x)).join(', ')}`)
|
||||
}
|
||||
|
||||
if ( this._useGreaterThan ) {
|
||||
clauses.push(`must be greater than${this._greateerThanBit ? ' or equal to' : ''}: ${String(this._greaterThanValue)}`)
|
||||
}
|
||||
|
||||
if ( this._useLessThan ) {
|
||||
clauses.push(`must be less than${this._lessThanBit ? ' or equal to' : ''}: ${String(this._lessThanValue)}`)
|
||||
}
|
||||
|
||||
if ( this._useEquality ) {
|
||||
clauses.push(`must be equal to: ${String(this._equalityValue)}`)
|
||||
}
|
||||
|
||||
return clauses
|
||||
}
|
||||
}
|
45
src/cli/directive/options/FlagOption.ts
Normal file
45
src/cli/directive/options/FlagOption.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import {CLIOption} from "./CLIOption"
|
||||
|
||||
/**
|
||||
* Non-positional, flag-based CLI option.
|
||||
*/
|
||||
export class FlagOption<T> extends CLIOption<T> {
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The long-form flag for this option.
|
||||
* @example --path, --create
|
||||
*/
|
||||
public readonly longFlag?: string,
|
||||
/**
|
||||
* The short-form flag for this option.
|
||||
* @example -p, -c
|
||||
*/
|
||||
public readonly shortFlag?: string,
|
||||
/**
|
||||
* Usage message describing this flag.
|
||||
*/
|
||||
public readonly message?: string,
|
||||
/**
|
||||
* Description of the argument required by this flag.
|
||||
* If this is set, the flag will expect a positional argument to follow as a param.
|
||||
*/
|
||||
public readonly argumentDescription?: string
|
||||
) { super() }
|
||||
|
||||
/**
|
||||
* Get the referential name for this option.
|
||||
* Defaults to the long flag (without the '--'). If this cannot
|
||||
* be found, the short flag (without the '-') is used.
|
||||
* @returns {string}
|
||||
*/
|
||||
getArgumentName() {
|
||||
if ( this.longFlag ) {
|
||||
return this.longFlag.replace('--', '')
|
||||
} else if ( this.shortFlag ) {
|
||||
return this.shortFlag.replace('-', '')
|
||||
}
|
||||
|
||||
throw new Error('Missing either a long- or short-flag for FlagOption.')
|
||||
}
|
||||
}
|
32
src/cli/directive/options/PositionalOption.ts
Normal file
32
src/cli/directive/options/PositionalOption.ts
Normal file
@ -0,0 +1,32 @@
|
||||
import {CLIOption} from "./CLIOption"
|
||||
|
||||
/**
|
||||
* A positional CLI option. Defined without a flag.
|
||||
*/
|
||||
export class PositionalOption<T> extends CLIOption<T> {
|
||||
|
||||
/**
|
||||
* Instantiate the option.
|
||||
* @param {string} name - the name of the option
|
||||
* @param {string} message - message describing the option
|
||||
*/
|
||||
constructor(
|
||||
/**
|
||||
* The display name of this positional argument.
|
||||
* @example path, filename
|
||||
*/
|
||||
public readonly name: string,
|
||||
/**
|
||||
* A usage message describing this parameter.
|
||||
*/
|
||||
public readonly message: string = ''
|
||||
) { super() }
|
||||
|
||||
/**
|
||||
* Gets the name of the option.
|
||||
* @returns {string}
|
||||
*/
|
||||
getArgumentName () {
|
||||
return this.name
|
||||
}
|
||||
}
|
13
src/cli/index.ts
Normal file
13
src/cli/index.ts
Normal file
@ -0,0 +1,13 @@
|
||||
export * from './Directive'
|
||||
export * from './Template'
|
||||
|
||||
export * from './service/CommandLineApplication'
|
||||
export * from './service/CommandLine'
|
||||
|
||||
export * from './directive/options/CLIOption'
|
||||
export * from './directive/options/FlagOption'
|
||||
export * from './directive/options/PositionalOption'
|
||||
|
||||
export * from './directive/ShellDirective'
|
||||
export * from './directive/TemplateDirective'
|
||||
export * from './directive/UsageDirective'
|
124
src/cli/service/CommandLine.ts
Normal file
124
src/cli/service/CommandLine.ts
Normal file
@ -0,0 +1,124 @@
|
||||
import {Singleton, Instantiable, Inject} from "../../di"
|
||||
import {Collection} from "../../util"
|
||||
import {CommandLineApplication} from "./CommandLineApplication"
|
||||
import {Directive} from "../Directive"
|
||||
import {Template} from "../Template"
|
||||
import {directive_template} from "../templates/directive"
|
||||
import {unit_template} from "../templates/unit";
|
||||
import {controller_template} from "../templates/controller";
|
||||
import {middleware_template} from "../templates/middleware";
|
||||
import {routes_template} from "../templates/routes";
|
||||
import {config_template} from "../templates/config";
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Service for managing directives, templates, and other resources related
|
||||
* to the command line utilities.
|
||||
*/
|
||||
@Singleton()
|
||||
export class CommandLine extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** Directive classes registered with the CLI command. */
|
||||
protected directives: Collection<Instantiable<Directive>> = new Collection<Instantiable<Directive>>()
|
||||
|
||||
/** Templates registered with the CLI command. These can be created with the TemplateDirective. */
|
||||
protected templates: Collection<Template> = new Collection<Template>()
|
||||
|
||||
constructor() { super() }
|
||||
|
||||
async up() {
|
||||
this.registerTemplate(directive_template)
|
||||
this.registerTemplate(unit_template)
|
||||
this.registerTemplate(controller_template)
|
||||
this.registerTemplate(middleware_template)
|
||||
this.registerTemplate(routes_template)
|
||||
this.registerTemplate(config_template)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the application was started from the command line.
|
||||
*/
|
||||
public isCLI() {
|
||||
return this.app().hasUnit(CommandLineApplication)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a string containing the Extollo ASCII logo.
|
||||
*/
|
||||
public getASCIILogo() {
|
||||
return ` _
|
||||
/ /\\ ______ _ _ _
|
||||
/ / \\ | ____| | | | | |
|
||||
/ / /\\ \\ | |__ __ _| |_ ___ | | | ___
|
||||
/ / /\\ \\ \\ | __| \\ \\/ / __/ _ \\| | |/ _ \\
|
||||
/ / / \\ \\_\\ | |____ > <| || (_) | | | (_) |
|
||||
\\/_/ \\/_/ |______/_/\\_\\\\__\\___/|_|_|\\___/
|
||||
`
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a Directive class with this service. This will make
|
||||
* the directive available for use on the CLI.
|
||||
* @param directiveClass
|
||||
*/
|
||||
public registerDirective(directiveClass: Instantiable<Directive>) {
|
||||
if ( !this.directives.includes(directiveClass) ) {
|
||||
this.directives.push(directiveClass)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given directive is registered with this service.
|
||||
* @param directiveClass
|
||||
*/
|
||||
public hasDirective(directiveClass: Instantiable<Directive>) {
|
||||
return this.directives.includes(directiveClass)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of all registered directives.
|
||||
*/
|
||||
public getDirectives() {
|
||||
return this.directives.clone()
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given template with this service. This makes the template
|
||||
* available for use with the TemplateDirective service.
|
||||
* @param template
|
||||
*/
|
||||
public registerTemplate(template: Template) {
|
||||
if ( !this.templates.firstWhere('name', '=', template.name) ) {
|
||||
this.templates.push(template)
|
||||
} else {
|
||||
this.logging.warn(`Duplicate template will not be registered: ${template.name}`)
|
||||
this.logging.debug(`Duplicate template registered at: ${(new Error()).stack}`)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a template with the given name exists.
|
||||
* @param name
|
||||
*/
|
||||
public hasTemplate(name: string) {
|
||||
return !!this.templates.firstWhere('name', '=', name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the template with the given name, if one exists.
|
||||
* @param name
|
||||
*/
|
||||
public getTemplate(name: string): Template | undefined {
|
||||
return this.templates.firstWhere('name', '=', name)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of all registered templates.
|
||||
*/
|
||||
public getTemplates() {
|
||||
return this.templates.clone()
|
||||
}
|
||||
}
|
56
src/cli/service/CommandLineApplication.ts
Normal file
56
src/cli/service/CommandLineApplication.ts
Normal file
@ -0,0 +1,56 @@
|
||||
import {Unit} from "../../lifecycle/Unit"
|
||||
import {Logging} from "../../service/Logging";
|
||||
import {Singleton, Inject} from "../../di/decorator/injection"
|
||||
import {CommandLine} from "./CommandLine"
|
||||
import {UsageDirective} from "../directive/UsageDirective";
|
||||
import {Directive} from "../Directive";
|
||||
import {ShellDirective} from "../directive/ShellDirective";
|
||||
import {TemplateDirective} from "../directive/TemplateDirective";
|
||||
import {RunDirective} from "../directive/RunDirective";
|
||||
|
||||
/**
|
||||
* Unit that takes the place of the final unit in the application that handles
|
||||
* invocations from the command line.
|
||||
*/
|
||||
@Singleton()
|
||||
export class CommandLineApplication extends Unit {
|
||||
/** The unit that was replaced by the CLI app. */
|
||||
private static replacement?: typeof Unit
|
||||
|
||||
/** Set the replaced unit. */
|
||||
public static setReplacement(unitClass?: typeof Unit) {
|
||||
this.replacement = unitClass
|
||||
}
|
||||
|
||||
/** Get the replaced unit. */
|
||||
public static getReplacement() {
|
||||
return this.replacement
|
||||
}
|
||||
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
constructor() { super() }
|
||||
|
||||
public async up() {
|
||||
this.cli.registerDirective(UsageDirective)
|
||||
this.cli.registerDirective(ShellDirective)
|
||||
this.cli.registerDirective(TemplateDirective)
|
||||
this.cli.registerDirective(RunDirective)
|
||||
|
||||
const argv = process.argv.slice(2)
|
||||
const match = this.cli.getDirectives()
|
||||
.map(dirCls => this.make<Directive>(dirCls))
|
||||
.firstWhere(dir => dir.matchesKeyword(argv[0]))
|
||||
|
||||
if ( match ) {
|
||||
await match.invoke(argv.slice(1))
|
||||
} else {
|
||||
const usage = this.make<UsageDirective>(UsageDirective)
|
||||
await usage.handle(process.argv)
|
||||
}
|
||||
}
|
||||
}
|
2
src/cli/service/index.ts
Normal file
2
src/cli/service/index.ts
Normal file
@ -0,0 +1,2 @@
|
||||
export * from './CommandLine'
|
||||
export * from './CommandLineApplication'
|
22
src/cli/templates/config.ts
Normal file
22
src/cli/templates/config.ts
Normal file
@ -0,0 +1,22 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* A template that generates a new configuration file in the app/configs directory.
|
||||
*/
|
||||
const config_template: Template = {
|
||||
name: 'config',
|
||||
fileSuffix: '.config.ts',
|
||||
description: 'Create a new config file.',
|
||||
baseAppPath: ['configs'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import { env } from '@extollo/lib'
|
||||
|
||||
export default {
|
||||
key: env('VALUE_ENV_VAR', 'default value'),
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { config_template }
|
31
src/cli/templates/controller.ts
Normal file
31
src/cli/templates/controller.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template that generates a new controller in the app/http/controllers directory.
|
||||
*/
|
||||
const controller_template: Template = {
|
||||
name: 'controller',
|
||||
fileSuffix: '.controller.ts',
|
||||
description: 'Create a controller class that can be used to handle requests.',
|
||||
baseAppPath: ['http', 'controllers'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {Controller, view} from "@extollo/lib"
|
||||
import {Inject, Injectable} from "@extollo/di"
|
||||
|
||||
/**
|
||||
* ${name} Controller
|
||||
* ------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Controller {
|
||||
public ${name.toLowerCase()}() {
|
||||
return view('${name.toLowerCase()}')
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { controller_template }
|
43
src/cli/templates/directive.ts
Normal file
43
src/cli/templates/directive.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template that generates a new Directive class in the app/directives directory.
|
||||
*/
|
||||
const directive_template: Template = {
|
||||
name: 'directive',
|
||||
fileSuffix: '.directive.ts',
|
||||
description: 'Create a new Directive class which adds functionality to the ./ex command.',
|
||||
baseAppPath: ['directives'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {Directive, OptionDefinition} from "@extollo/cli"
|
||||
import {Injectable} from "@extollo/di"
|
||||
|
||||
/**
|
||||
* ${name} Directive
|
||||
* ---------------------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name}Directive extends Directive {
|
||||
getKeywords(): string | string[] {
|
||||
return ['${name.toLowerCase()}']
|
||||
}
|
||||
|
||||
getDescription(): string {
|
||||
return ''
|
||||
}
|
||||
|
||||
getOptions(): OptionDefinition[] {
|
||||
return []
|
||||
}
|
||||
|
||||
async handle(argv: string[]) {
|
||||
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { directive_template }
|
31
src/cli/templates/middleware.ts
Normal file
31
src/cli/templates/middleware.ts
Normal file
@ -0,0 +1,31 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template that generates a new middleware class in app/http/middlewares.
|
||||
*/
|
||||
const middleware_template: Template = {
|
||||
name: 'middleware',
|
||||
fileSuffix: '.middleware.ts',
|
||||
description: 'Create a middleware class that can be applied to routes.',
|
||||
baseAppPath: ['http', 'middlewares'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {Middleware} from "@extollo/lib"
|
||||
import {Injectable} from "@extollo/di"
|
||||
|
||||
/**
|
||||
* ${name} Middleware
|
||||
* --------------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Middleware {
|
||||
public async apply() {
|
||||
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { middleware_template }
|
26
src/cli/templates/routes.ts
Normal file
26
src/cli/templates/routes.ts
Normal file
@ -0,0 +1,26 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template that generates a new route definition file in app/http/routes.
|
||||
*/
|
||||
const routes_template: Template = {
|
||||
name: 'routes',
|
||||
fileSuffix: '.routes.ts',
|
||||
description: 'Create a file for route definitions.',
|
||||
baseAppPath: ['http', 'routes'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {Route} from "@extollo/lib"
|
||||
|
||||
/*
|
||||
* ${name} Routes
|
||||
* -------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
|
||||
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { routes_template }
|
38
src/cli/templates/unit.ts
Normal file
38
src/cli/templates/unit.ts
Normal file
@ -0,0 +1,38 @@
|
||||
import {Template} from "../Template"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template that generates a new application unit class in app/units.
|
||||
*/
|
||||
const unit_template: Template = {
|
||||
name: 'unit',
|
||||
fileSuffix: '.ts',
|
||||
description: 'Create a service unit that will start and stop with your application.',
|
||||
baseAppPath: ['units'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {Singleton, Inject} from "@extollo/di"
|
||||
import {Unit, Logging} from "@extollo/lib"
|
||||
|
||||
/**
|
||||
* ${name} Unit
|
||||
* ---------------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Singleton()
|
||||
export class ${name} extends Unit {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public async up() {
|
||||
this.logging.info('${name} has started!')
|
||||
}
|
||||
|
||||
public async down() {
|
||||
this.logging.info('${name} has stopped!')
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { unit_template }
|
11
src/cli/tsconfig.json
Normal file
11
src/cli/tsconfig.json
Normal file
@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"module": "commonjs",
|
||||
"target": "es5",
|
||||
"sourceMap": true,
|
||||
"experimentalDecorators": true
|
||||
},
|
||||
"exclude": [
|
||||
"node_modules"
|
||||
]
|
||||
}
|
272
src/di/Container.ts
Normal file
272
src/di/Container.ts
Normal file
@ -0,0 +1,272 @@
|
||||
import {DependencyKey, InstanceRef, Instantiable, isInstantiable, StaticClass} from "./types";
|
||||
import {AbstractFactory} from "./factory/AbstractFactory";
|
||||
import {collect, Collection, globalRegistry, logIfDebugging} from "../util";
|
||||
import {Factory} from "./factory/Factory";
|
||||
import {DuplicateFactoryKeyError} from "./error/DuplicateFactoryKeyError";
|
||||
import {ClosureFactory} from "./factory/ClosureFactory";
|
||||
import NamedFactory from "./factory/NamedFactory";
|
||||
import SingletonFactory from "./factory/SingletonFactory";
|
||||
import {InvalidDependencyKeyError} from "./error/InvalidDependencyKeyError";
|
||||
|
||||
export type MaybeFactory = AbstractFactory | undefined
|
||||
export type MaybeDependency = any | undefined
|
||||
export type ResolvedDependency = { paramIndex: number, key: DependencyKey, resolved: any }
|
||||
|
||||
/**
|
||||
* A container of resolve-able dependencies that are created via inversion-of-control.
|
||||
*/
|
||||
export class Container {
|
||||
/**
|
||||
* Get the global instance of this container.
|
||||
*/
|
||||
public static getContainer(): Container {
|
||||
const existing = <Container | undefined> globalRegistry.getGlobal('extollo/injector')
|
||||
if ( !existing ) {
|
||||
const container = new Container()
|
||||
globalRegistry.setGlobal('extollo/injector', container)
|
||||
return container
|
||||
}
|
||||
|
||||
return existing
|
||||
}
|
||||
|
||||
/**
|
||||
* Collection of factories registered with this container.
|
||||
* @type Collection<AbstractFactory>
|
||||
*/
|
||||
protected factories: Collection<AbstractFactory> = new Collection<AbstractFactory>()
|
||||
|
||||
/**
|
||||
* Collection of singleton instances produced by this container.
|
||||
* @type Collection<InstanceRef>
|
||||
*/
|
||||
protected instances: Collection<InstanceRef> = new Collection<InstanceRef>()
|
||||
|
||||
constructor() {
|
||||
this.registerSingletonInstance<Container>(Container, this)
|
||||
this.registerSingleton('injector', this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container.
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
register(dependency: Instantiable<any>) {
|
||||
if ( this.resolve(dependency) )
|
||||
throw new DuplicateFactoryKeyError(dependency)
|
||||
|
||||
const factory = new Factory(dependency)
|
||||
this.factories.push(factory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the given function as a factory within the container.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {function} producer - factory to produce a value
|
||||
*/
|
||||
registerProducer(name: string | StaticClass<any, any>, producer: () => any) {
|
||||
if ( this.resolve(name) )
|
||||
throw new DuplicateFactoryKeyError(name)
|
||||
|
||||
const factory = new ClosureFactory(name, producer)
|
||||
this.factories.push(factory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a basic instantiable class as a standard Factory with this container,
|
||||
* identified by a string name rather than static class.
|
||||
* @param {string} name - unique name to identify the factory in the container
|
||||
* @param {Instantiable} dependency
|
||||
*/
|
||||
registerNamed(name: string, dependency: Instantiable<any>) {
|
||||
if ( this.resolve(name) )
|
||||
throw new DuplicateFactoryKeyError(name)
|
||||
|
||||
const factory = new NamedFactory(name, dependency)
|
||||
this.factories.push(factory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a value as a singleton in the container. It will not be instantiated, but
|
||||
* can be injected by its unique name.
|
||||
* @param {string} key - unique name to identify the singleton in the container
|
||||
* @param value
|
||||
*/
|
||||
registerSingleton(key: string, value: any) {
|
||||
if ( this.resolve(key) )
|
||||
throw new DuplicateFactoryKeyError(key)
|
||||
|
||||
this.factories.push(new SingletonFactory(value, key))
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a static class to the container along with its already-instantiated
|
||||
* instance that will be used to resolve the class.
|
||||
* @param staticClass
|
||||
* @param instance
|
||||
*/
|
||||
registerSingletonInstance<T>(staticClass: Instantiable<T>, instance: T) {
|
||||
if ( this.resolve(staticClass) )
|
||||
throw new DuplicateFactoryKeyError(staticClass)
|
||||
|
||||
this.register(staticClass)
|
||||
this.instances.push({
|
||||
key: staticClass,
|
||||
value: instance,
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Register a given factory with the container.
|
||||
* @param {AbstractFactory} factory
|
||||
*/
|
||||
registerFactory(factory: AbstractFactory) {
|
||||
if ( !this.factories.includes(factory) )
|
||||
this.factories.push(factory)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has an already-produced value for the given key.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
hasInstance(key: DependencyKey): boolean {
|
||||
return this.instances.where('key', '=', key).isNotEmpty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the container has a factory for the given key.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
hasKey(key: DependencyKey): boolean {
|
||||
return !!this.resolve(key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the already-produced value for the given key, if one exists.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||
const instances = this.instances.where('key', '=', key)
|
||||
if ( instances.isNotEmpty() ) return instances.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the factory for the given key, if one is registered with this container.
|
||||
* @param {DependencyKey} key
|
||||
*/
|
||||
resolve(key: DependencyKey): MaybeFactory {
|
||||
const factory = this.factories.firstWhere(item => item.match(key))
|
||||
if ( factory ) return factory
|
||||
else logIfDebugging('extollo.di.injector', 'unable to resolve factory', factory, this.factories)
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the dependency key. If a singleton value for that key already exists in this container,
|
||||
* return that value. Otherwise, use the factory an given parameters to produce and return the value.
|
||||
* @param {DependencyKey} key
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
resolveAndCreate(key: DependencyKey, ...parameters: any[]): any {
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate', key, {parameters})
|
||||
|
||||
// If we've already instantiated this, just return that
|
||||
const instance = this.getExistingInstance(key)
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate existing instance?', instance)
|
||||
if ( typeof instance !== 'undefined' ) return instance.value
|
||||
|
||||
// Otherwise, attempt to create it
|
||||
const factory = this.resolve(key)
|
||||
logIfDebugging('extollo.di.injector', 'resolveAndCreate factory', factory)
|
||||
if ( !factory )
|
||||
throw new InvalidDependencyKeyError(key)
|
||||
|
||||
// Produce and store a new instance
|
||||
const new_instance = this.produceFactory(factory, parameters)
|
||||
this.instances.push({
|
||||
key,
|
||||
value: new_instance,
|
||||
})
|
||||
|
||||
return new_instance
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a factory and manually-provided parameters, resolve the dependencies for the
|
||||
* factory and produce its value.
|
||||
* @param {AbstractFactory} factory
|
||||
* @param {array} parameters
|
||||
*/
|
||||
protected produceFactory(factory: AbstractFactory, parameters: any[]) {
|
||||
// Create the dependencies for the factory
|
||||
const keys = factory.getDependencyKeys().filter(req => this.hasKey(req.key))
|
||||
const dependencies = keys.map<ResolvedDependency>(req => {
|
||||
return {
|
||||
paramIndex: req.paramIndex,
|
||||
key: req.key,
|
||||
resolved: this.resolveAndCreate(req.key),
|
||||
}
|
||||
}).sortBy('paramIndex')
|
||||
|
||||
// Build the arguments for the factory, using dependencies in the
|
||||
// correct paramIndex positions, or parameters of we don't have
|
||||
// the dependency.
|
||||
const construction_args = []
|
||||
let params = collect(parameters).reverse()
|
||||
for ( let i = 0; i <= dependencies.max('paramIndex'); i++ ) {
|
||||
const dep = dependencies.firstWhere('paramIndex', '=', i)
|
||||
if ( dep ) construction_args.push(dep.resolved)
|
||||
else construction_args.push(params.pop())
|
||||
}
|
||||
|
||||
// Produce a new instance
|
||||
const inst = factory.produce(construction_args, params.reverse().all())
|
||||
|
||||
factory.getInjectedProperties().each(dependency => {
|
||||
if ( dependency.key && inst ) {
|
||||
inst[dependency.property] = this.resolveAndCreate(dependency.key)
|
||||
}
|
||||
})
|
||||
|
||||
return inst
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an instance of the given target. The target can either be a DependencyKey registered with
|
||||
* this container (in which case, the singleton value will be returned), or an instantiable class.
|
||||
*
|
||||
* If the instantiable class has the Injectable decorator, its injectable parameters will be automatically
|
||||
* injected into the instance.
|
||||
* @param {DependencyKey} target
|
||||
* @param {...any} parameters
|
||||
*/
|
||||
make<T>(target: DependencyKey, ...parameters: any[]): T {
|
||||
if ( this.hasKey(target) )
|
||||
return this.resolveAndCreate(target, ...parameters)
|
||||
else if ( typeof target !== 'string' && isInstantiable(target) )
|
||||
return this.produceFactory(new Factory(target), parameters)
|
||||
else
|
||||
throw new TypeError(`Invalid or unknown make target: ${target}`)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a collection of dependency keys required by the given target, if it is registered with this container.
|
||||
* @param {DependencyKey} target
|
||||
*/
|
||||
getDependencies(target: DependencyKey): Collection<DependencyKey> {
|
||||
const factory = this.resolve(target)
|
||||
|
||||
if ( !factory )
|
||||
throw new InvalidDependencyKeyError(target)
|
||||
|
||||
return factory.getDependencyKeys().pluck('key')
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a different container, copy the factories and instances from this container over to it.
|
||||
* @param container
|
||||
*/
|
||||
cloneTo(container: Container) {
|
||||
container.factories = this.factories.clone()
|
||||
container.instances = this.instances.clone()
|
||||
}
|
||||
}
|
61
src/di/ScopedContainer.ts
Normal file
61
src/di/ScopedContainer.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {Container, MaybeDependency, MaybeFactory} from "./Container"
|
||||
import {DependencyKey} from "./types"
|
||||
|
||||
/**
|
||||
* A container that uses some parent container as a base, but
|
||||
* can have other factories distinct from that parent.
|
||||
*
|
||||
* If an instance is not found in this container, it will be resolved from
|
||||
* the parent container.
|
||||
*
|
||||
* However, if an instance IS found in this container, it will ALWAYS be
|
||||
* resolved from this container, rather than the parent.
|
||||
*
|
||||
* This can be used to create scope-specific containers that can still resolve
|
||||
* the global dependencies, while keeping scope-specific dependencies separate.
|
||||
*
|
||||
* @example
|
||||
* The Request class from @extollo/lib is a ScopedContainer. It can resolve
|
||||
* all dependencies that exist in the global Container, but it can also have
|
||||
* request-specific services (like the Session) injected into it.
|
||||
*
|
||||
* @extends Container
|
||||
*/
|
||||
export class ScopedContainer extends Container {
|
||||
/**
|
||||
* Create a new scoped container based on a parent container instance.
|
||||
* @param container
|
||||
*/
|
||||
public static fromParent(container: Container) {
|
||||
return new ScopedContainer(container);
|
||||
}
|
||||
|
||||
constructor(
|
||||
private parentContainer: Container,
|
||||
) {
|
||||
super()
|
||||
this.registerSingletonInstance<ScopedContainer>(ScopedContainer, this)
|
||||
}
|
||||
|
||||
hasInstance(key: DependencyKey): boolean {
|
||||
return super.hasInstance(key) || this.parentContainer.hasInstance(key)
|
||||
}
|
||||
|
||||
hasKey(key: DependencyKey): boolean {
|
||||
return super.hasKey(key) || this.parentContainer.hasKey(key)
|
||||
}
|
||||
|
||||
getExistingInstance(key: DependencyKey): MaybeDependency {
|
||||
const inst = super.getExistingInstance(key)
|
||||
if ( inst ) return inst;
|
||||
|
||||
return this.parentContainer.getExistingInstance(key);
|
||||
}
|
||||
|
||||
resolve(key: DependencyKey): MaybeFactory {
|
||||
const factory = super.resolve(key);
|
||||
if ( factory ) return factory;
|
||||
|
||||
return this.parentContainer?.resolve(key);
|
||||
}
|
||||
}
|
151
src/di/decorator/injection.ts
Normal file
151
src/di/decorator/injection.ts
Normal file
@ -0,0 +1,151 @@
|
||||
import 'reflect-metadata'
|
||||
import {collect, Collection} from "../../util";
|
||||
import {
|
||||
DependencyKey,
|
||||
DependencyRequirement,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
isInstantiable,
|
||||
InjectionType,
|
||||
DEPENDENCY_KEYS_SERVICE_TYPE_KEY,
|
||||
PropertyDependency,
|
||||
} from "../types";
|
||||
import {Container} from "../Container";
|
||||
|
||||
/**
|
||||
* Get a collection of dependency requirements for the given target object.
|
||||
* @param {Object} target
|
||||
* @return Collection<DependencyRequirement>
|
||||
*/
|
||||
function initDependencyMetadata(target: Object): Collection<DependencyRequirement> {
|
||||
const paramTypes = Reflect.getMetadata('design:paramtypes', target)
|
||||
return collect<DependencyKey>(paramTypes).map<DependencyRequirement>((type, idx) => {
|
||||
return {
|
||||
paramIndex: idx,
|
||||
key: type,
|
||||
overridden: false,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Class decorator that marks a class as injectable. When this is applied, dependency
|
||||
* metadata for the constructors params is resolved and stored in metadata.
|
||||
* @constructor
|
||||
*/
|
||||
export const Injectable = (): ClassDecorator => {
|
||||
return (target) => {
|
||||
const meta = initDependencyMetadata(target)
|
||||
const existing = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
|
||||
const new_meta = new Collection<DependencyRequirement>()
|
||||
|
||||
if ( existing ) {
|
||||
const max_new = meta.max('paramIndex')
|
||||
const max_existing = existing.max('paramIndex')
|
||||
for ( let i = 0; i <= Math.max(max_new, max_existing); i++ ) {
|
||||
const existing_dr = existing.firstWhere('paramIndex', '=', i)
|
||||
const new_dr = meta.firstWhere('paramIndex', '=', i)
|
||||
|
||||
if ( existing_dr && !new_dr ) {
|
||||
new_meta.push(existing_dr)
|
||||
} else if ( new_dr && !existing_dr ) {
|
||||
new_meta.push(new_dr)
|
||||
} else if ( new_dr && existing_dr ) {
|
||||
if ( existing_dr.overridden ) {
|
||||
new_meta.push(existing_dr)
|
||||
} else {
|
||||
new_meta.push(new_dr)
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
new_meta.concat(meta)
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, new_meta, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark the given class property to be injected by the container.
|
||||
* If a `key` is specified, that DependencyKey will be injected.
|
||||
* Otherwise, the DependencyKey is inferred from the type annotation.
|
||||
* @param key
|
||||
* @constructor
|
||||
*/
|
||||
export const Inject = (key?: DependencyKey): PropertyDecorator => {
|
||||
return (target, property) => {
|
||||
let propertyMetadata = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, target?.constructor || target) as Collection<PropertyDependency>
|
||||
if ( !propertyMetadata ) {
|
||||
propertyMetadata = new Collection<PropertyDependency>()
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
|
||||
const type = Reflect.getMetadata('design:type', target, property)
|
||||
if ( !key && type ) key = type
|
||||
|
||||
if ( key ) {
|
||||
const existing = propertyMetadata.firstWhere('property', '=', property)
|
||||
if ( existing ) {
|
||||
existing.key = key
|
||||
} else {
|
||||
propertyMetadata.push({ property, key })
|
||||
}
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, propertyMetadata, target?.constructor || target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parameter decorator to manually mark a parameter as being an injection target on injectable
|
||||
* classes. This can be used to override the dependency key of a given parameter.
|
||||
* @param {DependencyKey} key
|
||||
* @constructor
|
||||
*/
|
||||
export const InjectParam = (key: DependencyKey): ParameterDecorator => {
|
||||
return (target, property, paramIndex) => {
|
||||
if ( !Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target) ) {
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, initDependencyMetadata(target), target)
|
||||
}
|
||||
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, target)
|
||||
const req = meta.firstWhere('paramIndex', '=', paramIndex)
|
||||
if ( req ) {
|
||||
req.key = key
|
||||
req.overridden = true
|
||||
} else {
|
||||
meta.push({
|
||||
paramIndex,
|
||||
key,
|
||||
overridden: true
|
||||
})
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_METADATA_KEY, meta, target)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Class decorator that registers the class as a singleton instance in the global container.
|
||||
* @param {string} name
|
||||
*/
|
||||
export const Singleton = (name?: string): ClassDecorator => {
|
||||
return (target) => {
|
||||
if ( isInstantiable(target) ) {
|
||||
const injectionType: InjectionType = {
|
||||
type: name ? 'named' : 'singleton',
|
||||
...(name ? { name } : {})
|
||||
}
|
||||
|
||||
Reflect.defineMetadata(DEPENDENCY_KEYS_SERVICE_TYPE_KEY, injectionType, target)
|
||||
Injectable()(target)
|
||||
|
||||
if ( name ) {
|
||||
Container.getContainer().registerNamed(name, target)
|
||||
} else {
|
||||
Container.getContainer().register(target)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
11
src/di/error/DuplicateFactoryKeyError.ts
Normal file
11
src/di/error/DuplicateFactoryKeyError.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import {DependencyKey} from "../types";
|
||||
|
||||
/**
|
||||
* Error thrown when a factory is registered with a duplicate dependency key.
|
||||
* @extends Error
|
||||
*/
|
||||
export class DuplicateFactoryKeyError extends Error {
|
||||
constructor(key: DependencyKey) {
|
||||
super(`A factory definition already exists with the key for ${key}.`)
|
||||
}
|
||||
}
|
11
src/di/error/InvalidDependencyKeyError.ts
Normal file
11
src/di/error/InvalidDependencyKeyError.ts
Normal file
@ -0,0 +1,11 @@
|
||||
import {DependencyKey} from "../types";
|
||||
|
||||
/**
|
||||
* Error thrown when a dependency key that has not been registered is passed to a resolver.
|
||||
* @extends Error
|
||||
*/
|
||||
export class InvalidDependencyKeyError extends Error {
|
||||
constructor(key: DependencyKey) {
|
||||
super(`No such dependency is registered with this container: ${key}`)
|
||||
}
|
||||
}
|
44
src/di/factory/AbstractFactory.ts
Normal file
44
src/di/factory/AbstractFactory.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {DependencyRequirement, PropertyDependency} from "../types";
|
||||
import { Collection } from "../../util";
|
||||
|
||||
/**
|
||||
* Abstract base class for dependency container factories.
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class AbstractFactory {
|
||||
protected constructor(
|
||||
/**
|
||||
* Token that was registered for this factory. In most cases, this is the static
|
||||
* form of the item that is to be produced by this factory.
|
||||
* @var
|
||||
* @protected
|
||||
*/
|
||||
protected token: any
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Produce an instance of the token.
|
||||
* @param {Array} dependencies - the resolved dependencies, in order
|
||||
* @param {Array} parameters - the bound constructor parameters, in order
|
||||
*/
|
||||
abstract produce(dependencies: any[], parameters: any[]): any
|
||||
|
||||
/**
|
||||
* Should return true if the given identifier matches the token for this factory.
|
||||
* @param something
|
||||
* @return boolean
|
||||
*/
|
||||
abstract match(something: any): boolean
|
||||
|
||||
/**
|
||||
* Get the dependency requirements required by this factory's token.
|
||||
* @return Collection<DependencyRequirement>
|
||||
*/
|
||||
abstract getDependencyKeys(): Collection<DependencyRequirement>
|
||||
|
||||
/**
|
||||
* Get the property dependencies that should be injected to the created instance.
|
||||
* @return Collection<PropertyDependency>
|
||||
*/
|
||||
abstract getInjectedProperties(): Collection<PropertyDependency>
|
||||
}
|
43
src/di/factory/ClosureFactory.ts
Normal file
43
src/di/factory/ClosureFactory.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {AbstractFactory} from "./AbstractFactory";
|
||||
import {DependencyRequirement, PropertyDependency, StaticClass} from "../types";
|
||||
import {Collection} from "../../util";
|
||||
|
||||
/**
|
||||
* A factory whose token is produced by calling a function.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* let i = 0
|
||||
* const fact = new ClosureFactory('someName', () => {
|
||||
* i += 1
|
||||
* return i * 2
|
||||
* })
|
||||
*
|
||||
* fact.produce([], []) // => 2
|
||||
* fact.produce([], []) // => 4
|
||||
* ```
|
||||
*/
|
||||
export class ClosureFactory extends AbstractFactory {
|
||||
constructor(
|
||||
protected readonly name: string | StaticClass<any, any>,
|
||||
protected readonly token: () => any,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(dependencies: any[], parameters: any[]): any {
|
||||
return this.token()
|
||||
}
|
||||
|
||||
match(something: any) {
|
||||
return something === this.name
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return new Collection<PropertyDependency>()
|
||||
}
|
||||
}
|
65
src/di/factory/Factory.ts
Normal file
65
src/di/factory/Factory.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {AbstractFactory} from "./AbstractFactory";
|
||||
import {
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY,
|
||||
DependencyRequirement,
|
||||
Instantiable,
|
||||
PropertyDependency
|
||||
} from "../types";
|
||||
import {Collection} from "../../util";
|
||||
import 'reflect-metadata'
|
||||
|
||||
/**
|
||||
* Standard static-class factory. The token of this factory is a reference to a
|
||||
* static class that is instantiated when the factory produces.
|
||||
*
|
||||
* Dependency keys are inferred from injection metadata on the constructor's params,
|
||||
* as are the injected properties.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A {
|
||||
* constructor(
|
||||
* protected readonly myService: MyService
|
||||
* ) { }
|
||||
* }
|
||||
*
|
||||
* const fact = new Factory(A)
|
||||
*
|
||||
* fact.produce([myServiceInstance], []) // => A { myService: myServiceInstance }
|
||||
* ```
|
||||
*/
|
||||
export class Factory extends AbstractFactory {
|
||||
constructor(
|
||||
protected readonly token: Instantiable<any>
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(dependencies: any[], parameters: any[]): any {
|
||||
return new this.token(...dependencies, ...parameters)
|
||||
}
|
||||
|
||||
match(something: any) {
|
||||
return something === this.token // || (something?.name && something.name === this.token.name)
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
const meta = Reflect.getMetadata(DEPENDENCY_KEYS_METADATA_KEY, this.token)
|
||||
if ( meta ) return meta
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
const meta = new Collection<PropertyDependency>()
|
||||
let currentToken = this.token
|
||||
|
||||
do {
|
||||
const loadedMeta = Reflect.getMetadata(DEPENDENCY_KEYS_PROPERTY_METADATA_KEY, currentToken)
|
||||
if ( loadedMeta ) meta.concat(loadedMeta)
|
||||
currentToken = Object.getPrototypeOf(currentToken)
|
||||
} while (Object.getPrototypeOf(currentToken) !== Function.prototype && Object.getPrototypeOf(currentToken) !== Object.prototype)
|
||||
|
||||
return meta
|
||||
}
|
||||
}
|
29
src/di/factory/NamedFactory.ts
Normal file
29
src/di/factory/NamedFactory.ts
Normal file
@ -0,0 +1,29 @@
|
||||
import {Factory} from "./Factory";
|
||||
import {Instantiable} from "../types";
|
||||
|
||||
/**
|
||||
* Container factory that produces an instance of the token, however the token
|
||||
* is identified by a string name rather than a class reference.
|
||||
* @extends Factory
|
||||
*/
|
||||
export default class NamedFactory extends Factory {
|
||||
constructor(
|
||||
/**
|
||||
* The name identifying this factory in the container.
|
||||
* @type {string}
|
||||
*/
|
||||
protected name: string,
|
||||
|
||||
/**
|
||||
* The token to be instantiated.
|
||||
* @type {Instantiable}
|
||||
*/
|
||||
protected token: Instantiable<any>,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
match(something: any) {
|
||||
return something === this.name
|
||||
}
|
||||
}
|
54
src/di/factory/SingletonFactory.ts
Normal file
54
src/di/factory/SingletonFactory.ts
Normal file
@ -0,0 +1,54 @@
|
||||
import { Factory } from './Factory'
|
||||
import { Collection } from '../../util'
|
||||
import {DependencyRequirement, PropertyDependency} from "../types";
|
||||
|
||||
/**
|
||||
* Container factory which returns its token as its value, without attempting
|
||||
* to instantiate anything. This is used to register already-produced-singletons
|
||||
* with the container.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A {}
|
||||
* const exactlyThisInstanceOfA = new A()
|
||||
*
|
||||
* const fact = new SingletonFactory(A, a)
|
||||
*
|
||||
* fact.produce([], []) // => exactlyThisInstanceOfA
|
||||
* ```
|
||||
*
|
||||
* @extends Factory
|
||||
*/
|
||||
export default class SingletonFactory extends Factory {
|
||||
constructor(
|
||||
/**
|
||||
* Instantiated value of this factory.
|
||||
* @type FunctionConstructor
|
||||
*/
|
||||
protected token: FunctionConstructor,
|
||||
|
||||
/**
|
||||
* String name of this singleton identifying it in the container.
|
||||
* @type string
|
||||
*/
|
||||
protected key: string,
|
||||
) {
|
||||
super(token)
|
||||
}
|
||||
|
||||
produce(dependencies: any[], parameters: any[]) {
|
||||
return this.token
|
||||
}
|
||||
|
||||
match(something: any) {
|
||||
return something === this.key
|
||||
}
|
||||
|
||||
getDependencyKeys(): Collection<DependencyRequirement> {
|
||||
return new Collection<DependencyRequirement>()
|
||||
}
|
||||
|
||||
getInjectedProperties(): Collection<PropertyDependency> {
|
||||
return new Collection<PropertyDependency>()
|
||||
}
|
||||
}
|
14
src/di/index.ts
Normal file
14
src/di/index.ts
Normal file
@ -0,0 +1,14 @@
|
||||
export * from './error/DuplicateFactoryKeyError'
|
||||
export * from './error/InvalidDependencyKeyError'
|
||||
|
||||
export * from './factory/AbstractFactory'
|
||||
export * from './factory/ClosureFactory'
|
||||
export * from './factory/Factory'
|
||||
export * from './factory/NamedFactory'
|
||||
export * from './factory/SingletonFactory'
|
||||
|
||||
export * from './Container'
|
||||
export * from './ScopedContainer'
|
||||
export * from './types'
|
||||
|
||||
export * from './decorator/injection'
|
71
src/di/types.ts
Normal file
71
src/di/types.ts
Normal file
@ -0,0 +1,71 @@
|
||||
export const DEPENDENCY_KEYS_METADATA_KEY = 'extollo:di:dependencies:ctor';
|
||||
export const DEPENDENCY_KEYS_PROPERTY_METADATA_KEY = 'extollo:di:dependencies:properties';
|
||||
export const DEPENDENCY_KEYS_SERVICE_TYPE_KEY = 'extollo:di:service_type';
|
||||
|
||||
/**
|
||||
* Interface that designates a particular value as able to be constructed.
|
||||
*/
|
||||
export interface Instantiable<T> {
|
||||
new(...args: any[]): T
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given value is instantiable.
|
||||
* @param what
|
||||
*/
|
||||
export function isInstantiable<T>(what: any): what is Instantiable<T> {
|
||||
return (typeof what === 'object' || typeof what === 'function') && 'constructor' in what && typeof what.constructor === 'function'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type that identifies a value as a static class, even if it is not instantiable.
|
||||
*/
|
||||
export type StaticClass<T, T2> = Function & {prototype: T} & T2
|
||||
|
||||
/**
|
||||
* Returns true if the parameter is a static class.
|
||||
* @param something
|
||||
*/
|
||||
export function isStaticClass<T, T2>(something: any): something is StaticClass<T, T2> {
|
||||
return typeof something === 'function' && typeof something.prototype !== 'undefined'
|
||||
}
|
||||
|
||||
/**
|
||||
* Type used to represent a value that can identify a factory in the container.
|
||||
*/
|
||||
export type DependencyKey = Instantiable<any> | StaticClass<any, any> | string
|
||||
|
||||
/**
|
||||
* Interface used to store dependency requirements by their place in the injectable
|
||||
* target's parameters.
|
||||
*/
|
||||
export interface DependencyRequirement {
|
||||
paramIndex: number,
|
||||
key: DependencyKey,
|
||||
overridden: boolean,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface used to store dependency requirements by the class property they should
|
||||
* be injected into.
|
||||
*/
|
||||
export interface PropertyDependency {
|
||||
key: DependencyKey,
|
||||
property: string | symbol,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface used to keep track of singleton factory values, by their dependency key.
|
||||
*/
|
||||
export interface InstanceRef {
|
||||
key: DependencyKey,
|
||||
value: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface used to keep track of the injection type of a class.
|
||||
*/
|
||||
export interface InjectionType {
|
||||
type: 'named' | 'singleton',
|
||||
name?: string,
|
||||
}
|
0
src/forms/.gitkeep
Normal file
0
src/forms/.gitkeep
Normal file
60
src/forms/FormRequest.ts
Normal file
60
src/forms/FormRequest.ts
Normal file
@ -0,0 +1,60 @@
|
||||
import {Container, Injectable, InjectParam} from '../di'
|
||||
import {Request} from "../http/lifecycle/Request";
|
||||
import {Valid, ValidationRules} from './rules/types'
|
||||
import {Validator} from './Validator'
|
||||
import {AppClass} from "../lifecycle/AppClass";
|
||||
import {DataContainer} from "../http/lifecycle/Request";
|
||||
|
||||
/**
|
||||
* Base class for defining reusable validators for request routes.
|
||||
* If instantiated with a container, it must be a request-level container,
|
||||
* but the type interface allows any data-container to be used when creating
|
||||
* manually.
|
||||
*
|
||||
* You should mark implementations of this class as singleton to avoid
|
||||
* re-validating the input data every time it is accessed.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* // Instantiate with the request:
|
||||
* const data = <MyFormRequest> request.make(MyFormRequest)
|
||||
*
|
||||
* // Instantiate with some container:
|
||||
* const data = new MyFormRequest(someDataContainer)
|
||||
* ```
|
||||
*/
|
||||
@Injectable()
|
||||
export abstract class FormRequest<T> extends AppClass {
|
||||
/** The cached validation result. */
|
||||
protected cachedResult?: Valid<T>
|
||||
|
||||
constructor(
|
||||
@InjectParam(Request)
|
||||
protected readonly data: DataContainer
|
||||
) { super() }
|
||||
|
||||
protected container() {
|
||||
return (this.data as unknown) as Container
|
||||
}
|
||||
|
||||
/**
|
||||
* The validation rules that should be applied to the request to guarantee
|
||||
* that it contains the given data type.
|
||||
* @protected
|
||||
*/
|
||||
protected abstract getRules(): ValidationRules | Promise<ValidationRules>
|
||||
|
||||
/**
|
||||
* Validate and get the request input. Throws a validation error on fail.
|
||||
* Internally, caches the result after the first validation. So, singleton
|
||||
* validators will avoid re-processing their rules every time.
|
||||
*/
|
||||
public async get(): Promise<Valid<T>> {
|
||||
if ( !this.cachedResult ) {
|
||||
const validator = <Validator<T>> this.make(Validator, await this.getRules())
|
||||
this.cachedResult = await validator.validate(this.data.input())
|
||||
}
|
||||
|
||||
return this.cachedResult
|
||||
}
|
||||
}
|
104
src/forms/Validator.ts
Normal file
104
src/forms/Validator.ts
Normal file
@ -0,0 +1,104 @@
|
||||
import {Valid, ValidationResult, ValidationRules, ValidatorFunction, ValidatorFunctionParams} from "./rules/types";
|
||||
import {Messages, ErrorWithContext, dataWalkUnsafe, dataSetUnsafe} from "../util";
|
||||
|
||||
/**
|
||||
* An error thrown thrown when an object fails its validation.
|
||||
*/
|
||||
export class ValidationError<T> extends ErrorWithContext {
|
||||
constructor(
|
||||
/** The original input data. */
|
||||
public readonly data: any,
|
||||
|
||||
/** The validator instance used. */
|
||||
public readonly validator: Validator<T>,
|
||||
|
||||
/** Validation error messages, by field. */
|
||||
public readonly errors: Messages
|
||||
) {
|
||||
super('One or more fields were invalid.', { data, messages: errors.all() });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A class to validate arbitrary data using functional rules.
|
||||
*/
|
||||
export class Validator<T> {
|
||||
constructor(
|
||||
/** The rules used to validate input objects. */
|
||||
protected readonly rules: ValidationRules
|
||||
) {}
|
||||
|
||||
/**
|
||||
* Attempt to validate the input data.
|
||||
* If it is valid, it is type aliased as Valid<T>.
|
||||
* If it is invalid, a ValidationError is thrown.
|
||||
* @param data
|
||||
*/
|
||||
public async validate(data: unknown): Promise<Valid<T>> {
|
||||
const messages = await this.validateAndGetErrors(data)
|
||||
if ( messages.any() ) {
|
||||
throw new ValidationError<T>(data, this, messages)
|
||||
}
|
||||
|
||||
return data as Valid<T>
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given data is valid and type aliases it as Valid<T>.
|
||||
* @param data
|
||||
*/
|
||||
public async isValid(data: any): Promise<boolean> {
|
||||
return !(await this.validateAndGetErrors(data)).any()
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply the validation rules to the data object and return any error messages.
|
||||
* @param data
|
||||
* @protected
|
||||
*/
|
||||
protected async validateAndGetErrors(data: any): Promise<Messages> {
|
||||
const messages = new Messages()
|
||||
const params: ValidatorFunctionParams = { data }
|
||||
|
||||
for ( const key in this.rules ) {
|
||||
if ( !this.rules.hasOwnProperty(key) ) continue;
|
||||
|
||||
// This walks over all of the values in the data structure using the nested
|
||||
// key notation. It's not type-safe, but neither is the original input object
|
||||
// yet, so it's useful here.
|
||||
for ( const walkEntry of dataWalkUnsafe<any>(data, key) ) {
|
||||
let [entry, dataKey] = walkEntry
|
||||
const rules = (Array.isArray(this.rules[key]) ? this.rules[key] : [this.rules[key]]) as ValidatorFunction[]
|
||||
|
||||
for ( const rule of rules ) {
|
||||
const result: ValidationResult = await rule(dataKey, entry, params)
|
||||
|
||||
if ( !result.valid ) {
|
||||
let errors = ['is invalid']
|
||||
|
||||
if ( Array.isArray(result.message) && result.message.length ) {
|
||||
errors = result.message
|
||||
} else if ( !Array.isArray(result.message) && result.message ) {
|
||||
errors = [result.message]
|
||||
}
|
||||
|
||||
for ( const error of errors ) {
|
||||
if ( !messages.has(dataKey, error) ) messages.put(dataKey, error)
|
||||
}
|
||||
}
|
||||
|
||||
if ( result.valid && result.castValue ) {
|
||||
entry = result.castValue
|
||||
data = dataSetUnsafe(dataKey, entry, data)
|
||||
}
|
||||
|
||||
if ( result.stopValidation ) {
|
||||
break // move on to the next field
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return messages
|
||||
}
|
||||
}
|
9
src/forms/index.ts
Normal file
9
src/forms/index.ts
Normal file
@ -0,0 +1,9 @@
|
||||
export * from './rules/types'
|
||||
export * as Rule from './rules/rules'
|
||||
|
||||
export * from './unit/Forms'
|
||||
|
||||
export * from './Validator'
|
||||
export * from './FormRequest'
|
||||
|
||||
export * from './middleware'
|
34
src/forms/middleware.ts
Normal file
34
src/forms/middleware.ts
Normal file
@ -0,0 +1,34 @@
|
||||
import {Instantiable} from '../di'
|
||||
import {FormRequest} from './FormRequest'
|
||||
import {ValidationError} from './Validator'
|
||||
import {ResponseObject, RouteHandler} from "../http/routing/Route";
|
||||
import {Request} from "../http/lifecycle/Request";
|
||||
|
||||
/**
|
||||
* Builds a middleware function that validates a request's input against
|
||||
* the given form request class and registers the FormRequest class into
|
||||
* the request container.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* Route.group(...).pre(formRequest(MyFormRequestClass))
|
||||
* ```
|
||||
*
|
||||
* @param formRequestClass
|
||||
*/
|
||||
export function formRequest<T>(formRequestClass: Instantiable<FormRequest<T>>): RouteHandler {
|
||||
return async function formRequestRouteHandler(request: Request): Promise<ResponseObject> {
|
||||
const formRequestInstance = <FormRequest<T>> request.make(formRequestClass)
|
||||
|
||||
try {
|
||||
await formRequestInstance.get()
|
||||
request.registerSingletonInstance<FormRequest<T>>(formRequestClass, formRequestInstance)
|
||||
} catch (e: unknown) {
|
||||
if ( e instanceof ValidationError ) {
|
||||
return e.errors.toJSON()
|
||||
}
|
||||
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
130
src/forms/rules/arrays.ts
Normal file
130
src/forms/rules/arrays.ts
Normal file
@ -0,0 +1,130 @@
|
||||
import {ValidationResult, ValidatorFunction} from "./types";
|
||||
|
||||
export namespace Arr {
|
||||
/** Requires the input value to be an array. */
|
||||
export function is(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( Array.isArray(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be an array'
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the values in the input value array to be distinct. */
|
||||
export function distinct(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( (new Set(inputValue)).size === inputValue.length ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must not contain duplicate values'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input array to contain the given value.
|
||||
* @param value
|
||||
*/
|
||||
export function includes(value: any): ValidatorFunction {
|
||||
return function includes(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( inputValue.includes(value) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must include ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input array NOT to contain the given value.
|
||||
* @param value
|
||||
*/
|
||||
export function excludes(value: any): ValidatorFunction {
|
||||
return function excludes(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( !inputValue.includes(value) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must not include ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input array to have exactly `len` many entries.
|
||||
* @param len
|
||||
*/
|
||||
export function length(len: number): ValidatorFunction {
|
||||
return function length(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( inputValue.length === len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be exactly of length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input array to have at least `len` many entries.
|
||||
* @param len
|
||||
*/
|
||||
export function lengthMin(len: number): ValidatorFunction {
|
||||
return function lengthMin(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( inputValue.length >= len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at least length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input array to have at most `len` many entries.
|
||||
* @param len
|
||||
*/
|
||||
export function lengthMax(len: number): ValidatorFunction {
|
||||
return function lengthMax(fieldName: string, inputValue: any): ValidationResult {
|
||||
const arr = is(fieldName, inputValue)
|
||||
if ( !arr.valid ) return arr
|
||||
|
||||
if ( inputValue.length <= len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at most length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
70
src/forms/rules/inference.ts
Normal file
70
src/forms/rules/inference.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import {infer as inferUtil} from '../../util'
|
||||
import {ValidationResult} from "./types";
|
||||
|
||||
export namespace Cast {
|
||||
/** Attempt to infer the native type of a string value. */
|
||||
export function infer(fieldName: string, inputValue: any): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
castValue: typeof inputValue === 'string' ? inferUtil(inputValue) : inputValue,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Casts the input value to a boolean.
|
||||
* Note that this assumes the value may be boolish. The strings "true", "True",
|
||||
* "TRUE", and "1" evaluate to `true`, while "false", "False", "FALSE", and "0"
|
||||
* evaluate to `false`.
|
||||
* @param fieldName
|
||||
* @param inputValue
|
||||
*/
|
||||
export function boolean(fieldName: string, inputValue: any): ValidationResult {
|
||||
let castValue = !!inputValue
|
||||
|
||||
if ( ['true', 'True', 'TRUE', '1'].includes(inputValue) ) castValue = true
|
||||
if ( ['false', 'False', 'FALSE', '0'].includes(inputValue) ) castValue = false
|
||||
|
||||
return {
|
||||
valid: true,
|
||||
castValue,
|
||||
}
|
||||
}
|
||||
|
||||
/** Casts the input value to a string. */
|
||||
export function string(fieldName: string, inputValue: any): ValidationResult {
|
||||
return {
|
||||
valid: true,
|
||||
castValue: String(inputValue),
|
||||
}
|
||||
}
|
||||
|
||||
/** Casts the input value to a number, if it is numerical. Fails otherwise. */
|
||||
export function numeric(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( !isNaN(parseFloat(inputValue)) ) {
|
||||
return {
|
||||
valid: true,
|
||||
castValue: parseFloat(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be numeric',
|
||||
}
|
||||
}
|
||||
|
||||
/** Casts the input value to an integer. Fails otherwise. */
|
||||
export function integer(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( !isNaN(parseInt(inputValue)) ) {
|
||||
return {
|
||||
valid: true,
|
||||
castValue: parseInt(inputValue)
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be an integer',
|
||||
}
|
||||
}
|
||||
}
|
197
src/forms/rules/numeric.ts
Normal file
197
src/forms/rules/numeric.ts
Normal file
@ -0,0 +1,197 @@
|
||||
import {ValidationResult, ValidatorFunction} from "./types";
|
||||
|
||||
export namespace Num {
|
||||
/**
|
||||
* Builds a validator function that requires the input value to be greater than some value.
|
||||
* @param value
|
||||
*/
|
||||
export function greaterThan(value: number): ValidatorFunction {
|
||||
return function greaterThan(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue > value ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be greater than ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to be at least some value.
|
||||
* @param value
|
||||
*/
|
||||
export function atLeast(value: number): ValidatorFunction {
|
||||
return function atLeast(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue >= value ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at least ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to be less than some value.
|
||||
* @param value
|
||||
*/
|
||||
export function lessThan(value: number): ValidatorFunction {
|
||||
return function lessThan(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue < value ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be less than ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to be at most some value.
|
||||
* @param value
|
||||
*/
|
||||
export function atMost(value: number): ValidatorFunction {
|
||||
return function atMost(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue <= value ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at most ${value}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have exactly `num` many digits.
|
||||
* @param num
|
||||
*/
|
||||
export function digits(num: number): ValidatorFunction {
|
||||
return function digits(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).replace('.', '').length === num ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must have exactly ${num} digits`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have at least `num` many digits.
|
||||
* @param num
|
||||
*/
|
||||
export function digitsMin(num: number): ValidatorFunction {
|
||||
return function digitsMin(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).replace('.', '').length >= num ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must have at least ${num} digits`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have at most `num` many digits.
|
||||
* @param num
|
||||
*/
|
||||
export function digitsMax(num: number): ValidatorFunction {
|
||||
return function digitsMax(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).replace('.', '').length <= num ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must have at most ${num} digits`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to end with the given number sequence.
|
||||
* @param num
|
||||
*/
|
||||
export function ends(num: number): ValidatorFunction {
|
||||
return function ends(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).endsWith(String(num)) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must end with "${num}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to begin with the given number sequence.
|
||||
* @param num
|
||||
*/
|
||||
export function begins(num: number): ValidatorFunction {
|
||||
return function begins(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).startsWith(String(num)) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must begin with "${num}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to be a multiple of the given number.
|
||||
* @param num
|
||||
*/
|
||||
export function multipleOf(num: number): ValidatorFunction {
|
||||
return function multipleOf(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue % num === 0 ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be a multiple of ${num}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be even. */
|
||||
export function even(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue % 2 === 0 ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be even',
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be odd. */
|
||||
export function odd(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue % 2 === 0 ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be odd',
|
||||
}
|
||||
}
|
||||
}
|
175
src/forms/rules/presence.ts
Normal file
175
src/forms/rules/presence.ts
Normal file
@ -0,0 +1,175 @@
|
||||
import {ValidationResult, ValidatorFunction} from "./types";
|
||||
import {UniversalPath} from '../../util'
|
||||
|
||||
export namespace Is {
|
||||
/** Requires the given input value to be some form of affirmative boolean. */
|
||||
export function accepted(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( ['yes', 'Yes', 'YES', 1, true, 'true', 'True', 'TRUE'].includes(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be accepted'
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the given input value to be some form of boolean. */
|
||||
export function boolean(fieldName: string, inputValue: any): ValidationResult {
|
||||
const boolish = ['true', 'True', 'TRUE', '1', 'false', 'False', 'FALSE', '0', true, false, 1, 0]
|
||||
if ( boolish.includes(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be true or false'
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be of type string. */
|
||||
export function string(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( typeof inputValue === 'string' ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be a string'
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the given input value to be present and non-nullish. */
|
||||
export function required(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( typeof inputValue !== 'undefined' && inputValue !== null && inputValue !== '' ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'is required',
|
||||
stopValidation: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias of required(). */
|
||||
export function present(fieldName: string, inputValue: any): ValidationResult {
|
||||
return required(fieldName, inputValue)
|
||||
}
|
||||
|
||||
/** Alias of required(). */
|
||||
export function filled(fieldName: string, inputValue: any): ValidationResult {
|
||||
return required(fieldName, inputValue)
|
||||
}
|
||||
|
||||
/** Requires the given input value to be absent or nullish. */
|
||||
export function prohibited(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( typeof inputValue === 'undefined' || inputValue === null || inputValue === '' ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'is not allowed',
|
||||
stopValidation: true,
|
||||
}
|
||||
}
|
||||
|
||||
/** Alias of prohibited(). */
|
||||
export function absent(fieldName: string, inputValue: any): ValidationResult {
|
||||
return prohibited(fieldName, inputValue)
|
||||
}
|
||||
|
||||
/** Alias of prohibited(). */
|
||||
export function empty(fieldName: string, inputValue: any): ValidationResult {
|
||||
return prohibited(fieldName, inputValue)
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the given input to be found in an array of values.
|
||||
* @param values
|
||||
*/
|
||||
export function foundIn(values: any[]): ValidatorFunction {
|
||||
return function foundIn(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( values.includes(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be one of: ${values.join(', ')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the given input NOT to be found in an array of values.
|
||||
* @param values
|
||||
*/
|
||||
export function notFoundIn(values: any[]): ValidatorFunction {
|
||||
return function foundIn(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( values.includes(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be one of: ${values.join(', ')}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be number-like. */
|
||||
export function numeric(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( !isNaN(parseFloat(inputValue)) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be numeric',
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the given input value to be integer-like. */
|
||||
export function integer(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( !isNaN(parseInt(inputValue)) && parseInt(inputValue) === parseFloat(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be an integer',
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the given input value to be a UniversalPath. */
|
||||
export function file(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue instanceof UniversalPath ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be a file'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* A special validator function that marks a field as optional.
|
||||
* If the value of the field is nullish, no further validation rules will be applied.
|
||||
* If it is non-nullish, validation will continue.
|
||||
* @param fieldName
|
||||
* @param inputValue
|
||||
*/
|
||||
export function optional(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue ?? true ) {
|
||||
return {
|
||||
valid: true,
|
||||
stopValidation: true,
|
||||
}
|
||||
}
|
||||
|
||||
return { valid: true }
|
||||
}
|
||||
}
|
31
src/forms/rules/provided/DateValidator.ts
Normal file
31
src/forms/rules/provided/DateValidator.ts
Normal file
@ -0,0 +1,31 @@
|
||||
/*
|
||||
import {Injectable} from '@extollo/di'
|
||||
import {Validator} from '../Validator'
|
||||
import {ValidationResult} from "../types";
|
||||
|
||||
@Injectable()
|
||||
export class DateValidator extends Validator {
|
||||
protected names: string[] = [
|
||||
'date',
|
||||
'date.after',
|
||||
'date.at_least',
|
||||
'date.before',
|
||||
'date.at_most',
|
||||
'date.equals',
|
||||
'date.format',
|
||||
]
|
||||
|
||||
public matchName(name: string): boolean {
|
||||
return this.names.includes(name)
|
||||
}
|
||||
|
||||
validate(fieldName: string, inputValue: any, params: { name: string; params: any }): ValidationResult {
|
||||
switch ( params.name ) {
|
||||
|
||||
}
|
||||
|
||||
|
||||
return { valid: false }
|
||||
}
|
||||
}
|
||||
*/
|
5
src/forms/rules/rules.ts
Normal file
5
src/forms/rules/rules.ts
Normal file
@ -0,0 +1,5 @@
|
||||
export { Arr } from './arrays'
|
||||
export { Cast } from './inference'
|
||||
export { Num } from './numeric'
|
||||
export { Is } from './presence'
|
||||
export { Str } from './strings'
|
224
src/forms/rules/strings.ts
Normal file
224
src/forms/rules/strings.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import {ValidationResult, ValidatorFunction} from "./types";
|
||||
import {isJSON} from '../../util'
|
||||
|
||||
/**
|
||||
* String-related validation rules.
|
||||
*/
|
||||
export namespace Str {
|
||||
const regexes: {[key: string]: RegExp} = {
|
||||
'string.is.alpha': /[a-zA-Z]*/,
|
||||
'string.is.alpha_num': /[a-zA-Z0-9]*/,
|
||||
'string.is.alpha_dash': /[a-zA-Z\-]*/,
|
||||
'string.is.alpha_score': /[a-zA-Z_]*/,
|
||||
'string.is.alpha_num_dash_score': /[a-zA-Z\-_0-9]*/,
|
||||
'string.is.email': /(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]+(?:\.[a-z0-9!#$%&'*+/=?^_`{|}~-]+)*|"(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21\x23-\x5b\x5d-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])*")@(?:(?:[a-z0-9](?:[a-z0-9-]*[a-z0-9])?\.)+[a-z0-9](?:[a-z0-9-]*[a-z0-9])?|\[(?:(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9]))\.){3}(?:(2(5[0-5]|[0-4][0-9])|1[0-9][0-9]|[1-9]?[0-9])|[a-z0-9-]*[a-z0-9]:(?:[\x01-\x08\x0b\x0c\x0e-\x1f\x21-\x5a\x53-\x7f]|\\[\x01-\x09\x0b\x0c\x0e-\x7f])+)])/,
|
||||
'string.is.ip': /((^\s*((([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5])\.){3}([0-9]|[1-9][0-9]|1[0-9]{2}|2[0-4][0-9]|25[0-5]))\s*$)|(^\s*((([0-9A-Fa-f]{1,4}:){7}([0-9A-Fa-f]{1,4}|:))|(([0-9A-Fa-f]{1,4}:){6}(:[0-9A-Fa-f]{1,4}|((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){5}(((:[0-9A-Fa-f]{1,4}){1,2})|:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3})|:))|(([0-9A-Fa-f]{1,4}:){4}(((:[0-9A-Fa-f]{1,4}){1,3})|((:[0-9A-Fa-f]{1,4})?:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){3}(((:[0-9A-Fa-f]{1,4}){1,4})|((:[0-9A-Fa-f]{1,4}){0,2}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){2}(((:[0-9A-Fa-f]{1,4}){1,5})|((:[0-9A-Fa-f]{1,4}){0,3}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(([0-9A-Fa-f]{1,4}:){1}(((:[0-9A-Fa-f]{1,4}){1,6})|((:[0-9A-Fa-f]{1,4}){0,4}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:))|(:(((:[0-9A-Fa-f]{1,4}){1,7})|((:[0-9A-Fa-f]{1,4}){0,5}:((25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)(\.(25[0-5]|2[0-4]\d|1\d\d|[1-9]?\d)){3}))|:)))(%.+)?\s*$))/,
|
||||
'string.is.ip.v4': /(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)(\.(25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)){3}/,
|
||||
'string.is.ip.v6': /(([0-9a-fA-F]{1,4}:){7}[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,7}:|([0-9a-fA-F]{1,4}:){1,6}:[0-9a-fA-F]{1,4}|([0-9a-fA-F]{1,4}:){1,5}(:[0-9a-fA-F]{1,4}){1,2}|([0-9a-fA-F]{1,4}:){1,4}(:[0-9a-fA-F]{1,4}){1,3}|([0-9a-fA-F]{1,4}:){1,3}(:[0-9a-fA-F]{1,4}){1,4}|([0-9a-fA-F]{1,4}:){1,2}(:[0-9a-fA-F]{1,4}){1,5}|[0-9a-fA-F]{1,4}:((:[0-9a-fA-F]{1,4}){1,6})|:((:[0-9a-fA-F]{1,4}){1,7}|:)|fe80:(:[0-9a-fA-F]{0,4}){0,4}%[0-9a-zA-Z]+|::(ffff(:0{1,4})?:)?((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9])|([0-9a-fA-F]{1,4}:){1,4}:((25[0-5]|(2[0-4]|1?[0-9])?[0-9])\.){3}(25[0-5]|(2[0-4]|1?[0-9])?[0-9]))/,
|
||||
'string.is.mime': /^(?=[-a-z]{1,127}\/[-.a-z0-9]{1,127}$)[a-z]+(-[a-z]+)*\/[a-z0-9]+([-.][a-z0-9]+)*$/,
|
||||
'string.is.url': /((([A-Za-z]{3,9}:(?:\/\/)?)(?:[\-;:&=+$,\w]+@)?[A-Za-z0-9.\-]+|(?:www\.|[\-;:&=+$,\w]+@)[A-Za-z0-9.\-]+)((?:\/[+~%\/.\w\-_]*)?\??(?:[\-+=&;%@.\w_]*)#?(?:[.!\/\\\w]*))?)/,
|
||||
'string.is.uuid': /^[0-9a-fA-F]{8}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{4}\b-[0-9a-fA-F]{12}$/,
|
||||
}
|
||||
|
||||
function validateRex(key: string, inputValue: any, message: string): ValidationResult {
|
||||
if ( regexes[key].test(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be alphabetical characters only. */
|
||||
export function alpha(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.alpha', inputValue, 'must be alphabetical only')
|
||||
}
|
||||
|
||||
/** Requires the input value to be alphanumeric characters only. */
|
||||
export function alphaNum(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.alpha_num', inputValue, 'must be alphanumeric only')
|
||||
}
|
||||
|
||||
/** Requires the input value to be alphabetical characters or the "-" character only. */
|
||||
export function alphaDash(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.alpha_dash', inputValue, 'must be alphabetical and dashes only')
|
||||
}
|
||||
|
||||
/** Requires the input value to be alphabetical characters or the "_" character only. */
|
||||
export function alphaScore(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.alpha_score', inputValue, 'must be alphabetical and underscores only')
|
||||
}
|
||||
|
||||
/** Requires the input value to be alphabetical characters, numeric characters, "-", or "_" only. */
|
||||
export function alphaNumDashScore(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.alpha_num_dash_score', inputValue, 'must be alphanumeric, dashes, and underscores only')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid RFC email address format. */
|
||||
export function email(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.email', inputValue, 'must be an email address')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid IPv4 or IPv6 address. */
|
||||
export function ip(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.ip', inputValue, 'must be a valid IP address')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid IPv4 address. */
|
||||
export function ipv4(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.ip.v4', inputValue, 'must be a valid IP version 4 address')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid IPv6 address. */
|
||||
export function ipv6(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.ip.v6', inputValue, 'must be a valid IP version 6 address')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid file MIME type. */
|
||||
export function mime(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.mime', inputValue, 'must be a valid MIME-type')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid RFC URL format. */
|
||||
export function url(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.url', inputValue, 'must be a valid URL')
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid RFC UUID format. */
|
||||
export function uuid(fieldName: string, inputValue: any): ValidationResult {
|
||||
return validateRex('string.is.uuid', inputValue, 'must be a valid UUID')
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validation function that requires the input value to match the given regex.
|
||||
* @param rex
|
||||
*/
|
||||
export function regex(rex: RegExp): ValidatorFunction {
|
||||
return function regex(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( rex.test(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'is not valid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validation function that requires the input to NOT match the given regex.
|
||||
* @param rex
|
||||
*/
|
||||
export function notRegex(rex: RegExp): ValidatorFunction {
|
||||
return function notRegex(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( !rex.test(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'is not valid'
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validation function that requires the given input to end with the substring.
|
||||
* @param substr
|
||||
*/
|
||||
export function ends(substr: string): ValidatorFunction {
|
||||
return function ends(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).endsWith(substr) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must end with "${substr}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validation function that requires the given input to begin with the substring.
|
||||
* @param substr
|
||||
*/
|
||||
export function begins(substr: string): ValidatorFunction {
|
||||
return function begins(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( String(inputValue).startsWith(substr) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must begin with "${substr}"`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/** Requires the input value to be a valid JSON string. */
|
||||
export function json(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( isJSON(inputValue) ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: 'must be valid JSON'
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have exactly len many characters.
|
||||
* @param len
|
||||
*/
|
||||
export function length(len: number): ValidatorFunction {
|
||||
return function length(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue.length === len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be exactly of length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have at least len many characters.
|
||||
* @param len
|
||||
*/
|
||||
export function lengthMin(len: number): ValidatorFunction {
|
||||
return function lengthMin(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue.length >= len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at least length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Builds a validator function that requires the input value to have at most len many characters.
|
||||
* @param len
|
||||
*/
|
||||
export function lengthMax(len: number): ValidatorFunction {
|
||||
return function lengthMax(fieldName: string, inputValue: any): ValidationResult {
|
||||
if ( inputValue.length <= len ) {
|
||||
return { valid: true }
|
||||
}
|
||||
|
||||
return {
|
||||
valid: false,
|
||||
message: `must be at most length ${len}`
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
85
src/forms/rules/types.ts
Normal file
85
src/forms/rules/types.ts
Normal file
@ -0,0 +1,85 @@
|
||||
/**
|
||||
* Additional parameters passed to complex validation functions.
|
||||
*/
|
||||
export interface ValidatorFunctionParams {
|
||||
/** The entire original input data. */
|
||||
data: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface representing the result of an attempted validation that failed.
|
||||
*/
|
||||
export interface ValidationErrorResult {
|
||||
/** Whether or not the validation succeeded. */
|
||||
valid: false
|
||||
|
||||
/**
|
||||
* The human-readable error message(s) describing the issue.
|
||||
*/
|
||||
message?: string | string[]
|
||||
|
||||
/**
|
||||
* If true, validation of subsequent fields will stop.
|
||||
*/
|
||||
stopValidation?: boolean
|
||||
}
|
||||
|
||||
/**
|
||||
* An interface representing the result of an attempted validation that succeeded.
|
||||
*/
|
||||
export interface ValidationSuccessResult {
|
||||
/** Whether or not the validation succeeded. */
|
||||
valid: true
|
||||
|
||||
/**
|
||||
* If the value was cast to a different type, or inferred, as a result of this validation,
|
||||
* provide it here. It will replace the input string as the value of the field in the form.
|
||||
*/
|
||||
castValue?: any
|
||||
|
||||
/**
|
||||
* If true, validation of subsequent fields will stop.
|
||||
*/
|
||||
stopValidation?: boolean
|
||||
}
|
||||
|
||||
/** All possible results of an attempted validation. */
|
||||
export type ValidationResult = ValidationErrorResult | ValidationSuccessResult
|
||||
|
||||
/** A validator function that takes only the field key and the object value. */
|
||||
export type SimpleValidatorFunction = (fieldName: string, inputValue: any) => ValidationResult | Promise<ValidationResult>
|
||||
|
||||
/** A validator function that takes the field key, the object value, and an object of contextual params. */
|
||||
export type ComplexValidatorFunction = (fieldName: string, inputValue: any, params: ValidatorFunctionParams) => ValidationResult | Promise<ValidationResult>
|
||||
|
||||
/** Useful type alias for all allowed validator function signatures. */
|
||||
export type ValidatorFunction = SimpleValidatorFunction | ComplexValidatorFunction
|
||||
|
||||
/**
|
||||
* A set of validation rules that are applied to input objects on validators.
|
||||
*
|
||||
* The keys of this object are deep-nested keys and can be used to validate
|
||||
* nested properties.
|
||||
*
|
||||
* For example, the key "user.links.*.url" refers to the "url" property of all
|
||||
* objects in the "links" array on the "user" object on:
|
||||
*
|
||||
* ```json
|
||||
* {
|
||||
* "user": {
|
||||
* "links": [
|
||||
* {
|
||||
* "url": "..."
|
||||
* },
|
||||
* {
|
||||
* "url": "..."
|
||||
* }
|
||||
* ]
|
||||
* }
|
||||
* }
|
||||
* ```
|
||||
*/
|
||||
export type ValidationRules = {[key: string]: ValidatorFunction | ValidatorFunction[]}
|
||||
|
||||
/** A type alias denoting that a particular type has been validated. */
|
||||
export type Valid<T> = T
|
46
src/forms/templates/form.ts
Normal file
46
src/forms/templates/form.ts
Normal file
@ -0,0 +1,46 @@
|
||||
import {UniversalPath} from '../../util'
|
||||
import {Template} from '../../cli'
|
||||
|
||||
const form_template: Template = {
|
||||
name: 'form',
|
||||
fileSuffix: '.form.ts',
|
||||
description: 'Create a new form request validator',
|
||||
baseAppPath: ['http', 'forms'],
|
||||
render(name: string, fullCanonicalName: string, targetFilePath: UniversalPath) {
|
||||
return `import {FormRequest, ValidationRules, Rule} from '@extollo/forms'
|
||||
import {Injectable} from '@extollo/di'
|
||||
|
||||
/**
|
||||
* ${name} object
|
||||
* ----------------------------
|
||||
* This is the object interface that is guaranteed when
|
||||
* all of the validation rules below pass.
|
||||
*/
|
||||
export interface ${name}Form {
|
||||
|
||||
}
|
||||
|
||||
/**
|
||||
* ${name}Request validator
|
||||
* ----------------------------
|
||||
* Request validator that defines the rules needed to guarantee
|
||||
* that a request's input conforms to the interface defined above.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name}FormRequest extends FormRequest<${name}Form> {
|
||||
/**
|
||||
* The validation rules that should be applied to the various
|
||||
* request input fields.
|
||||
* @protected
|
||||
*/
|
||||
protected getRules(): ValidationRules {
|
||||
return {
|
||||
|
||||
}
|
||||
}
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { form_template }
|
18
src/forms/unit/Forms.ts
Normal file
18
src/forms/unit/Forms.ts
Normal file
@ -0,0 +1,18 @@
|
||||
import {Singleton, Inject} from '../../di'
|
||||
import {CommandLine} from '../../cli'
|
||||
import {form_template} from '../templates/form'
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
@Singleton()
|
||||
export class Forms extends Unit {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
public async up(): Promise<void> {
|
||||
this.cli.registerTemplate(form_template)
|
||||
}
|
||||
}
|
@ -1,5 +1,5 @@
|
||||
import {Request} from "../lifecycle/Request";
|
||||
import {uninfer, infer, uuid_v4} from "@extollo/util";
|
||||
import {uninfer, infer, uuid_v4} from "../../util";
|
||||
|
||||
/**
|
||||
* Base type representing a parsed cookie.
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Inject, Instantiable, Singleton} from "@extollo/di"
|
||||
import {Collection, HTTPStatus} from "@extollo/util"
|
||||
import {Inject, Instantiable, Singleton} from "../../di"
|
||||
import {Collection, HTTPStatus} from "../../util"
|
||||
import {HTTPKernelModule} from "./HTTPKernelModule";
|
||||
import {Logging} from "../../service/Logging";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {HTTPKernelModule} from "../HTTPKernelModule";
|
||||
import {Injectable} from "@extollo/di"
|
||||
import {ErrorWithContext} from "@extollo/util"
|
||||
import {Injectable} from "../../../di"
|
||||
import {ErrorWithContext} from "../../../util"
|
||||
import {HTTPKernel} from "../HTTPKernel";
|
||||
import {Request} from "../../lifecycle/Request";
|
||||
import {SetSessionCookieHTTPModule} from "./SetSessionCookieHTTPModule";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Injectable, Inject} from "@extollo/di"
|
||||
import {Injectable, Inject} from "../../../di"
|
||||
import {HTTPKernelModule} from "../HTTPKernelModule";
|
||||
import {HTTPKernel} from "../HTTPKernel";
|
||||
import {Request} from "../../lifecycle/Request";
|
||||
|
@ -2,11 +2,11 @@ import {HTTPKernelModule} from "../HTTPKernelModule"
|
||||
import {HTTPKernel} from "../HTTPKernel"
|
||||
import * as Busboy from "busboy"
|
||||
import {Request} from "../../lifecycle/Request"
|
||||
import {infer, uuid_v4} from "@extollo/util"
|
||||
import {infer, uuid_v4} from "../../../util"
|
||||
import {Files} from "../../../service/Files"
|
||||
import {Config} from "../../../service/Config"
|
||||
import {Logging} from "../../../service/Logging"
|
||||
import {Injectable, Inject, Container} from "@extollo/di"
|
||||
import {Injectable, Inject, Container} from "../../../di"
|
||||
|
||||
@Injectable()
|
||||
export class ParseIncomingBodyHTTPModule extends HTTPKernelModule {
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {HTTPKernelModule} from "../HTTPKernelModule";
|
||||
import {Request} from "../../lifecycle/Request";
|
||||
import {Injectable, Inject} from "@extollo/di"
|
||||
import {Injectable, Inject} from "../../../di"
|
||||
import {HTTPKernel} from "../HTTPKernel";
|
||||
import {Config} from "../../../service/Config";
|
||||
|
||||
|
@ -1,6 +1,6 @@
|
||||
import {HTTPKernelModule} from "../HTTPKernelModule";
|
||||
import {Injectable, Inject} from "@extollo/di";
|
||||
import {uuid_v4} from "@extollo/util";
|
||||
import {Injectable, Inject} from "../../../di";
|
||||
import {uuid_v4} from "../../../util";
|
||||
import {HTTPKernel} from "../HTTPKernel";
|
||||
import {Request} from "../../lifecycle/Request";
|
||||
import {Logging} from "../../../service/Logging";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Injectable, ScopedContainer, Container} from "@extollo/di"
|
||||
import {infer, UniversalPath} from "@extollo/util"
|
||||
import {Injectable, ScopedContainer, Container} from "../../di"
|
||||
import {infer, UniversalPath} from "../../util"
|
||||
import {IncomingMessage, ServerResponse} from "http"
|
||||
import {HTTPCookieJar} from "../kernel/HTTPCookieJar";
|
||||
import {TLSSocket} from "tls";
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Request} from "./Request";
|
||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from "@extollo/util"
|
||||
import {ErrorWithContext, HTTPStatus, BehaviorSubject} from "../../util"
|
||||
import {ServerResponse} from "http"
|
||||
import {HTTPCookieJar} from "../kernel/HTTPCookieJar";
|
||||
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {ResponseFactory} from "./ResponseFactory"
|
||||
import {ErrorWithContext, HTTPStatus} from "@extollo/util"
|
||||
import {ErrorWithContext, HTTPStatus} from "../../util"
|
||||
import {Request} from "../lifecycle/Request";
|
||||
import * as api from "./api"
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Container} from "@extollo/di";
|
||||
import {Container} from "../../di";
|
||||
import {ResponseFactory} from "./ResponseFactory";
|
||||
import {Request} from "../lifecycle/Request";
|
||||
import {ViewEngine} from "../../views/ViewEngine";
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {ErrorWithContext} from "@extollo/util";
|
||||
import {ErrorWithContext} from "../../util";
|
||||
import {ResolvedRouteHandler, Route} from "./Route";
|
||||
|
||||
/**
|
||||
|
@ -5,8 +5,8 @@ import {RouteGroup} from "./RouteGroup";
|
||||
import {ResponseFactory} from "../response/ResponseFactory";
|
||||
import {Response} from "../lifecycle/Response";
|
||||
import {Controllers} from "../../service/Controllers";
|
||||
import {ErrorWithContext, Collection} from "@extollo/util";
|
||||
import {Container} from "@extollo/di";
|
||||
import {ErrorWithContext, Collection} from "../../util";
|
||||
import {Container} from "../../di";
|
||||
import {Controller} from "../Controller";
|
||||
import {Middlewares} from "../../service/Middlewares";
|
||||
import {Middleware} from "./Middleware";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import {Collection, ErrorWithContext} from "@extollo/util"
|
||||
import {Collection, ErrorWithContext} from "../../util"
|
||||
import {AppClass} from "../../lifecycle/AppClass"
|
||||
import {RouteHandler} from "./Route"
|
||||
import {Container} from "@extollo/di"
|
||||
import {Container} from "../../di"
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Injectable, Inject} from "@extollo/di"
|
||||
import {ErrorWithContext} from "@extollo/util"
|
||||
import {Injectable, Inject} from "../../di"
|
||||
import {ErrorWithContext} from "../../util"
|
||||
import {Request} from "../lifecycle/Request"
|
||||
|
||||
/**
|
||||
|
@ -6,8 +6,8 @@ import {
|
||||
isInstantiable,
|
||||
DEPENDENCY_KEYS_METADATA_KEY,
|
||||
DEPENDENCY_KEYS_PROPERTY_METADATA_KEY
|
||||
} from "@extollo/di"
|
||||
import {Collection, ErrorWithContext} from "@extollo/util"
|
||||
} from "../../di"
|
||||
import {Collection, ErrorWithContext} from "../../util"
|
||||
import {MemorySession} from "./MemorySession";
|
||||
import {Session} from "./Session";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
3
src/i18n/index.ts
Normal file
3
src/i18n/index.ts
Normal file
@ -0,0 +1,3 @@
|
||||
export * from './service/Locale'
|
||||
export * from './modules/InjectRequestLocale'
|
||||
export * from './service/Internationalization'
|
40
src/i18n/modules/InjectRequestLocale.ts
Normal file
40
src/i18n/modules/InjectRequestLocale.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import {Request} from "../../http/lifecycle/Request";
|
||||
import {Injectable} from "../../di"
|
||||
import {Locale} from "../service/Locale";
|
||||
import {HTTPKernelModule} from "../../http/kernel/HTTPKernelModule";
|
||||
import {HTTPKernel} from "../../http/kernel/HTTPKernel";
|
||||
import {InjectSessionHTTPModule} from "../../http/kernel/module/InjectSessionHTTPModule";
|
||||
import {Session} from "../../http/session/Session";
|
||||
|
||||
/**
|
||||
* An HTTP kernel module that adds the Locale service to the request container.
|
||||
*/
|
||||
@Injectable()
|
||||
export class InjectRequestLocale extends HTTPKernelModule {
|
||||
public executeWithBlockingWriteback = true
|
||||
|
||||
/** Register this kernel module to the given kernel. */
|
||||
public static register(kernel: HTTPKernel) {
|
||||
kernel.register(this).after(InjectSessionHTTPModule)
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets or sets the default locale in the session and instantiates a Locale
|
||||
* service into the Request container based on said locale.
|
||||
* @param request
|
||||
*/
|
||||
public async apply(request: Request) {
|
||||
const session = <Session> request.make(Session)
|
||||
const locale = <Locale> request.make(Locale)
|
||||
|
||||
// Set the default locale in the session
|
||||
if ( !session.get('i18n.locale') ) {
|
||||
session.set('i18n.locale', locale.getDefaultLocale())
|
||||
}
|
||||
|
||||
locale.setLocale(session.get('i18n.locale'))
|
||||
request.registerSingletonInstance(Locale, locale)
|
||||
|
||||
return request
|
||||
}
|
||||
}
|
43
src/i18n/service/Internationalization.ts
Normal file
43
src/i18n/service/Internationalization.ts
Normal file
@ -0,0 +1,43 @@
|
||||
import {Singleton, Inject} from "../../di"
|
||||
import {CommandLine} from "../../cli"
|
||||
import {InjectRequestLocale} from "../modules/InjectRequestLocale"
|
||||
import {locale_template} from "../template/locale"
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
import {HTTPKernel} from "../../http/kernel/HTTPKernel";
|
||||
import {Config} from "../../service/Config";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Application unit to register @extollo/i18n resources.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Internationalization extends Unit {
|
||||
@Inject()
|
||||
protected readonly kernel!: HTTPKernel
|
||||
|
||||
@Inject()
|
||||
protected readonly config!: Config
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
/**
|
||||
* Registers the locale template and middleware, if enabled by config.
|
||||
*
|
||||
* You can set the "locale.enable" config property to `false` to disable
|
||||
* the InjectRequestLocale HTTP kernel module.
|
||||
*/
|
||||
up() {
|
||||
this.logging.debug(`Registering locale template with CLI...`)
|
||||
this.cli.registerTemplate(locale_template)
|
||||
|
||||
if ( this.config.get('locale.enable', true) ) {
|
||||
this.kernel.register(InjectRequestLocale).before()
|
||||
} else {
|
||||
this.logging.warn(`@extollo/i18n is registered, but disabled by config. Localization will not be done per-request.`)
|
||||
}
|
||||
}
|
||||
}
|
206
src/i18n/service/Locale.ts
Normal file
206
src/i18n/service/Locale.ts
Normal file
@ -0,0 +1,206 @@
|
||||
import {Injectable} from "../../di"
|
||||
import {ErrorWithContext} from "../../util"
|
||||
import * as pluralize from "pluralize"
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
import {Config} from "../../service/Config";
|
||||
|
||||
/**
|
||||
* Type name for the standalone localization helper function that can be passed around
|
||||
* in lieu of the Locale service.
|
||||
*/
|
||||
export type LocaleHelper = (phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}}) => string
|
||||
|
||||
/**
|
||||
* Request-level service that provides localization of phrases based on config files.
|
||||
*/
|
||||
@Injectable()
|
||||
export class Locale extends AppClass {
|
||||
protected get config() {
|
||||
// For some reason, was having issues with this not injecting properly.
|
||||
// TODO convert this back to @Inject() and solve that bug
|
||||
return this.app().make<Config>(Config)
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The preferred locale. This corresponds to the config prefix.
|
||||
* @example en_US means "lang:en_US" config scope
|
||||
* @example es_MX means "lang:es_MX" config scope
|
||||
*/
|
||||
protected locale?: string
|
||||
) {
|
||||
super()
|
||||
if ( !this.locale ) {
|
||||
this.locale = this.getDefaultLocale()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the default locale that should be assigned if none is specified in the session.
|
||||
* @return string
|
||||
*/
|
||||
getDefaultLocale() {
|
||||
return this.config.get('locale.default', 'en_US')
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the preferred locale for lookups by this service.
|
||||
* @param locale
|
||||
*/
|
||||
setLocale(locale: string) {
|
||||
this.locale = locale
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a wrapped helper function that can be passed around instead of this service.
|
||||
*/
|
||||
helper(): LocaleHelper {
|
||||
return (phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}} = {}) => {
|
||||
return this.get(phrase, {plural, fallback, interp})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the given phrase to the locale of this service. This is where all the magic happens.
|
||||
*
|
||||
* This method tries to load the given phrase from the config using `load()` then localizes it
|
||||
* using the specified parameters.
|
||||
*
|
||||
* @example
|
||||
* The pluralization can be specified using the `plural` parameter. If an explicit pluralization
|
||||
* is specified in the phrase config, that will be used. Otherwise, the `pluralize` library is
|
||||
* used to generate one automatically.
|
||||
*
|
||||
* ```typescript
|
||||
* // Example phrase config:
|
||||
* {
|
||||
* apple: 'Apple',
|
||||
* bunch_of_bananas: {
|
||||
* one: 'Bunch of Bananas',
|
||||
* many: 'Bunches of Bananas',
|
||||
* }
|
||||
* }
|
||||
*
|
||||
* // Example usage:
|
||||
* locale.get('apple', { plural: 3 }) // => 'Apples'
|
||||
* locale.get('apple') // => 'Apple'
|
||||
* locale.get('bunch_of_bananas', { plural: 2 }) // => 'Bunches of Bananas'
|
||||
* locale.get('bunch_of_bananas', { plural: 1}) // => 'Bunch of Bananas'
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* If a translation cannot be found, and a fallback is specified, the fallback will be returned.
|
||||
* Otherwise, the value of `phrase` is returned instead.
|
||||
*
|
||||
* ```typescript
|
||||
* locale.get('nonexistent_phrase', { fallback: 'Hello, world!' }) // => 'Hello, world!'
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* Values can be interpolated into phrases using the `interp` parameter. For example, if there is
|
||||
* a phrase `my_phrase: 'Hello, :name:!`, the value of `:name:` can be replaced like so:
|
||||
*
|
||||
* ```typescript
|
||||
* locale.get('my_phrase', {interp: {name: 'John Doe'}}) // => 'Hello, John Doe!'
|
||||
* ```
|
||||
*
|
||||
* @example
|
||||
* If a phrase cannot be found in the specific locale config, the service will try to load it from
|
||||
* the equivalent `common` locale config. For example, if `this.locale` is `es_MX`:
|
||||
*
|
||||
* ```typescript
|
||||
* // Example "lang:common:dashboard" config:
|
||||
* {
|
||||
* title: "MyDash2.0",
|
||||
* header: "Welcome to the dashboard!",
|
||||
* }
|
||||
*
|
||||
* // Example "lang:es_MX:dashboard" config:
|
||||
* {
|
||||
* header: "¡Bienvenido al panel de control!",
|
||||
* }
|
||||
*
|
||||
* // Example usage:
|
||||
* locale.get('dashboard.title') // => 'MyDash2.0'
|
||||
* locale.get('dashboard.header') // => '¡Bienvenido al panel de control!'
|
||||
* ```
|
||||
*
|
||||
* @param phrase
|
||||
* @param plural
|
||||
* @param fallback
|
||||
* @param interp
|
||||
*/
|
||||
get(phrase: string, { plural, fallback, interp }: {plural?: number, fallback?: string, interp?: {[key: string]: any}} = {}): string {
|
||||
const scope = phrase.split(':').reverse().slice(1).reverse().join(':')
|
||||
const specific = phrase.split(':').reverse()[0]
|
||||
const load = `${this.locale}${scope ? ':' + scope : ''}.${specific}`
|
||||
const translated = this.load(load)
|
||||
const is_plural = plural && plural !== 1
|
||||
|
||||
if ( !translated ) {
|
||||
return fallback ?? specific
|
||||
}
|
||||
|
||||
let ret = ''
|
||||
|
||||
if ( typeof translated === 'object' ) {
|
||||
if ( is_plural && translated.many ) {
|
||||
ret = translated.many
|
||||
} else if ( is_plural && translated.one ) {
|
||||
ret = pluralize(translated.one, plural)
|
||||
} else if ( !is_plural && translated.one ) {
|
||||
ret = translated.one
|
||||
} else if ( !is_plural && translated.many ) {
|
||||
ret = pluralize(translated.many, 1)
|
||||
} else {
|
||||
throw new ErrorWithContext(`Invalid translation config for ${phrase}. Must provide 'one' or 'many' keys.`, {
|
||||
locale: this.locale,
|
||||
phrase,
|
||||
plural,
|
||||
fallback,
|
||||
translated,
|
||||
})
|
||||
}
|
||||
} else if ( typeof translated === 'string' ) {
|
||||
ret = pluralize(translated, is_plural ? 5 : 1)
|
||||
} else {
|
||||
throw new ErrorWithContext(`Invalid translation object for ${phrase}.`, {
|
||||
locale: this.locale,
|
||||
phrase,
|
||||
plural,
|
||||
fallback,
|
||||
translated,
|
||||
})
|
||||
}
|
||||
|
||||
if ( interp ) {
|
||||
for ( const key in interp ) {
|
||||
const rex = new RegExp(`:${key}:`, 'g')
|
||||
ret = ret.replace(rex, interp[key])
|
||||
}
|
||||
}
|
||||
|
||||
return ret
|
||||
}
|
||||
|
||||
/**
|
||||
* Attempt to load the given locale string, merging in the appropriate common locale values.
|
||||
* @param locale
|
||||
* @protected
|
||||
*/
|
||||
protected load(locale: string) {
|
||||
const subloc = locale.split(':').slice(1).join(':')
|
||||
const specific_loc = locale.split(':').reverse()[0].split('.').slice(1).join('.')
|
||||
|
||||
let common: any = this.config.get(`lang:common${subloc ? ':' + subloc : ''}${specific_loc ? '.' + specific_loc : ''}`, undefined)
|
||||
let specific: any = this.config.get(`lang:${locale}`, undefined)
|
||||
|
||||
if ( typeof specific === 'string' ) return specific
|
||||
if ( typeof common === 'string' && typeof specific === 'undefined' ) return common
|
||||
|
||||
if ( !common ) common = {}
|
||||
if ( !specific ) specific = {}
|
||||
|
||||
return {...common, ...specific}
|
||||
}
|
||||
}
|
30
src/i18n/template/locale.ts
Normal file
30
src/i18n/template/locale.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {Template} from "../../cli"
|
||||
import {UniversalPath} from "../../util"
|
||||
import {Container} from "../../di"
|
||||
import {Config} from "../../service/Config";
|
||||
|
||||
/**
|
||||
* CLI template that generates a new locale file.
|
||||
* Automatically adds placeholder entries for phrases that exist in the
|
||||
* associated common locale file.
|
||||
*/
|
||||
const locale_template: Template = {
|
||||
name: 'locale',
|
||||
fileSuffix: '.config.ts',
|
||||
description: 'Create a new config file that specifies translations for a locale.',
|
||||
baseAppPath: ['configs', 'lang'],
|
||||
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => {
|
||||
const config = <Config> Container.getContainer().make(Config)
|
||||
const subloc = fullCanonicalName.split(':').slice(1).join(':')
|
||||
const common: any = config.get(`lang:common${subloc ? ':' + subloc : ''}`, {})
|
||||
|
||||
return `import {env} from '@extollo/lib'
|
||||
|
||||
export default {
|
||||
${Object.keys(common).map(key => ' ' + key + ': \'\',\n')}
|
||||
}
|
||||
`
|
||||
},
|
||||
}
|
||||
|
||||
export { locale_template }
|
@ -1,3 +1,6 @@
|
||||
export * from './util'
|
||||
export * from './di'
|
||||
|
||||
export * from './service/Logging'
|
||||
|
||||
export * from './lifecycle/RunLevelErrorHandler'
|
||||
@ -60,10 +63,14 @@ export * from './service/HTTPServer'
|
||||
export * from './service/Routing'
|
||||
export * from './service/Middlewares'
|
||||
|
||||
export * from './support/cache/Cache'
|
||||
export * from './support/cache/MemoryCache'
|
||||
export * from './support/cache/CacheFactory'
|
||||
|
||||
export * from './views/ViewEngine'
|
||||
export * from './views/ViewEngineFactory'
|
||||
export * from './views/PugViewEngine'
|
||||
|
||||
export * from './cli'
|
||||
export * from './i18n'
|
||||
export * from './forms'
|
||||
export * from './orm'
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Application} from './Application';
|
||||
import {Container, DependencyKey} from "@extollo/di";
|
||||
import {Container, DependencyKey} from "../di";
|
||||
|
||||
/**
|
||||
* Base type for a class that supports binding methods by string.
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Container} from '@extollo/di';
|
||||
import {Container} from '../di';
|
||||
import {
|
||||
ErrorWithContext,
|
||||
globalRegistry,
|
||||
@ -8,7 +8,7 @@ import {
|
||||
StandardLogger,
|
||||
universalPath,
|
||||
UniversalPath
|
||||
} from '@extollo/util';
|
||||
} from '../util';
|
||||
|
||||
import {Logging} from '../service/Logging';
|
||||
import {RunLevelErrorHandler} from "./RunLevelErrorHandler";
|
||||
|
@ -1,7 +1,7 @@
|
||||
import * as color from 'colors/safe'
|
||||
import {Logging} from "../service/Logging";
|
||||
import {Inject} from "@extollo/di";
|
||||
import {ErrorWithContext} from "@extollo/util";
|
||||
import {Inject} from "../di";
|
||||
import {ErrorWithContext} from "../util";
|
||||
|
||||
/**
|
||||
* Class with logic for handling errors that are thrown at the run-level of the application.
|
||||
|
68
src/orm/DatabaseService.ts
Normal file
68
src/orm/DatabaseService.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {Connection} from "./connection/Connection";
|
||||
import {Inject, Singleton} from "../di";
|
||||
import {ErrorWithContext, uuid_v4} from "../util";
|
||||
import {AppClass} from "../lifecycle/AppClass";
|
||||
import {Logging} from "../service/Logging";
|
||||
|
||||
/**
|
||||
* A singleton, non-unit service that stores and retrieves database connections by name.
|
||||
*/
|
||||
@Singleton()
|
||||
export class DatabaseService extends AppClass {
|
||||
@Inject()
|
||||
protected logging!: Logging
|
||||
|
||||
/** Mapping of connection name -> connection instance for connections registered with this service. */
|
||||
protected readonly connections: { [key: string]: Connection } = {}
|
||||
|
||||
/**
|
||||
* Register a new connection instance by name.
|
||||
* @param name
|
||||
* @param connection
|
||||
*/
|
||||
register(name: string, connection: Connection) {
|
||||
if ( this.connections[name] ) {
|
||||
this.logging.warn(`Overriding duplicate connection: ${name}`)
|
||||
}
|
||||
|
||||
this.connections[name] = connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if a connection is registered with the given name.
|
||||
* @param name
|
||||
*/
|
||||
has(name: string) {
|
||||
return !!this.connections[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a connection instance by its name. Throws if none exists.
|
||||
* @param name
|
||||
*/
|
||||
get(name: string): Connection {
|
||||
if ( !this.has(name) ) {
|
||||
throw new ErrorWithContext(`No such connection is registered: ${name}`)
|
||||
}
|
||||
|
||||
return this.connections[name]
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an array of the names of all registered connections.
|
||||
*/
|
||||
names(): string[] {
|
||||
return Object.keys(this.connections)
|
||||
}
|
||||
|
||||
/** Get a guaranteed-unique connection name. */
|
||||
uniqueName(): string {
|
||||
let name: string;
|
||||
|
||||
do {
|
||||
name = uuid_v4()
|
||||
} while (this.has(name))
|
||||
|
||||
return name
|
||||
}
|
||||
}
|
574
src/orm/builder/AbstractBuilder.ts
Normal file
574
src/orm/builder/AbstractBuilder.ts
Normal file
@ -0,0 +1,574 @@
|
||||
import {Inject} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {
|
||||
Constraint, ConstraintConnectionOperator,
|
||||
ConstraintOperator,
|
||||
OrderDirection,
|
||||
OrderStatement, QueryResult,
|
||||
QuerySource,
|
||||
SpecifiedField
|
||||
} from "../types";
|
||||
import {Connection} from "../connection/Connection";
|
||||
import {deepCopy, ErrorWithContext} from "../../util";
|
||||
import {EscapeValue, QuerySafeValue, raw} from "../dialect/SQLDialect";
|
||||
import {ResultCollection} from "./result/ResultCollection";
|
||||
import {AbstractResultIterable} from "./result/AbstractResultIterable";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
||||
/**
|
||||
* Type alias for a function that applies some constraints to a builder group.
|
||||
*/
|
||||
export type ConstraintGroupClosure<T> = (group: AbstractBuilder<T>) => any
|
||||
|
||||
/**
|
||||
* A base class that facilitates building database queries using a fluent interface.
|
||||
* This can be specialized by child-classes to yield query results of the given type `T`.
|
||||
*/
|
||||
export abstract class AbstractBuilder<T> extends AppClass {
|
||||
@Inject()
|
||||
protected readonly databaseService!: DatabaseService
|
||||
|
||||
/** Constraints applied to this query. */
|
||||
protected constraints: Constraint[] = []
|
||||
|
||||
/** The source table to query from. */
|
||||
protected source?: QuerySource
|
||||
|
||||
/** The fields to query from the table. */
|
||||
protected _fields: SpecifiedField[] = []
|
||||
|
||||
/** The number of records to skip before the result set. */
|
||||
protected _skip?: number
|
||||
|
||||
/** The max number of records to include in the result set. */
|
||||
protected _take?: number
|
||||
|
||||
/** If true, the query should refer to distinct records. */
|
||||
protected _distinct: boolean = false
|
||||
|
||||
/** Array of SQL group-by clauses. */
|
||||
protected _groupings: string[] = []
|
||||
|
||||
/** Array of SQL order-by clauses. */
|
||||
protected _orders: OrderStatement[] = []
|
||||
|
||||
/** The connection on which the query should be executed. */
|
||||
protected _connection?: Connection
|
||||
|
||||
/**
|
||||
* Create a new, empty, instance of the current builder.
|
||||
*/
|
||||
public abstract getNewInstance(): AbstractBuilder<T>
|
||||
|
||||
/**
|
||||
* Get a result iterable for the built query.
|
||||
*/
|
||||
public abstract getResultIterable(): AbstractResultIterable<T>
|
||||
|
||||
/**
|
||||
* Clone the current query to a new AbstractBuilder instance with the same properties.
|
||||
*/
|
||||
public clone(): AbstractBuilder<T> {
|
||||
const bldr = this.getNewInstance()
|
||||
|
||||
bldr.constraints = deepCopy(this.constraints)
|
||||
bldr.source = deepCopy(this.source)
|
||||
bldr._fields = deepCopy(this._fields)
|
||||
bldr._skip = deepCopy(this._skip)
|
||||
bldr._take = deepCopy(this._take)
|
||||
bldr._distinct = deepCopy(this._distinct)
|
||||
bldr._groupings = deepCopy(this._groupings)
|
||||
bldr._orders = deepCopy(this._orders)
|
||||
bldr._connection = this._connection
|
||||
|
||||
return bldr
|
||||
}
|
||||
|
||||
/** Get the constraints applied to this query. */
|
||||
public get appliedConstraints() {
|
||||
return deepCopy(this.constraints)
|
||||
}
|
||||
|
||||
/** Get the fields that should be included in this query. */
|
||||
public get appliedFields() {
|
||||
return deepCopy(this._fields)
|
||||
}
|
||||
|
||||
/** Get the skip/take values of this query. */
|
||||
public get appliedPagination() {
|
||||
return { skip: this._skip, take: this._take }
|
||||
}
|
||||
|
||||
/** True if the query should be DISTINCT */
|
||||
public get appliedDistinction() {
|
||||
return this._distinct
|
||||
}
|
||||
|
||||
/** Get the SQL group-by clauses applied to this query. */
|
||||
public get appliedGroupings() {
|
||||
return deepCopy(this._groupings)
|
||||
}
|
||||
|
||||
/** Get the SQL order-by clauses applied to this query. */
|
||||
public get appliedOrder() {
|
||||
return deepCopy(this._orders)
|
||||
}
|
||||
|
||||
/** Get the source table for this query. */
|
||||
public get querySource() {
|
||||
if ( this.source ) return deepCopy(this.source)
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the source table (and optional alias) for this query.
|
||||
* @param table
|
||||
* @param alias
|
||||
*/
|
||||
from(table: string, alias?: string) {
|
||||
if ( alias ) {
|
||||
this.source = { table, alias }
|
||||
} else {
|
||||
this.source = table
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `from()`.
|
||||
* @param table
|
||||
* @param alias
|
||||
*/
|
||||
table(table: string, alias?: string) {
|
||||
return this.from(table, alias)
|
||||
}
|
||||
|
||||
/**
|
||||
* Include the given field (and optional alias) in the query.
|
||||
* @param field
|
||||
* @param alias
|
||||
*/
|
||||
field(field: string | QuerySafeValue, alias?: string) {
|
||||
if ( alias ) {
|
||||
this._fields.push({ field, alias })
|
||||
} else {
|
||||
this._fields.push(field)
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Include the given fields in the query.
|
||||
* @param fields
|
||||
*/
|
||||
fields(...fields: SpecifiedField[]): this {
|
||||
this._fields = [...this._fields, ...fields]
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `fields()`.
|
||||
* @param fields
|
||||
*/
|
||||
returning(...fields: SpecifiedField[]): this {
|
||||
return this.fields(...fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `fields()`.
|
||||
* @param fields
|
||||
*/
|
||||
select(...fields: SpecifiedField[]): this {
|
||||
return this.fields(...fields)
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all selected fields from this query.
|
||||
*/
|
||||
clearFields() {
|
||||
this._fields = []
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
where(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('AND', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE constraint to the query, without escaping `operand`. Prefer `where()`.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
whereRaw(field: string, operator: ConstraintOperator, operand: string) {
|
||||
this.createConstraint('AND', field, operator, raw(operand))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a new WHERE NOT constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
whereNot(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('AND NOT', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhere(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('OR', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE NOT constraint to the query.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhereNot(field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: EscapeValue) {
|
||||
this.createConstraint('OR NOT', field, operator, operand)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE constraint to the query, without escaping `operand`. Prefer `orWhere()`.
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
*/
|
||||
orWhereRaw(field: string, operator: ConstraintOperator, operand: string) {
|
||||
this.createConstraint('OR', field, operator, raw(operand))
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
operand: values,
|
||||
preop: 'AND',
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply a WHERE NOT IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
whereNotIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
operand: values,
|
||||
preop: 'AND',
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'IN',
|
||||
operand: values,
|
||||
preop: 'OR'
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply an OR WHERE NOT IN constraint to the query, escaping the values in the set.
|
||||
* @param field
|
||||
* @param values
|
||||
*/
|
||||
orWhereNotIn(field: string, values: EscapeValue) {
|
||||
this.constraints.push({
|
||||
field,
|
||||
operator: 'NOT IN',
|
||||
operand: values,
|
||||
preop: 'OR'
|
||||
})
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Limit the query to a maximum number of rows.
|
||||
* @param rows
|
||||
*/
|
||||
limit(rows: number) {
|
||||
this._take = rows
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `limit()`.
|
||||
* @param rows
|
||||
*/
|
||||
take(rows: number) {
|
||||
return this.limit(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Skip the first `rows` many rows in the result set.
|
||||
* @param rows
|
||||
*/
|
||||
skip(rows: number) {
|
||||
this._skip = rows
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Alias of `skip()`.
|
||||
* @param rows
|
||||
*/
|
||||
offset(rows: number) {
|
||||
return this.skip(rows)
|
||||
}
|
||||
|
||||
/**
|
||||
* Make the query return only distinct rows.
|
||||
*/
|
||||
distinct() {
|
||||
this._distinct = true
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Allow the query to return non-distinct rows. (Undoes `distinct()`.)
|
||||
*/
|
||||
notDistinct() {
|
||||
this._distinct = false
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply `skip()` and `take()` calls to retrieve the records that should appear on
|
||||
* the `pageNum` page, assuming each page has `pageSize` many records.
|
||||
* @param pageNum
|
||||
* @param pageSize
|
||||
*/
|
||||
page(pageNum: number = 1, pageSize: number = 20) {
|
||||
this.skip(pageSize * (pageNum - 1))
|
||||
this.take(pageSize)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Apply one or more GROUP-BY clauses to the query.
|
||||
* @param groupings
|
||||
*/
|
||||
groupBy(...groupings: string[]) {
|
||||
this._groupings = groupings
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field.
|
||||
* @param field
|
||||
* @param direction
|
||||
*/
|
||||
orderBy(field: string, direction: OrderDirection = 'ASC') {
|
||||
this._orders.push({ field, direction })
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field, ascending.
|
||||
* @param field
|
||||
*/
|
||||
orderByAscending(field: string) {
|
||||
return this.orderBy(field, 'ASC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Order the query by the given field, descending.
|
||||
* @param field
|
||||
*/
|
||||
orderByDescending(field: string) {
|
||||
return this.orderBy(field, 'DESC')
|
||||
}
|
||||
|
||||
/**
|
||||
* Specify the connection name or instance to execute the query on.
|
||||
* @param nameOrInstance
|
||||
*/
|
||||
connection(nameOrInstance: string | Connection) {
|
||||
if ( nameOrInstance instanceof Connection ) {
|
||||
this._connection = nameOrInstance
|
||||
} else {
|
||||
this._connection = this.databaseService.get(nameOrInstance)
|
||||
}
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a result iterable for the rows of this query.
|
||||
*/
|
||||
iterator(): AbstractResultIterable<T> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
|
||||
}
|
||||
|
||||
return this.getResultIterable();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an async collection of the rows resulting from this query.
|
||||
*/
|
||||
get(): ResultCollection<T> {
|
||||
return new ResultCollection<T>(this.iterator())
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the first record matched by this query, if it exists.
|
||||
*/
|
||||
async first(): Promise<T | undefined> {
|
||||
return this.iterator().at(0)
|
||||
}
|
||||
|
||||
/**
|
||||
* Run an UPDATE query for all rows matched by this query, setting the given data.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* query.table('my_table').update({ my_col: 4 })
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* UPDATE TO my_table
|
||||
* SET
|
||||
* my_col = 4
|
||||
* ```
|
||||
*
|
||||
* @param data
|
||||
*/
|
||||
async update(data: {[key: string]: EscapeValue}): Promise<QueryResult> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderUpdate(this, data)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Execute a DELETE based on this query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* query.table('my_table').where('id', <, 44).delete()
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* DELETE
|
||||
* FROM my_table
|
||||
* WHERE
|
||||
* id < 44
|
||||
* ```
|
||||
*
|
||||
*/
|
||||
async delete(): Promise<QueryResult> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderDelete(this)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the given rows into the table for this query, returning the fields specified in this query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const rows = [
|
||||
* { name: 'A' },
|
||||
* { name: 'B' },
|
||||
* ]
|
||||
*
|
||||
* query.table('my_table')
|
||||
* .returning('id', 'name')
|
||||
* .insert(rows)
|
||||
* ```
|
||||
*
|
||||
* This is equivalent to:
|
||||
* ```sql
|
||||
* INSERT INTO my_table (name)
|
||||
* VALUES ('A'), ('B')
|
||||
* RETURNING id, name
|
||||
* ```
|
||||
*
|
||||
* @param rowOrRows
|
||||
*/
|
||||
async insert(rowOrRows: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[]) {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderInsert(this, rowOrRows)
|
||||
return this._connection.query(query)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if at least one row matches the current query.
|
||||
*/
|
||||
async exists() {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to execute update query.`)
|
||||
}
|
||||
|
||||
const query = this._connection.dialect().renderExistential(this)
|
||||
const result = await this._connection.query(query)
|
||||
return !!result.rows.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds a constraint to this query. This is used internally by the various `where`, `whereIn`, `orWhereNot`, &c.
|
||||
* @param preop
|
||||
* @param field
|
||||
* @param operator
|
||||
* @param operand
|
||||
* @private
|
||||
*/
|
||||
private createConstraint(preop: ConstraintConnectionOperator, field: string | ConstraintGroupClosure<T>, operator?: ConstraintOperator, operand?: any) {
|
||||
if ( typeof field === 'function' ) {
|
||||
const builder = this.getNewInstance()
|
||||
field(builder)
|
||||
this.constraints.push({
|
||||
preop,
|
||||
items: builder.appliedConstraints
|
||||
})
|
||||
} else if ( field && operator && typeof operand !== 'undefined' ) {
|
||||
this.constraints.push({
|
||||
field, operator, operand, preop, // FIXME escape operand
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
23
src/orm/builder/Builder.ts
Normal file
23
src/orm/builder/Builder.ts
Normal file
@ -0,0 +1,23 @@
|
||||
import {ErrorWithContext} from "../../util";
|
||||
import {Container} from "../../di";
|
||||
import {ResultIterable} from "./result/ResultIterable";
|
||||
import {QueryRow} from "../types";
|
||||
import {AbstractBuilder} from "./AbstractBuilder";
|
||||
import {AbstractResultIterable} from "./result/AbstractResultIterable";
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder class that returns simple QueryRow objects.
|
||||
*/
|
||||
export class Builder extends AbstractBuilder<QueryRow> {
|
||||
public getNewInstance(): AbstractBuilder<QueryRow> {
|
||||
return Container.getContainer().make<Builder>(Builder);
|
||||
}
|
||||
|
||||
public getResultIterable(): AbstractResultIterable<QueryRow> {
|
||||
if ( !this._connection ) {
|
||||
throw new ErrorWithContext(`No connection specified to fetch iterator for query.`)
|
||||
}
|
||||
|
||||
return Container.getContainer().make<ResultIterable>(ResultIterable, this, this._connection)
|
||||
}
|
||||
}
|
49
src/orm/builder/result/AbstractResultIterable.ts
Normal file
49
src/orm/builder/result/AbstractResultIterable.ts
Normal file
@ -0,0 +1,49 @@
|
||||
import {Collection, Iterable} from "../../../util"
|
||||
import {Connection} from "../../connection/Connection";
|
||||
import {AbstractBuilder} from "../AbstractBuilder";
|
||||
|
||||
/**
|
||||
* Base Iterable class that generates the results of a Builder query.
|
||||
*/
|
||||
export abstract class AbstractResultIterable<T> extends Iterable<T> {
|
||||
protected constructor(
|
||||
/** The builder whose results should be iterated */
|
||||
public readonly builder: AbstractBuilder<T>,
|
||||
|
||||
/** The connection on which to execute the builder. */
|
||||
public readonly connection: Connection,
|
||||
) { super() }
|
||||
|
||||
/**
|
||||
* Get the SQL string for the SELECT query for this iterable.
|
||||
*/
|
||||
public abstract get selectSQL(): string
|
||||
|
||||
/**
|
||||
* Get the result at index i.
|
||||
* @param i
|
||||
*/
|
||||
public abstract at(i: number): Promise<T | undefined>
|
||||
|
||||
/**
|
||||
* Get the results starting at index `start` and ending at index `end`.
|
||||
* @param start
|
||||
* @param end
|
||||
*/
|
||||
public abstract range(start: number, end: number): Promise<Collection<T>>
|
||||
|
||||
/**
|
||||
* Count the number of results of the query.
|
||||
*/
|
||||
public abstract count(): Promise<number>
|
||||
|
||||
/**
|
||||
* Return all items resulting from this query.
|
||||
*/
|
||||
public abstract all(): Promise<Collection<T>>
|
||||
|
||||
/**
|
||||
* Create a new iterable based on this query.
|
||||
*/
|
||||
public abstract clone(): AbstractResultIterable<T>
|
||||
}
|
17
src/orm/builder/result/ResultCollection.ts
Normal file
17
src/orm/builder/result/ResultCollection.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {AsyncCollection} from "../../../util";
|
||||
import {AbstractResultIterable} from "./AbstractResultIterable";
|
||||
|
||||
/**
|
||||
* Async collection class that iterates AbstractResultIterables in chunks.
|
||||
*/
|
||||
export class ResultCollection<T> extends AsyncCollection<T> {
|
||||
constructor(
|
||||
/** The result iterable to base the collection on. */
|
||||
iterator: AbstractResultIterable<T>,
|
||||
|
||||
/** The max number of records to request per-query, by default. */
|
||||
chunkSize: number = 500
|
||||
) {
|
||||
super(iterator, chunkSize)
|
||||
}
|
||||
}
|
44
src/orm/builder/result/ResultIterable.ts
Normal file
44
src/orm/builder/result/ResultIterable.ts
Normal file
@ -0,0 +1,44 @@
|
||||
import {QueryRow} from "../../types";
|
||||
import {Builder} from "../Builder";
|
||||
import {Connection} from "../../connection/Connection";
|
||||
import {AbstractResultIterable} from "./AbstractResultIterable";
|
||||
import {Collection} from "../../../util";
|
||||
|
||||
/**
|
||||
* Implementation of AbstractResultIterable that yields simple QueryRow instances (objects).
|
||||
*/
|
||||
export class ResultIterable extends AbstractResultIterable<QueryRow> {
|
||||
constructor(
|
||||
public readonly builder: Builder,
|
||||
public readonly connection: Connection,
|
||||
) { super(builder, connection) }
|
||||
|
||||
public get selectSQL() {
|
||||
return this.connection.dialect().renderSelect(this.builder)
|
||||
}
|
||||
|
||||
async at(i: number): Promise<QueryRow | undefined> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, i, i + 1)
|
||||
return (await this.connection.query(query)).rows.first()
|
||||
}
|
||||
|
||||
async range(start: number, end: number): Promise<Collection<QueryRow>> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
||||
return (await this.connection.query(query)).rows
|
||||
}
|
||||
|
||||
async count() {
|
||||
const query = this.connection.dialect().renderCount(this.selectSQL)
|
||||
const result = (await this.connection.query(query)).rows.first()
|
||||
return result?.extollo_render_count ?? 0
|
||||
}
|
||||
|
||||
async all(): Promise<Collection<QueryRow>> {
|
||||
const result = await this.connection.query(this.selectSQL)
|
||||
return result.rows
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ResultIterable(this.builder, this.connection)
|
||||
}
|
||||
}
|
65
src/orm/connection/Connection.ts
Normal file
65
src/orm/connection/Connection.ts
Normal file
@ -0,0 +1,65 @@
|
||||
import {Collection, ErrorWithContext} from "../../util";
|
||||
import {QueryResult} from "../types";
|
||||
import {SQLDialect} from "../dialect/SQLDialect";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
||||
/**
|
||||
* Error thrown when a connection is used before it is ready.
|
||||
* @extends Error
|
||||
*/
|
||||
export class ConnectionNotReadyError extends ErrorWithContext {
|
||||
constructor(name = '', context: {[key: string]: any} = {}) {
|
||||
super(`The connection ${name} is not ready and cannot execute queries.`)
|
||||
this.context = context
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract base class for database connections.
|
||||
* @abstract
|
||||
*/
|
||||
export abstract class Connection extends AppClass {
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* The name of this connection
|
||||
* @type string
|
||||
*/
|
||||
public readonly name: string,
|
||||
/**
|
||||
* This connection's config object
|
||||
*/
|
||||
public readonly config: any = {},
|
||||
) { super() }
|
||||
|
||||
public abstract dialect(): SQLDialect
|
||||
|
||||
/**
|
||||
* Open the connection.
|
||||
* @return Promise<void>
|
||||
*/
|
||||
public abstract init(): Promise<void>
|
||||
|
||||
/**
|
||||
* Execute an SQL query and get the result.
|
||||
* @param {string} query
|
||||
* @return Promise<QueryResult>
|
||||
*/
|
||||
public abstract query(query: string): Promise<QueryResult>
|
||||
|
||||
/**
|
||||
* Close the connection.
|
||||
* @return Promise<void>
|
||||
*/
|
||||
public abstract close(): Promise<void>
|
||||
|
||||
// public abstract databases(): Promise<Collection<Database>>
|
||||
|
||||
// public abstract database(name: string): Promise<Database | undefined>
|
||||
|
||||
// public abstract database_as_schema(name: string): Promise<Database>
|
||||
|
||||
// public abstract tables(database_name: string): Promise<Collection<Table>>
|
||||
|
||||
// public abstract table(database_name: string, table_name: string): Promise<Table | undefined>
|
||||
}
|
66
src/orm/connection/PostgresConnection.ts
Normal file
66
src/orm/connection/PostgresConnection.ts
Normal file
@ -0,0 +1,66 @@
|
||||
import {Connection, ConnectionNotReadyError} from "./Connection";
|
||||
import {Client} from "pg";
|
||||
import {Inject} from "../../di";
|
||||
import {QueryResult} from "../types";
|
||||
import {collect} from "../../util";
|
||||
import {SQLDialect} from "../dialect/SQLDialect";
|
||||
import {PostgreSQLDialect} from "../dialect/PostgreSQLDialect";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Type interface representing the config for a PostgreSQL connection.
|
||||
*/
|
||||
export interface PostgresConnectionConfig {
|
||||
user: string,
|
||||
host: string,
|
||||
database: string,
|
||||
password?: string,
|
||||
port?: string,
|
||||
}
|
||||
|
||||
/**
|
||||
* An implementation of a database Connection for dealing with PostgreSQL servers.
|
||||
*/
|
||||
export class PostgresConnection extends Connection {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging
|
||||
|
||||
/** The `pg` database client. */
|
||||
protected client?: Client
|
||||
|
||||
public dialect(): SQLDialect {
|
||||
return <PostgreSQLDialect> this.app().make(PostgreSQLDialect)
|
||||
}
|
||||
|
||||
public async init() {
|
||||
this.logging.debug(`Initializing PostgreSQL connection ${this.name}...`)
|
||||
this.client = new Client(this.config)
|
||||
await this.client.connect()
|
||||
}
|
||||
|
||||
public async close() {
|
||||
this.logging.debug(`Closing PostgreSQL connection ${this.name}...`)
|
||||
if ( this.client ) {
|
||||
await this.client.end()
|
||||
}
|
||||
}
|
||||
|
||||
public async query(query: string): Promise<QueryResult> {
|
||||
if ( !this.client ) throw new ConnectionNotReadyError(this.name, { config: JSON.stringify(this.config) })
|
||||
this.logging.verbose(`Executing query in connection ${this.name}: \n${query.split('\n').map(x => ' ' + x).join('\n')}`)
|
||||
|
||||
try {
|
||||
const result = await this.client.query(query)
|
||||
|
||||
return {
|
||||
rows: collect(result.rows),
|
||||
rowCount: result.rowCount,
|
||||
}
|
||||
} catch (e) {
|
||||
throw this.app().errorWrapContext(e, {
|
||||
query,
|
||||
connection: this.name,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
270
src/orm/dialect/PostgreSQLDialect.ts
Normal file
270
src/orm/dialect/PostgreSQLDialect.ts
Normal file
@ -0,0 +1,270 @@
|
||||
import {EscapeValue, QuerySafeValue, raw, SQLDialect} from './SQLDialect';
|
||||
import {Constraint, isConstraintGroup, isConstraintItem, SpecifiedField} from "../types";
|
||||
import {AbstractBuilder} from "../builder/AbstractBuilder";
|
||||
|
||||
/**
|
||||
* An implementation of the SQLDialect specific to PostgreSQL.
|
||||
*/
|
||||
export class PostgreSQLDialect extends SQLDialect {
|
||||
|
||||
public escape(value: EscapeValue): QuerySafeValue {
|
||||
if ( value instanceof QuerySafeValue ) return value
|
||||
else if ( Array.isArray(value) ) {
|
||||
return new QuerySafeValue(value, `(${value.map(v => this.escape(v)).join(',')})`)
|
||||
} else if ( String(value).toLowerCase() === 'true' || value === true ) {
|
||||
return new QuerySafeValue(value, 'TRUE')
|
||||
} else if ( String(value).toLowerCase() === 'false' || value === false ) {
|
||||
return new QuerySafeValue(value, 'FALSE')
|
||||
} else if ( typeof value === 'number' ) {
|
||||
return new QuerySafeValue(value, `${value}`)
|
||||
} else if ( value instanceof Date ) {
|
||||
const pad = (val: number) => val < 10 ? `0${val}` : `${val}`
|
||||
const [y, m, d, h, i, s] = [
|
||||
`${value.getFullYear()}`,
|
||||
`${pad(value.getMonth() + 1)}`,
|
||||
`${pad(value.getDate())}`,
|
||||
`${pad(value.getHours())}`,
|
||||
`${pad(value.getMinutes())}`,
|
||||
`${pad(value.getSeconds())}`,
|
||||
]
|
||||
|
||||
return new QuerySafeValue(value, `${y}-${m}-${d} ${h}:${i}:${s}`)
|
||||
} else if ( !isNaN(Number(value)) ) {
|
||||
return new QuerySafeValue(value, String(Number(value)))
|
||||
} else if ( value === null || typeof value === 'undefined' ) {
|
||||
return new QuerySafeValue(value, 'NULL')
|
||||
} else {
|
||||
const escaped = value.replace(/'/g, '\\\'') //.replace(/"/g, '\\"').replace(/`/g, '\\`')
|
||||
return new QuerySafeValue(value, `'${escaped}'`)
|
||||
}
|
||||
}
|
||||
|
||||
public renderCount(query: string): string {
|
||||
return [
|
||||
'SELECT COUNT(*) AS "extollo_render_count"',
|
||||
'FROM (',
|
||||
...query.split('\n').map(x => ` ${x}`),
|
||||
') AS extollo_target_query'
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
public renderRangedSelect(query: string, start: number, end: number): string {
|
||||
return [
|
||||
'SELECT *',
|
||||
'FROM (',
|
||||
...query.split('\n').map(x => ` ${x}`),
|
||||
') AS extollo_target_query',
|
||||
`OFFSET ${start} LIMIT ${(end - start) + 1}`
|
||||
].join('\n')
|
||||
}
|
||||
|
||||
/** Render the fields from the builder class to PostgreSQL syntax. */
|
||||
protected renderFields(builder: AbstractBuilder<any>) {
|
||||
return builder.appliedFields.map((field: SpecifiedField) => {
|
||||
let columnString: string
|
||||
if ( typeof field === 'string' ) columnString = field.split('.').map(x => `"${x}"`).join('.')
|
||||
else if ( field instanceof QuerySafeValue ) columnString = field.toString()
|
||||
else if ( typeof field.field === 'string' ) columnString = field.field.split('.').map(x => `"${x}"`).join('.')
|
||||
else columnString = field.field.toString()
|
||||
|
||||
let aliasString = ''
|
||||
if ( typeof field !== 'string' && !(field instanceof QuerySafeValue) ) aliasString = ` AS "${field.alias}"`
|
||||
|
||||
return `${columnString}${aliasString}`
|
||||
})
|
||||
}
|
||||
|
||||
public renderSelect(builder: AbstractBuilder<any>): string {
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
|
||||
const queryLines = [
|
||||
`SELECT${builder.appliedDistinction ? ' DISTINCT' : ''}`
|
||||
]
|
||||
|
||||
// Add fields
|
||||
// FIXME error if no fields
|
||||
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
|
||||
// Add table source
|
||||
// FIXME error if no source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
|
||||
queryLines.push('FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
// Add group by
|
||||
if ( builder.appliedGroupings?.length ) {
|
||||
const grouping = builder.appliedGroupings.map(group => {
|
||||
return indent(group.split('.').map(x => `"${x}"`).join('.'))
|
||||
}).join(',\n')
|
||||
|
||||
queryLines.push('GROUP BY')
|
||||
queryLines.push(grouping)
|
||||
}
|
||||
|
||||
// Add order by
|
||||
if ( builder.appliedOrder?.length ) {
|
||||
const ordering = builder.appliedOrder.map(x => indent(`${x.field.split('.').map(x => '"' + x + '"').join('.')} ${x.direction}`)).join(',\n')
|
||||
queryLines.push('ORDER BY')
|
||||
queryLines.push(ordering)
|
||||
}
|
||||
|
||||
// Add limit/offset
|
||||
const pagination = builder.appliedPagination
|
||||
if ( pagination.take ) {
|
||||
queryLines.push(`LIMIT ${pagination.take}${pagination.skip ? ' OFFSET ' + pagination.skip : ''}`)
|
||||
} else if ( pagination.skip ) {
|
||||
queryLines.push(`OFFSET ${pagination.skip}`)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
// TODO support FROM, RETURNING
|
||||
public renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string {
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
|
||||
const queryLines: string[] = []
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
|
||||
queryLines.push('UPDATE ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
queryLines.push(this.renderUpdateSet(data))
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderExistential(builder: AbstractBuilder<any>): string {
|
||||
const query = builder.clone()
|
||||
.clearFields()
|
||||
.field(raw('TRUE'))
|
||||
.limit(1)
|
||||
|
||||
return this.renderSelect(query)
|
||||
}
|
||||
|
||||
// FIXME: subquery support here and with select
|
||||
public renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[] = []): string {
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
|
||||
const queryLines: string[] = []
|
||||
|
||||
if ( !Array.isArray(data) ) data = [data]
|
||||
const columns = Object.keys(data[0])
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
|
||||
queryLines.push('INSERT INTO ' + (typeof source === 'string' ? table : `${table} AS "${source.alias}"`)
|
||||
+ (columns.length ? ` (${columns.join(', ')})` : ''))
|
||||
}
|
||||
|
||||
if ( Array.isArray(data) && !data.length ) {
|
||||
queryLines.push('DEFAULT VALUES')
|
||||
} else {
|
||||
queryLines.push('VALUES')
|
||||
|
||||
const valueString = data.map(row => {
|
||||
const values = columns.map(x => this.escape(row[x]))
|
||||
return indent(`(${values.join(', ')})`)
|
||||
})
|
||||
.join(',\n')
|
||||
|
||||
queryLines.push(valueString)
|
||||
}
|
||||
|
||||
// Add return fields
|
||||
if ( builder.appliedFields?.length ) {
|
||||
queryLines.push('RETURNING')
|
||||
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderDelete(builder: AbstractBuilder<any>): string {
|
||||
const indent = (item: string, level = 1) => Array(level + 1).fill('').join(' ') + item
|
||||
const queryLines: string[] = []
|
||||
|
||||
// Add table source
|
||||
const source = builder.querySource
|
||||
if ( source ) {
|
||||
const tableString = typeof source === 'string' ? source : source.table
|
||||
const table: string = tableString.split('.').map(x => `"${x}"`).join('.')
|
||||
queryLines.push('DELETE FROM ' + (typeof source === 'string' ? table : `${table} "${source.alias}"`))
|
||||
}
|
||||
|
||||
// Add constraints
|
||||
const wheres = this.renderConstraints(builder.appliedConstraints)
|
||||
if ( wheres.trim() ) {
|
||||
queryLines.push('WHERE')
|
||||
queryLines.push(wheres)
|
||||
}
|
||||
|
||||
// Add return fields
|
||||
if ( builder.appliedFields?.length ) {
|
||||
queryLines.push('RETURNING')
|
||||
|
||||
const fields = this.renderFields(builder).map(x => indent(x)).join(',\n')
|
||||
|
||||
queryLines.push(fields)
|
||||
}
|
||||
|
||||
return queryLines.join('\n')
|
||||
}
|
||||
|
||||
public renderConstraints(constraints: Constraint[]): string {
|
||||
const constraintsToSql = (constraints: Constraint[], level = 1): string => {
|
||||
const indent = Array(level * 2).fill(' ').join('')
|
||||
let statements = []
|
||||
|
||||
for ( const constraint of constraints ) {
|
||||
if ( isConstraintGroup(constraint) ) {
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}(\n${constraintsToSql(constraint.items, level + 1)}\n${indent})`)
|
||||
} else if ( isConstraintItem(constraint) ) {
|
||||
const field: string = constraint.field.split('.').map(x => `"${x}"`).join('.')
|
||||
statements.push(`${indent}${statements.length < 1 ? '' : constraint.preop + ' '}${field} ${constraint.operator} ${this.escape(constraint.operand).value}`)
|
||||
}
|
||||
}
|
||||
|
||||
return statements.filter(Boolean).join('\n')
|
||||
}
|
||||
|
||||
return constraintsToSql(constraints)
|
||||
}
|
||||
|
||||
public renderUpdateSet(data: {[key: string]: EscapeValue}) {
|
||||
const sets = []
|
||||
for ( const key in data ) {
|
||||
if ( !data.hasOwnProperty(key) ) continue
|
||||
|
||||
sets.push(` "${key}" = ${this.escape(data[key])}`)
|
||||
}
|
||||
|
||||
return ['SET', ...sets].join('\n')
|
||||
}
|
||||
}
|
169
src/orm/dialect/SQLDialect.ts
Normal file
169
src/orm/dialect/SQLDialect.ts
Normal file
@ -0,0 +1,169 @@
|
||||
import {Constraint} from "../types";
|
||||
import {AbstractBuilder} from "../builder/AbstractBuilder";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
|
||||
/**
|
||||
* A value which can be escaped to be interpolated into an SQL query.
|
||||
*/
|
||||
export type EscapeValue = null | undefined | string | number | boolean | Date | QuerySafeValue | EscapeValue[] // FIXME | Select<any>
|
||||
|
||||
/**
|
||||
* Object mapping string field names to EscapeValue items.
|
||||
*/
|
||||
export type EscapeValueObject = { [field: string]: EscapeValue }
|
||||
|
||||
/**
|
||||
* A wrapper class whose value is save to inject directly into a query.
|
||||
*/
|
||||
export class QuerySafeValue {
|
||||
constructor(
|
||||
/** The unescaped value. */
|
||||
public readonly originalValue: any,
|
||||
|
||||
/** The query-safe sanitized value. */
|
||||
public readonly value: any,
|
||||
) { }
|
||||
|
||||
/** Cast the value to a query-safe string. */
|
||||
toString() {
|
||||
return this.value
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Treat the value as raw SQL that can be injected directly into a query.
|
||||
* This is dangerous and should NEVER be used to wrap user input.
|
||||
* @param value
|
||||
*/
|
||||
export function raw(value: any) {
|
||||
return new QuerySafeValue(value, value)
|
||||
}
|
||||
|
||||
/**
|
||||
* Abstract class defining a particular dialect of SQL that is used to render
|
||||
* query builders to strings of SQL of that dialect for execution by Connection
|
||||
* instances.
|
||||
*/
|
||||
export abstract class SQLDialect extends AppClass {
|
||||
/**
|
||||
* Escape the given value and return the query-safe equivalent.
|
||||
* @param value
|
||||
*/
|
||||
public abstract escape(value: EscapeValue): QuerySafeValue
|
||||
|
||||
/**
|
||||
* Render the given query builder as a "SELECT ..." query string.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
* @param builder
|
||||
*/
|
||||
public abstract renderSelect(builder: AbstractBuilder<any>): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as an "UPDATE ..." query string, setting the
|
||||
* column values from the given data object.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
* @param builder
|
||||
* @param data
|
||||
*/
|
||||
public abstract renderUpdate(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as a "DELETE ..." query string.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
* @param builder
|
||||
*/
|
||||
public abstract renderDelete(builder: AbstractBuilder<any>): string;
|
||||
|
||||
/**
|
||||
* Render the given query builder as a query that can be used to test if at
|
||||
* least 1 row exists for the given builder.
|
||||
*
|
||||
* The resultant query should return at least 1 row if that condition is met,
|
||||
* and should return NO rows otherwise.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
*
|
||||
* @example
|
||||
* The PostgreSQL dialect achieves this by removing the user-specified fields,
|
||||
* select-ing `TRUE`, and applying `LIMIT 1` to the query. This returns a single
|
||||
* row if the constraints have results, and nothing otherwise.
|
||||
*
|
||||
* @param builder
|
||||
*/
|
||||
public abstract renderExistential(builder: AbstractBuilder<any>): string;
|
||||
|
||||
/**
|
||||
* Render the given query as an "INSERT ..." query string, inserting rows for
|
||||
* the given data object(s).
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
*
|
||||
* @param builder
|
||||
* @param data
|
||||
*/
|
||||
public abstract renderInsert(builder: AbstractBuilder<any>, data: {[key: string]: EscapeValue}|{[key: string]: EscapeValue}[]): string;
|
||||
|
||||
/**
|
||||
* Wrap the given query string as a "SELECT ..." query that returns the number of
|
||||
* rows matched by the original query string.
|
||||
*
|
||||
* The resultant query should return the `extollo_render_count` field with the
|
||||
* number of rows that the original `query` would return.
|
||||
*
|
||||
* @param query
|
||||
*/
|
||||
public abstract renderCount(query: string): string;
|
||||
|
||||
/**
|
||||
* Given a rendered "SELECT ..." query string, wrap it such that the query will
|
||||
* only return the rows ranging from the `start` to `end` indices.
|
||||
*
|
||||
* @param query
|
||||
* @param start
|
||||
* @param end
|
||||
*/
|
||||
public abstract renderRangedSelect(query: string, start: number, end: number): string;
|
||||
|
||||
/**
|
||||
* Given an array of Constraint objects, render them as WHERE-clause SQL in this dialect.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
*
|
||||
* @example
|
||||
* ```ts
|
||||
* dialect.renderConstraints([
|
||||
* {
|
||||
* field: 'id',
|
||||
* operator: '<',
|
||||
* operand: 44,
|
||||
* preop: 'AND',
|
||||
* },
|
||||
* {
|
||||
* field: 'id',
|
||||
* operator: '>',
|
||||
* operand: 30,
|
||||
* preop: 'AND',
|
||||
* },
|
||||
* ]) // => 'id < 44 AND id > 30'
|
||||
* ```
|
||||
*
|
||||
* @param constraints
|
||||
*/
|
||||
public abstract renderConstraints(constraints: Constraint[]): string;
|
||||
|
||||
/**
|
||||
* Render the "SET ... [field = value ...]" portion of the update query.
|
||||
*
|
||||
* This function should escape the values before they are included in the query string.
|
||||
*
|
||||
* @example
|
||||
* dialect.renderUpdateSet({field1: 'value', field2: 45})
|
||||
* // => "SET field1 = 'value', field2 = 45"
|
||||
*
|
||||
* @param data
|
||||
*/
|
||||
public abstract renderUpdateSet(data: {[key: string]: EscapeValue}): string;
|
||||
}
|
29
src/orm/index.ts
Normal file
29
src/orm/index.ts
Normal file
@ -0,0 +1,29 @@
|
||||
export * from './builder/result/AbstractResultIterable'
|
||||
export * from './builder/result/ResultCollection'
|
||||
export * from './builder/result/ResultIterable'
|
||||
|
||||
export * from './builder/AbstractBuilder'
|
||||
export * from './builder/Builder'
|
||||
|
||||
export * from './connection/Connection'
|
||||
export * from './connection/PostgresConnection'
|
||||
|
||||
export * from './dialect/SQLDialect'
|
||||
export * from './dialect/PostgreSQLDialect'
|
||||
|
||||
export * from './model/Field'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelBuilder'
|
||||
export * from './model/ModelResultIterable'
|
||||
export * from './model/Model'
|
||||
|
||||
export * from './services/Database'
|
||||
export * from './services/Models'
|
||||
|
||||
export * from './support/SessionModel'
|
||||
export * from './support/ORMSession'
|
||||
export * from './support/CacheModel'
|
||||
export * from './support/ORMCache'
|
||||
|
||||
export * from './DatabaseService'
|
||||
export * from './types'
|
78
src/orm/model/Field.ts
Normal file
78
src/orm/model/Field.ts
Normal file
@ -0,0 +1,78 @@
|
||||
import {Collection} from "../../util";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/** The reflection metadata key containing information about the model's fields. */
|
||||
export const EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY = 'extollo:orm:Field.ts'
|
||||
|
||||
/**
|
||||
* Abstract representation of a field on a model.
|
||||
*/
|
||||
export interface ModelField {
|
||||
databaseKey: string,
|
||||
modelKey: string | symbol,
|
||||
type: any,
|
||||
}
|
||||
|
||||
/**
|
||||
* Retrieve a collection of ModelField metadata from the given model.
|
||||
* @param model
|
||||
*/
|
||||
export function getFieldsMeta(model: any): Collection<ModelField> {
|
||||
const fields = Reflect.getMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, model.constructor)
|
||||
if ( !(fields instanceof Collection) ) {
|
||||
return new Collection<ModelField>()
|
||||
}
|
||||
|
||||
return fields as Collection<ModelField>
|
||||
}
|
||||
|
||||
/**
|
||||
* Set the collection of ModelField metadata as the field data for the given model.
|
||||
* @param model
|
||||
* @param fields
|
||||
*/
|
||||
export function setFieldsMeta(model: any, fields: Collection<ModelField>) {
|
||||
Reflect.defineMetadata(EXTOLLO_ORM_MODEL_FIELDS_METADATA_KEY, fields, model.constructor)
|
||||
}
|
||||
|
||||
/**
|
||||
* Decorator that maps the given property to a database column of the specified FieldType.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class MyModel extends Model<MyModel> {
|
||||
* // Maps the 'name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar)
|
||||
* public name!: string
|
||||
*
|
||||
* // Maps the 'first_name' VARCHAR column in the database to this property
|
||||
* @Field(FieldType.Varchar, 'first_name')
|
||||
* public firstName!: string
|
||||
* }
|
||||
* ```
|
||||
*
|
||||
* @param type
|
||||
* @param databaseKey
|
||||
* @constructor
|
||||
*/
|
||||
export function Field(type: FieldType, databaseKey?: string): PropertyDecorator {
|
||||
return (target, modelKey) => {
|
||||
if ( !databaseKey ) databaseKey = String(modelKey)
|
||||
const fields = getFieldsMeta(target)
|
||||
|
||||
const existingField = fields.firstWhere('modelKey', '=', modelKey)
|
||||
if ( existingField ) {
|
||||
existingField.databaseKey = databaseKey
|
||||
existingField.type = type
|
||||
return setFieldsMeta(target, fields)
|
||||
}
|
||||
|
||||
fields.push({
|
||||
databaseKey,
|
||||
modelKey,
|
||||
type,
|
||||
})
|
||||
|
||||
setFieldsMeta(target, fields)
|
||||
}
|
||||
}
|
813
src/orm/model/Model.ts
Normal file
813
src/orm/model/Model.ts
Normal file
@ -0,0 +1,813 @@
|
||||
import {ModelKey, QueryRow, QuerySource} from "../types";
|
||||
import {Container, Inject} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {ModelBuilder} from "./ModelBuilder";
|
||||
import {getFieldsMeta, ModelField} from "./Field";
|
||||
import {deepCopy, BehaviorSubject, Pipe, Collection} from "../../util";
|
||||
import {EscapeValueObject} from "../dialect/SQLDialect";
|
||||
import {AppClass} from "../../lifecycle/AppClass";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Base for classes that are mapped to tables in a database.
|
||||
*/
|
||||
export abstract class Model<T extends Model<T>> extends AppClass {
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
|
||||
/**
|
||||
* The name of the connection this model should run through.
|
||||
* @type string
|
||||
*/
|
||||
protected static connection: string = 'default'
|
||||
|
||||
/**
|
||||
* The name of the table this model is stored in.
|
||||
* @type string
|
||||
*/
|
||||
protected static table: string
|
||||
|
||||
/**
|
||||
* The name of the column that uniquely identifies this model.
|
||||
* @type string
|
||||
*/
|
||||
protected static key: string
|
||||
|
||||
/**
|
||||
* If false (default), the primary key will be excluded from INSERTs.
|
||||
*/
|
||||
protected static populateKeyOnInsert: boolean = false
|
||||
|
||||
/**
|
||||
* Optionally, the timestamp field set on creation.
|
||||
* @type string
|
||||
*/
|
||||
protected static readonly CREATED_AT: string | null = 'created_at'
|
||||
|
||||
/**
|
||||
* Optionally, the timestamp field set op update.
|
||||
* @type string
|
||||
*/
|
||||
protected static readonly UPDATED_AT: string | null = 'updated_at'
|
||||
|
||||
/**
|
||||
* If true, the CREATED_AT and UPDATED_AT columns will be automatically set.
|
||||
* @type boolean
|
||||
*/
|
||||
protected static timestamps = true
|
||||
|
||||
/**
|
||||
* Array of additional fields on the class that should
|
||||
* be included in the object serializations.
|
||||
* @type string[]
|
||||
*/
|
||||
protected static appends: string[] = []
|
||||
|
||||
/**
|
||||
* Array of fields on the class that should be excluded
|
||||
* from the object serializations.
|
||||
* @type string[]
|
||||
*/
|
||||
protected static masks: string[] = []
|
||||
|
||||
/**
|
||||
* The original row fetched from the database.
|
||||
* @protected
|
||||
*/
|
||||
protected _original?: QueryRow
|
||||
|
||||
/**
|
||||
* Behavior subject that fires after the model is populated.
|
||||
*/
|
||||
protected retrieved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is saved.
|
||||
*/
|
||||
protected saving$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is saved.
|
||||
*/
|
||||
protected saved$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is updated.
|
||||
*/
|
||||
protected updating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is updated.
|
||||
*/
|
||||
protected updated$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is inserted.
|
||||
*/
|
||||
protected creating$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is inserted.
|
||||
*/
|
||||
protected created$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right before the model is deleted.
|
||||
*/
|
||||
protected deleting$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Behavior subject that fires right after the model is deleted.
|
||||
*/
|
||||
protected deleted$ = new BehaviorSubject<Model<T>>()
|
||||
|
||||
/**
|
||||
* Get the table name for this model.
|
||||
*/
|
||||
public static tableName() {
|
||||
return this.table
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the QuerySource object for this model as it should be applied to query builders.
|
||||
*
|
||||
* This sets the alias for the model table equal to the table name itself, so it can be
|
||||
* referenced explicitly in queries if necessary.
|
||||
*/
|
||||
public static querySource(): QuerySource {
|
||||
return {
|
||||
table: this.table,
|
||||
alias: this.table,
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the name of the connection where this model's table is found.
|
||||
*/
|
||||
public static connectionName() {
|
||||
return this.connection
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the database connection instance for this model's connection.
|
||||
*/
|
||||
public static getConnection() {
|
||||
return Container.getContainer().make<DatabaseService>(DatabaseService).get(this.connectionName());
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new query builder that yields instances of this model,
|
||||
* pre-configured with this model's QuerySource, connection, and fields.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* const user = await UserModel.query<UserModel>().where('name', 'LIKE', 'John Doe').first()
|
||||
* ```
|
||||
*/
|
||||
public static query<T2 extends Model<T2>>() {
|
||||
const builder = <ModelBuilder<T2>> Container.getContainer().make<ModelBuilder<T2>>(ModelBuilder, this)
|
||||
const source: QuerySource = this.querySource()
|
||||
|
||||
builder.connection(this.getConnection())
|
||||
|
||||
if ( typeof source === 'string' ) builder.from(source)
|
||||
else builder.from(source.table, source.alias)
|
||||
|
||||
getFieldsMeta(this.prototype).each(field => {
|
||||
builder.field(field.databaseKey)
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
constructor(
|
||||
/**
|
||||
* Pre-fill the model's properties from the given values.
|
||||
* Calls `boot()` under the hood.
|
||||
*/
|
||||
values?: {[key: string]: any}
|
||||
) {
|
||||
super()
|
||||
this.boot(values)
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the model's properties from the given values and do any other initial setup.
|
||||
*
|
||||
* `values` can optionally be an object mapping model properties to the values of those
|
||||
* properties. Only properties with `@Field()` annotations will be set.
|
||||
*
|
||||
* @param values
|
||||
*/
|
||||
public boot(values?: any) {
|
||||
if ( values ) {
|
||||
getFieldsMeta(this).each(field => {
|
||||
this.setFieldFromObject(field.modelKey, String(field.modelKey), values)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a row from the database, set the properties on this model that correspond to
|
||||
* fields on that database.
|
||||
*
|
||||
* The `row` maps database fields to values, and the values are set for the properties
|
||||
* that they correspond to based on the model's `@Field()` annotations.
|
||||
*
|
||||
* @param row
|
||||
*/
|
||||
public async assumeFromSource(row: QueryRow) {
|
||||
this._original = row
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
this.setFieldFromObject(field.modelKey, field.databaseKey, row)
|
||||
})
|
||||
|
||||
await this.retrieved$.next(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Similar to assumeFromSource, but instead of mapping database fields to model
|
||||
* properties, this function assumes the `object` contains a mapping of model properties
|
||||
* to the values of those properties.
|
||||
*
|
||||
* Only properties with `@Field()` annotations will be set.
|
||||
*
|
||||
* @param object
|
||||
*/
|
||||
public async assume(object: { [key: string]: any }) {
|
||||
getFieldsMeta(this).each(field => {
|
||||
if ( field.modelKey in object ) {
|
||||
this.setFieldFromObject(field.modelKey, String(field.modelKey), object)
|
||||
}
|
||||
})
|
||||
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the value of the primary key of this model, if it exists.
|
||||
*/
|
||||
public key() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
const field = getFieldsMeta(this)
|
||||
.firstWhere('databaseKey', '=', ctor.key)
|
||||
|
||||
if ( field ) {
|
||||
// @ts-ignore
|
||||
return this[field.modelKey]
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
return this[ctor.key]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if this instance's record has been persisted into the database.
|
||||
*/
|
||||
public exists() {
|
||||
return !!this._original && !!this.key()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get normalized values of the configured CREATED_AT/UPDATED_AT fields for this model.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* user.timestamps() // => {updated: Date, created: Date}
|
||||
* ```
|
||||
*/
|
||||
public timestamps(): { updated?: Date, created?: Date } {
|
||||
const ctor = this.constructor as typeof Model
|
||||
const timestamps: { updated?: Date, created?: Date } = {}
|
||||
|
||||
if ( ctor.timestamps ) {
|
||||
// @ts-ignore
|
||||
if ( ctor.CREATED_AT ) timestamps.created = this[ctor.CREATED_AT]
|
||||
|
||||
// @ts-ignore
|
||||
if ( ctor.UPDATED_AT ) timestamps.updated = this[ctor.UPDATED_AT]
|
||||
}
|
||||
|
||||
return timestamps
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a new query builder that yields instances of this model,
|
||||
* pre-configured with this model's QuerySource, connection, and fields.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* await user.query()
|
||||
* .where('name', 'LIKE', 'John Doe')
|
||||
* .update({ username: 'jdoe' })
|
||||
* ```
|
||||
*/
|
||||
public query(): ModelBuilder<T> {
|
||||
const ModelClass = this.constructor as typeof Model
|
||||
const builder = <ModelBuilder<T>> this.app().make<ModelBuilder<T>>(ModelBuilder, ModelClass)
|
||||
const source: QuerySource = ModelClass.querySource()
|
||||
|
||||
builder.connection(ModelClass.getConnection())
|
||||
|
||||
if ( typeof source === 'string' ) builder.from(source)
|
||||
else builder.from(source.table, source.alias)
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
builder.field(field.databaseKey)
|
||||
})
|
||||
|
||||
return builder
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the first instance of this model where the primary key matches `key`.
|
||||
*
|
||||
* @example
|
||||
* ```
|
||||
* const user = await UserModel.findByKey(45)
|
||||
* ```
|
||||
*
|
||||
* @param key
|
||||
*/
|
||||
public static async findByKey<T2 extends Model<T2>>(key: ModelKey): Promise<undefined | T2> {
|
||||
return this.query<T2>()
|
||||
.where(this.qualifyKey(), '=', key)
|
||||
.limit(1)
|
||||
.get()
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an array of all instances of this model.
|
||||
*/
|
||||
public async all() {
|
||||
return this.query().get().all()
|
||||
}
|
||||
|
||||
/**
|
||||
* Count all instances of this model in the database.
|
||||
*/
|
||||
public async count(): Promise<number> {
|
||||
return this.query().get().count()
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a column, return the qualified name of the column as it
|
||||
* could appear in a query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* modelInstance.qualify('id') // => 'model_table_name.id'
|
||||
* ```
|
||||
*
|
||||
* @param column
|
||||
*/
|
||||
public qualify(column: string) {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return `${ctor.tableName()}.${column}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the qualified name of the column corresponding to the model's primary key.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A extends Model<A> {
|
||||
* protected static table = 'table_a'
|
||||
* protected static key = 'a_id'
|
||||
* }
|
||||
*
|
||||
* const a = new A()
|
||||
* a.qualifyKey() // => 'table_a.a_id'
|
||||
* ```
|
||||
*/
|
||||
public qualifyKey() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return this.qualify(ctor.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a column, return the qualified name of the column as it
|
||||
* could appear in a query.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* SomeModel.qualify('col_name') // => 'model_table_name.col_name'
|
||||
* ```
|
||||
*
|
||||
* @param column
|
||||
*/
|
||||
public static qualify(column: string) {
|
||||
return `${this.tableName()}.${column}`
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the qualified name of the column corresponding to the model's primary key.
|
||||
*
|
||||
* @example
|
||||
* ```typescript
|
||||
* class A extends Model<A> {
|
||||
* protected static table = 'table_a'
|
||||
* protected static key = 'a_id'
|
||||
* }
|
||||
*
|
||||
* A.qualifyKey() // => 'table_a.a_id'
|
||||
* ```
|
||||
*/
|
||||
public static qualifyKey() {
|
||||
return this.qualify(this.key)
|
||||
}
|
||||
|
||||
/**
|
||||
* Given the name of a property on the model with a `@Field()` annotation,
|
||||
* return the unqualified name of the database column it corresponds to.
|
||||
* @param modelKey
|
||||
*/
|
||||
public static propertyToColumn(modelKey: string) {
|
||||
return getFieldsMeta(this)
|
||||
.firstWhere('modelKey', '=', modelKey)?.databaseKey || modelKey
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the unqualified name of the column corresponding to the primary key of this model.
|
||||
*/
|
||||
public keyName() {
|
||||
const ctor = this.constructor as typeof Model
|
||||
return ctor.key
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the model to the base QueryRow object. The resultant object maps
|
||||
* DATABASE fields to values, NOT MODEL fields to values.
|
||||
*
|
||||
* Only fields with `@Field()` annotations will be included.
|
||||
*/
|
||||
public toQueryRow(): QueryRow {
|
||||
const row = {}
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
// @ts-ignore
|
||||
row[field.databaseKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a query row mapping database columns to values for properties on this
|
||||
* model that (1) have `@Field()` annotations and (2) have been modified since
|
||||
* the record was fetched from the database or created.
|
||||
*/
|
||||
public dirtyToQueryRow(): QueryRow {
|
||||
const row = {}
|
||||
|
||||
getFieldsMeta(this)
|
||||
.filter(this._isDirty)
|
||||
.each(field => {
|
||||
// @ts-ignore
|
||||
row[field.databaseKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Get an object of the database field => value mapping that was originally
|
||||
* fetched from the database. Excludes changes to model properties.
|
||||
*/
|
||||
public getOriginalValues() {
|
||||
return deepCopy(this._original)
|
||||
}
|
||||
|
||||
/**
|
||||
* Return an object of only the given properties on this model.
|
||||
*
|
||||
* @example
|
||||
* Assume `a` is an instance of some model `A` with the given fields.
|
||||
* ```typescript
|
||||
* const a = new A({ field1: 'field1 value', field2: 'field2 value', id: 123 })
|
||||
*
|
||||
* a.only('field1', 'id) // => {field1: 'field1 value', id: 123}
|
||||
* ```
|
||||
*
|
||||
* @param fields
|
||||
*/
|
||||
public only(...fields: string[]) {
|
||||
const row = {}
|
||||
|
||||
for ( const field of fields ) {
|
||||
// @ts-ignore
|
||||
row[field] = this[field]
|
||||
}
|
||||
|
||||
return row
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if any of the fields on this model have been modified since they
|
||||
* were fetched from the database (or ones that were never saved to the database).
|
||||
*
|
||||
* Only fields with `@Field()` annotations are checked.
|
||||
*/
|
||||
public isDirty() {
|
||||
return getFieldsMeta(this).some(this._isDirty)
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* Returns true if none of the fields on this model have been modified since they
|
||||
* were fetched from the database (and all exist in the database).
|
||||
*
|
||||
* Only fields with `@Field()` annotations are checked.
|
||||
*/
|
||||
public isClean() {
|
||||
return !this.isDirty()
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given field has changed since this model was fetched from
|
||||
* the database, or if the given field never existed in the database.
|
||||
* @param field
|
||||
*/
|
||||
public wasChanged(field: string) {
|
||||
// @ts-ignore
|
||||
return getFieldsMeta(this).pluck('modelKey').includes(field) && this[field] !== this._original[field]
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of MODEL fields that have been modified since this record
|
||||
* was fetched from the database or created.
|
||||
*/
|
||||
public getDirtyFields() {
|
||||
return getFieldsMeta(this)
|
||||
.filter(this._isDirty)
|
||||
.pluck('modelKey')
|
||||
.toArray()
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the timestamps for this model, if they are configured.
|
||||
*
|
||||
* If the model doesn't yet exist, set the CREATED_AT date. Always
|
||||
* sets the UPDATED_AT date.
|
||||
*/
|
||||
public touch() {
|
||||
const constructor = (this.constructor as typeof Model)
|
||||
if ( constructor.timestamps ) {
|
||||
if ( constructor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[constructor.UPDATED_AT] = new Date()
|
||||
}
|
||||
|
||||
if ( !this.exists() && constructor.CREATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[constructor.CREATED_AT] = new Date()
|
||||
}
|
||||
}
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Persist the model into the database. If the model already exists, perform an
|
||||
* update on its fields. Otherwise, insert a new row with its fields.
|
||||
*
|
||||
* Passing the `withoutTimestamps` will prevent the configured CREATED_AT/UPDATED_AT
|
||||
* timestamps from being updated.
|
||||
*
|
||||
* @param withoutTimestamps
|
||||
*/
|
||||
public async save({ withoutTimestamps = false } = {}): Promise<Model<T>> {
|
||||
await this.saving$.next(this)
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
if ( this.exists() && this.isDirty() ) {
|
||||
await this.updating$.next(this)
|
||||
|
||||
if ( !withoutTimestamps && ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.UPDATED_AT] = new Date()
|
||||
}
|
||||
|
||||
const result = await this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.clearFields()
|
||||
.returning(...this.getLoadedDatabaseFields())
|
||||
.update(this.dirtyToQueryRow())
|
||||
|
||||
if ( result.rowCount !== 1 ) {
|
||||
this.logging.warn(`Model update modified ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`)
|
||||
}
|
||||
|
||||
const data = result.rows.firstWhere(this.keyName(), '=', this.key())
|
||||
if ( data ) await this.assumeFromSource(data)
|
||||
|
||||
await this.updated$.next(this)
|
||||
} else if ( !this.exists() ) {
|
||||
await this.creating$.next(this)
|
||||
|
||||
if ( !withoutTimestamps ) {
|
||||
if ( ctor.timestamps && ctor.CREATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.CREATED_AT] = new Date()
|
||||
}
|
||||
|
||||
if ( ctor.timestamps && ctor.UPDATED_AT ) {
|
||||
// @ts-ignore
|
||||
this[ctor.UPDATED_AT] = new Date()
|
||||
}
|
||||
}
|
||||
|
||||
const row = this._buildInsertFieldObject()
|
||||
const returnable = new Collection<string>([this.keyName(), ...Object.keys(row)])
|
||||
|
||||
const result = await this.query()
|
||||
.clearFields()
|
||||
.returning(...returnable.unique().toArray())
|
||||
.insert(row)
|
||||
|
||||
if ( result.rowCount !== 1 ) {
|
||||
this.logging.warn(`Model insert created ${result.rowCount} rows! Expected 1. (Key: ${this.qualifyKey()})`)
|
||||
}
|
||||
|
||||
const data = result.rows.first()
|
||||
if ( data ) await this.assumeFromSource(result)
|
||||
await this.created$.next(this)
|
||||
}
|
||||
|
||||
await this.saved$.next(this)
|
||||
return this
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast this model to a simple object mapping model fields to their values.
|
||||
*
|
||||
* Only fields with `@Field()` annotations are included.
|
||||
*/
|
||||
public toObject(): { [key: string]: any } {
|
||||
const ctor = this.constructor as typeof Model
|
||||
const obj = {}
|
||||
|
||||
getFieldsMeta(this).each(field => {
|
||||
// @ts-ignore
|
||||
obj[field.modelKey] = this[field.modelKey]
|
||||
})
|
||||
|
||||
ctor.appends.forEach(field => {
|
||||
// @ts-ignore
|
||||
obj[field] = this[field]
|
||||
})
|
||||
|
||||
ctor.masks.forEach(field => {
|
||||
// @ts-ignore
|
||||
delete obj[field]
|
||||
})
|
||||
|
||||
return obj
|
||||
}
|
||||
|
||||
/**
|
||||
* Cast the model to an JSON string object.
|
||||
*
|
||||
* Only fields with `@Field()` annotations are included.
|
||||
*/
|
||||
public toJSON(): string {
|
||||
return JSON.stringify(this.toObject())
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch a fresh instance of this record from the database.
|
||||
*
|
||||
* This returns a NEW instance of the SAME record by matching on
|
||||
* the primary key. It does NOT change the current instance of the record.
|
||||
*/
|
||||
public async fresh(): Promise<Model<T> | undefined> {
|
||||
return this.query()
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.limit(1)
|
||||
.get()
|
||||
.first()
|
||||
}
|
||||
|
||||
/**
|
||||
* Re-load the currently-loaded database fields from the table.
|
||||
*
|
||||
* Overwrites any un-persisted changes in the current instance.
|
||||
*/
|
||||
public async refresh() {
|
||||
const results = this.query()
|
||||
.clearFields()
|
||||
.fields(...this.getLoadedDatabaseFields())
|
||||
.where(this.qualifyKey(), '=', this.key())
|
||||
.limit(1)
|
||||
.get()
|
||||
|
||||
const row = await results.first()
|
||||
if ( row ) await this.assumeFromSource(row)
|
||||
}
|
||||
|
||||
/**
|
||||
* Populates an instance of the model with the same database fields that
|
||||
* are set on this model, with the exclusion of the primary key.
|
||||
*
|
||||
* Useful for inserting copies of records.
|
||||
*
|
||||
* @example
|
||||
* Assume a record, `a`, is an instance of some model `A` with the given fields.
|
||||
*
|
||||
* ```typescript
|
||||
* const a = A.find(123) // => A{id: 123, name: 'some_name', other_field: 'a value'}
|
||||
*
|
||||
* const b = a.populate(new A) // => A{name: 'some_name', other_field: 'a value'}
|
||||
* ```
|
||||
*
|
||||
* @param model
|
||||
*/
|
||||
public async populate(model: T): Promise<T> {
|
||||
const row = this.toQueryRow()
|
||||
delete row[this.keyName()]
|
||||
await model.assumeFromSource(row)
|
||||
return model
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the `other` model refers to the same database record as this instance.
|
||||
*
|
||||
* This is done by comparing the qualified primary keys.
|
||||
*
|
||||
* @param other
|
||||
*/
|
||||
public is(other: Model<any>): boolean {
|
||||
return this.key() === other.key() && this.qualifyKey() === other.qualifyKey()
|
||||
}
|
||||
|
||||
/**
|
||||
* Inverse of `is()`.
|
||||
* @param other
|
||||
*/
|
||||
public isNot(other: Model<any>): boolean {
|
||||
return !this.is(other)
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates a new Pipe instance containing this model instance.
|
||||
*/
|
||||
public pipe(): Pipe<this> {
|
||||
return Pipe.wrap(this)
|
||||
}
|
||||
|
||||
/**
|
||||
* Get a wrapped function that compares whether the given model field
|
||||
* on the current instance differs from the originally fetched value.
|
||||
*
|
||||
* Used to filter for dirty fields.
|
||||
*
|
||||
* @protected
|
||||
*/
|
||||
protected get _isDirty() {
|
||||
return (field: ModelField) => {
|
||||
// @ts-ignore
|
||||
return this[field.modelKey] !== this._original[field.databaseKey]
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a list of DATABASE fields that have been loaded for the current instance.
|
||||
* @protected
|
||||
*/
|
||||
protected getLoadedDatabaseFields(): string[] {
|
||||
if ( !this._original ) return []
|
||||
return Object.keys(this._original).map(String)
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an object mapping database fields to the values that should be inserted for them.
|
||||
* @private
|
||||
*/
|
||||
private _buildInsertFieldObject(): EscapeValueObject {
|
||||
const ctor = this.constructor as typeof Model
|
||||
|
||||
return getFieldsMeta(this)
|
||||
.pipe()
|
||||
.unless(ctor.populateKeyOnInsert, fields => {
|
||||
return fields.where('modelKey', '!=', this.keyName())
|
||||
})
|
||||
.get()
|
||||
// @ts-ignore
|
||||
.keyMap('databaseKey', inst => this[inst.modelKey])
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets a property on `this` to the value of a given property in `object`.
|
||||
* @param this_field_name
|
||||
* @param object_field_name
|
||||
* @param object
|
||||
* @protected
|
||||
*/
|
||||
protected setFieldFromObject(this_field_name: string | symbol, object_field_name: string, object: { [key: string]: any }) {
|
||||
// @ts-ignore
|
||||
this[this_field_name] = object[object_field_name]
|
||||
}
|
||||
}
|
25
src/orm/model/ModelBuilder.ts
Normal file
25
src/orm/model/ModelBuilder.ts
Normal file
@ -0,0 +1,25 @@
|
||||
import {Model} from "./Model";
|
||||
import {AbstractBuilder} from "../builder/AbstractBuilder";
|
||||
import {AbstractResultIterable} from "../builder/result/AbstractResultIterable";
|
||||
import {Instantiable} from "../../di";
|
||||
import {ModelResultIterable} from "./ModelResultIterable";
|
||||
|
||||
/**
|
||||
* Implementation of the abstract builder whose results yield instances of a given Model, `T`.
|
||||
*/
|
||||
export class ModelBuilder<T extends Model<T>> extends AbstractBuilder<T> {
|
||||
constructor(
|
||||
/** The model class that is created for results of this query. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) {
|
||||
super()
|
||||
}
|
||||
|
||||
public getNewInstance(): AbstractBuilder<T> {
|
||||
return this.app().make<ModelBuilder<T>>(ModelBuilder)
|
||||
}
|
||||
|
||||
public getResultIterable(): AbstractResultIterable<T> {
|
||||
return this.app().make<ModelResultIterable<T>>(ModelResultIterable, this, this._connection, this.ModelClass)
|
||||
}
|
||||
}
|
61
src/orm/model/ModelResultIterable.ts
Normal file
61
src/orm/model/ModelResultIterable.ts
Normal file
@ -0,0 +1,61 @@
|
||||
import {Model} from "./Model";
|
||||
import {AbstractResultIterable} from "../builder/result/AbstractResultIterable";
|
||||
import {Connection} from "../connection/Connection";
|
||||
import {ModelBuilder} from "./ModelBuilder";
|
||||
import {Container, Instantiable} from "../../di";
|
||||
import {QueryRow} from "../types";
|
||||
import {Collection} from "../../util";
|
||||
|
||||
/**
|
||||
* Implementation of the result iterable that returns query results as instances of the defined model.
|
||||
*/
|
||||
export class ModelResultIterable<T extends Model<T>> extends AbstractResultIterable<T> {
|
||||
constructor(
|
||||
public readonly builder: ModelBuilder<T>,
|
||||
public readonly connection: Connection,
|
||||
/** The model that should be instantiated for each row. */
|
||||
protected readonly ModelClass: Instantiable<T>
|
||||
) { super(builder, connection) }
|
||||
|
||||
public get selectSQL() {
|
||||
return this.connection.dialect().renderSelect(this.builder)
|
||||
}
|
||||
|
||||
async at(i: number) {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, i, i + 1)
|
||||
const row = (await this.connection.query(query)).rows.first()
|
||||
|
||||
if ( row ) {
|
||||
return this.inflateRow(row)
|
||||
}
|
||||
}
|
||||
|
||||
async range(start: number, end: number): Promise<Collection<T>> {
|
||||
const query = this.connection.dialect().renderRangedSelect(this.selectSQL, start, end)
|
||||
return (await this.connection.query(query)).rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
async count() {
|
||||
const query = this.connection.dialect().renderCount(this.selectSQL)
|
||||
const result = (await this.connection.query(query)).rows.first()
|
||||
return result?.extollo_render_count ?? 0
|
||||
}
|
||||
|
||||
async all(): Promise<Collection<T>> {
|
||||
const result = await this.connection.query(this.selectSQL)
|
||||
return result.rows.promiseMap<T>(row => this.inflateRow(row))
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a query row, create an instance of the configured model class from it.
|
||||
* @param row
|
||||
* @protected
|
||||
*/
|
||||
protected async inflateRow(row: QueryRow): Promise<T> {
|
||||
return Container.getContainer().make<T>(this.ModelClass).assumeFromSource(row)
|
||||
}
|
||||
|
||||
clone() {
|
||||
return new ModelResultIterable(this.builder, this.connection, this.ModelClass)
|
||||
}
|
||||
}
|
62
src/orm/services/Database.ts
Normal file
62
src/orm/services/Database.ts
Normal file
@ -0,0 +1,62 @@
|
||||
import {Inject, Singleton} from "../../di";
|
||||
import {DatabaseService} from "../DatabaseService";
|
||||
import {PostgresConnection} from "../connection/PostgresConnection";
|
||||
import {ErrorWithContext} from "../../util";
|
||||
import {Unit} from "../../lifecycle/Unit";
|
||||
import {Config} from "../../service/Config";
|
||||
import {Logging} from "../../service/Logging";
|
||||
|
||||
/**
|
||||
* Application unit responsible for loading and creating database connections from config.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Database extends Unit {
|
||||
@Inject()
|
||||
protected readonly config!: Config;
|
||||
|
||||
@Inject()
|
||||
protected readonly dbService!: DatabaseService;
|
||||
|
||||
@Inject()
|
||||
protected readonly logging!: Logging;
|
||||
|
||||
/**
|
||||
* Load the `database.connections` config and register Connection instances for each config.
|
||||
* Automatically initializes the connections.
|
||||
*/
|
||||
public async up() {
|
||||
const connections = this.config.get('database.connections')
|
||||
const promises = []
|
||||
|
||||
for ( const key in connections ) {
|
||||
if ( !connections.hasOwnProperty(key) ) continue
|
||||
const config = connections[key]
|
||||
|
||||
this.logging.info(`Initializing database connection: ${key}`)
|
||||
this.logging.verbose(config)
|
||||
|
||||
let conn
|
||||
if ( config?.dialect === 'postgres' ) {
|
||||
conn = <PostgresConnection> this.app().make(PostgresConnection, key, config)
|
||||
} else {
|
||||
const e = new ErrorWithContext(`Invalid or missing database dialect: ${config.dialect}. Should be one of: postgres`)
|
||||
e.context = { connectionName: key }
|
||||
throw e
|
||||
}
|
||||
|
||||
this.dbService.register(key, conn)
|
||||
promises.push(conn.init())
|
||||
}
|
||||
|
||||
await Promise.all(promises)
|
||||
this.logging.info('Database connections opened.')
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the configured connections cleanly before exit.
|
||||
*/
|
||||
public async down() {
|
||||
await Promise.all(this.dbService.names()
|
||||
.map(name => this.dbService.get(name).close()))
|
||||
}
|
||||
}
|
33
src/orm/services/Models.ts
Normal file
33
src/orm/services/Models.ts
Normal file
@ -0,0 +1,33 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Instantiable, Singleton, Inject} from "../../di";
|
||||
import {CommandLine} from "../../cli";
|
||||
import {model_template} from "../template/model";
|
||||
import {CanonicalStatic} from "../../service/CanonicalStatic";
|
||||
import {CanonicalDefinition} from "../../service/Canonical";
|
||||
|
||||
/**
|
||||
* Canonical unit responsible for loading the model classes defined by the application.
|
||||
*/
|
||||
@Singleton()
|
||||
export class Models extends CanonicalStatic<Model<any>, Instantiable<Model<any>>> {
|
||||
@Inject()
|
||||
protected readonly cli!: CommandLine
|
||||
|
||||
protected appPath = ['models']
|
||||
protected canonicalItem = 'model'
|
||||
protected suffix = '.model.js'
|
||||
|
||||
public async up() {
|
||||
await super.up()
|
||||
this.cli.registerTemplate(model_template)
|
||||
}
|
||||
|
||||
public async initCanonicalItem(definition: CanonicalDefinition): Promise<Instantiable<Model<any>>> {
|
||||
const item = await super.initCanonicalItem(definition)
|
||||
if ( !(item.prototype instanceof Model) ) {
|
||||
throw new TypeError(`Invalid controller definition: ${definition.originalName}. Models must extend from @extollo/orm.Model.`)
|
||||
}
|
||||
|
||||
return item
|
||||
}
|
||||
}
|
20
src/orm/support/CacheModel.ts
Normal file
20
src/orm/support/CacheModel.ts
Normal file
@ -0,0 +1,20 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Field} from "../model/Field";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/**
|
||||
* A model instance which stores records from the ORMCache driver.
|
||||
*/
|
||||
export class CacheModel extends Model<CacheModel> {
|
||||
protected static table = 'caches'; // FIXME allow configuring
|
||||
protected static key = 'cache_key';
|
||||
|
||||
@Field(FieldType.varchar, 'cache_key')
|
||||
public cacheKey!: string;
|
||||
|
||||
@Field(FieldType.text, 'cache_value')
|
||||
public cacheValue!: string;
|
||||
|
||||
@Field(FieldType.timestamp, 'cache_expires')
|
||||
public cacheExpires?: Date;
|
||||
}
|
45
src/orm/support/ORMCache.ts
Normal file
45
src/orm/support/ORMCache.ts
Normal file
@ -0,0 +1,45 @@
|
||||
import {Container} from "../../di"
|
||||
import {Cache} from "../../util"
|
||||
import {CacheModel} from "./CacheModel"
|
||||
|
||||
/**
|
||||
* A cache driver whose records are stored in a database table using the CacheModel.
|
||||
*/
|
||||
export class ORMCache extends Cache {
|
||||
public async fetch(key: string): Promise<string | undefined> {
|
||||
const model = await CacheModel.query<CacheModel>()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.first()
|
||||
|
||||
if ( model ) {
|
||||
return model.cacheValue
|
||||
}
|
||||
}
|
||||
|
||||
public async put(key: string, value: string, expires?: Date): Promise<void> {
|
||||
let model = await CacheModel.findByKey<CacheModel>(key)
|
||||
if ( !model ) {
|
||||
model = <CacheModel> Container.getContainer().make(CacheModel)
|
||||
}
|
||||
|
||||
model.cacheKey = key
|
||||
model.cacheValue = value
|
||||
model.cacheExpires = expires
|
||||
|
||||
await model.save()
|
||||
}
|
||||
|
||||
public async has(key: string): Promise<boolean> {
|
||||
return CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.where(CacheModel.propertyToColumn('cacheExpires'), '>', new Date())
|
||||
.exists()
|
||||
}
|
||||
|
||||
public async drop(key: string): Promise<void> {
|
||||
await CacheModel.query()
|
||||
.where(CacheModel.qualifyKey(), '=', key)
|
||||
.delete()
|
||||
}
|
||||
}
|
68
src/orm/support/ORMSession.ts
Normal file
68
src/orm/support/ORMSession.ts
Normal file
@ -0,0 +1,68 @@
|
||||
import {SessionModel} from "./SessionModel"
|
||||
import {Container} from "../../di"
|
||||
import {NoSessionKeyError, Session, SessionData, SessionNotLoadedError} from "../../http/session/Session";
|
||||
|
||||
/**
|
||||
* An implementation of the Session driver whose records are stored in a database table.
|
||||
*/
|
||||
export class ORMSession extends Session {
|
||||
protected key?: string
|
||||
protected data?: SessionData
|
||||
protected session?: SessionModel
|
||||
|
||||
public getKey(): string {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
return this.key
|
||||
}
|
||||
|
||||
public setKey(key: string): void {
|
||||
this.key = key
|
||||
}
|
||||
|
||||
public async load() {
|
||||
if ( !this.key ) {
|
||||
throw new NoSessionKeyError()
|
||||
}
|
||||
|
||||
const session = <SessionModel> await SessionModel.findByKey(this.key)
|
||||
if ( session ) {
|
||||
this.session = session
|
||||
this.data = this.session.json
|
||||
} else {
|
||||
this.session = <SessionModel> Container.getContainer().make(SessionModel)
|
||||
this.session.uuid = this.key
|
||||
this.data = {} as SessionData
|
||||
}
|
||||
}
|
||||
|
||||
public async persist() {
|
||||
if ( !this.key ) throw new NoSessionKeyError()
|
||||
if ( !this.data || !this.session ) throw new SessionNotLoadedError()
|
||||
|
||||
this.session.uuid = this.key
|
||||
this.session.json = JSON.stringify(this.data)
|
||||
await this.session.save()
|
||||
}
|
||||
|
||||
public getData() {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
return this.data
|
||||
}
|
||||
|
||||
public setData(data: SessionData) {
|
||||
this.data = data
|
||||
}
|
||||
|
||||
public get(key: string, fallback?: any): any {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
return this.data[key] ?? fallback
|
||||
}
|
||||
|
||||
public set(key: string, value: any) {
|
||||
if ( !this.data ) throw new SessionNotLoadedError()
|
||||
this.data[key] = value
|
||||
}
|
||||
}
|
17
src/orm/support/SessionModel.ts
Normal file
17
src/orm/support/SessionModel.ts
Normal file
@ -0,0 +1,17 @@
|
||||
import {Model} from "../model/Model";
|
||||
import {Field} from "../model/Field";
|
||||
import {FieldType} from "../types";
|
||||
|
||||
/**
|
||||
* Model used to fetch & store sessions from the ORMSession driver.
|
||||
*/
|
||||
export class SessionModel extends Model<SessionModel> {
|
||||
protected static table = 'sessions'; // FIXME allow configuring
|
||||
protected static key = 'session_uuid';
|
||||
|
||||
@Field(FieldType.varchar, 'session_uuid')
|
||||
public uuid!: string;
|
||||
|
||||
@Field(FieldType.json, 'session_data')
|
||||
public json!: any;
|
||||
}
|
30
src/orm/template/model.ts
Normal file
30
src/orm/template/model.ts
Normal file
@ -0,0 +1,30 @@
|
||||
import {Template} from "../../cli"
|
||||
import {UniversalPath} from "../../util"
|
||||
|
||||
/**
|
||||
* Template for creating new database model classes in app/models.
|
||||
*/
|
||||
const model_template: Template = {
|
||||
name: 'model',
|
||||
fileSuffix: '.model.ts',
|
||||
baseAppPath: ['models'],
|
||||
description: 'Create a new class that represents a record in a database',
|
||||
render: (name: string, fullCanonicalName: string, targetFilePath: UniversalPath) => {
|
||||
return `import {Model} from "@extollo/orm"
|
||||
import {Injectable} from "@extollo/di"
|
||||
|
||||
/**
|
||||
* ${name} Model
|
||||
* -----------------------------------
|
||||
* Put some description here.
|
||||
*/
|
||||
@Injectable()
|
||||
export class ${name} extends Model<${name}> {
|
||||
protected static table = '${name.toLowerCase()}';
|
||||
protected static key = '${name.toLowerCase()}_id';
|
||||
}
|
||||
`
|
||||
}
|
||||
}
|
||||
|
||||
export { model_template }
|
148
src/orm/types.ts
Normal file
148
src/orm/types.ts
Normal file
@ -0,0 +1,148 @@
|
||||
import { Collection } from '../util';
|
||||
import {EscapeValue, QuerySafeValue} from "./dialect/SQLDialect";
|
||||
|
||||
/**
|
||||
* A single query row, as an object.
|
||||
*/
|
||||
export type QueryRow = { [key: string]: any }
|
||||
|
||||
/**
|
||||
* A valid key on a model.
|
||||
*/
|
||||
export type ModelKey = string | number
|
||||
|
||||
/**
|
||||
* Interface for the result of a query execution.
|
||||
*/
|
||||
export interface QueryResult {
|
||||
rows: Collection<QueryRow>,
|
||||
rowCount: number,
|
||||
}
|
||||
|
||||
/**
|
||||
* SQL operator that is used to join two constraint clauses.
|
||||
*/
|
||||
export type ConstraintConnectionOperator = 'AND' | 'OR' | 'AND NOT' | 'OR NOT'
|
||||
|
||||
/**
|
||||
* SQL operator that appears in a constraint clause.
|
||||
*/
|
||||
export type ConstraintOperator = '&' | '>' | '>=' | '<' | '<=' | '!=' | '<=>' | '%' | '|' | '!' | '~' | '=' | '^' | 'IN' | 'NOT IN' | 'LIKE' | 'BETWEEN' | 'NOT BETWEEN' | 'IS' | 'IS NOT';
|
||||
|
||||
/**
|
||||
* Interface for storing the various parts of a single SQL constraint.
|
||||
*/
|
||||
export interface ConstraintItem {
|
||||
field: string,
|
||||
operator: ConstraintOperator,
|
||||
operand: EscapeValue,
|
||||
preop: ConstraintConnectionOperator,
|
||||
}
|
||||
|
||||
/**
|
||||
* Interface for storing a group of constraints connected by the given connection operator.
|
||||
*/
|
||||
export interface ConstraintGroup {
|
||||
items: Constraint[],
|
||||
preop: ConstraintConnectionOperator,
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given object is a valid ConstraintGroup.
|
||||
* @param what
|
||||
*/
|
||||
export function isConstraintGroup(what: any): what is ConstraintGroup {
|
||||
return typeof what === 'object' && Array.isArray(what.items) && what.preop
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given object is a valid ConstraintItem
|
||||
* @param what
|
||||
*/
|
||||
export function isConstraintItem(what: any): what is ConstraintItem {
|
||||
return typeof what === 'object' && what.field && what.operator && what.operand && what.preop
|
||||
}
|
||||
|
||||
/**
|
||||
* Type alias for something that can be either a single constraint or a group of them.
|
||||
*/
|
||||
export type Constraint = ConstraintItem | ConstraintGroup
|
||||
|
||||
/**
|
||||
* Type alias for an item that refers to a field on a table.
|
||||
*/
|
||||
export type SpecifiedField = string | QuerySafeValue | { field: string | QuerySafeValue, alias: string }
|
||||
|
||||
/**
|
||||
* Type alias for an item that refers to a table in a database.
|
||||
*/
|
||||
export type QuerySource = string | { table: string, alias: string }
|
||||
|
||||
/**
|
||||
* Possible SQL order-by clause directions.
|
||||
*/
|
||||
export type OrderDirection = 'ASC' | 'DESC' | 'asc' | 'desc'
|
||||
|
||||
/**
|
||||
* Interface for storing the parts of a SQL order-by clause.
|
||||
*/
|
||||
export type OrderStatement = { field: string, direction: OrderDirection }
|
||||
|
||||
/**
|
||||
* Database column types.
|
||||
*/
|
||||
export enum FieldType {
|
||||
bigint = 'bigint',
|
||||
int8 = 'bigint',
|
||||
bigserial = 'bigserial',
|
||||
serial8 = 'bigserial',
|
||||
bit = 'bit',
|
||||
bit_varying = 'bit varying',
|
||||
varbit = 'bit varying',
|
||||
boolean = 'boolean',
|
||||
bool = 'boolean',
|
||||
box = 'box',
|
||||
bytea = 'bytea',
|
||||
character = 'character',
|
||||
char = 'character',
|
||||
character_varying = 'character varying',
|
||||
varchar = 'character varying',
|
||||
cidr = 'cidr',
|
||||
circle = 'circle',
|
||||
date = 'date',
|
||||
double_precision = 'double precision',
|
||||
float8 = 'double precision',
|
||||
inet = 'inet',
|
||||
integer = 'integer',
|
||||
int = 'integer',
|
||||
int4 = 'integer',
|
||||
interval = 'interval',
|
||||
json = 'json',
|
||||
line = 'line',
|
||||
lseg = 'lseg',
|
||||
macaddr = 'macaddr',
|
||||
money = 'money',
|
||||
numeric = 'numeric',
|
||||
decimal = 'numeric',
|
||||
path = 'path',
|
||||
point = 'point',
|
||||
polygon = 'polygon',
|
||||
real = 'real',
|
||||
float4 = 'real',
|
||||
smallint = 'smallint',
|
||||
int2 = 'smallint',
|
||||
smallserial = 'smallserial',
|
||||
serial2 = 'smallserial',
|
||||
serial = 'serial',
|
||||
serial4 = 'serial',
|
||||
text = 'text',
|
||||
time = 'time',
|
||||
timestamp = 'timestamp',
|
||||
tsquery = 'tsquery',
|
||||
tsvector = 'tsvector',
|
||||
txid_snapshot = 'txid_snapshot',
|
||||
uuid = 'uuid',
|
||||
xml = 'xml',
|
||||
other = 'other',
|
||||
}
|
||||
|
@ -1,5 +1,5 @@
|
||||
import {Canonical} from "./Canonical";
|
||||
import {Singleton} from "@extollo/di";
|
||||
import {Singleton} from "../di";
|
||||
|
||||
/**
|
||||
* Error throw when a duplicate canonical key is registered.
|
||||
|
@ -2,9 +2,9 @@
|
||||
* Base type for a canonical definition.
|
||||
*/
|
||||
import {Canon} from "./Canon";
|
||||
import {universalPath, UniversalPath, ErrorWithContext} from "@extollo/util";
|
||||
import {universalPath, UniversalPath, ErrorWithContext} from "../util";
|
||||
import {Logging} from "./Logging";
|
||||
import {Inject} from "@extollo/di";
|
||||
import {Inject} from "../di";
|
||||
import * as nodePath from 'path'
|
||||
import {Unit} from "../lifecycle/Unit";
|
||||
|
||||
|
@ -1,4 +1,4 @@
|
||||
import {Singleton, Inject} from "@extollo/di";
|
||||
import {Singleton, Inject} from "../di";
|
||||
import {CanonicalRecursive} from "./CanonicalRecursive";
|
||||
import {Logging} from "./Logging";
|
||||
|
||||
|
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user