mirror of
				https://github.com/chartdb/chartdb.git
				synced 2025-10-31 03:53:55 +00:00 
			
		
		
		
	Compare commits
	
		
			23 Commits
		
	
	
		
			v1.15.0
			...
			jf/add_edi
		
	
	| Author | SHA1 | Date | |
|---|---|---|---|
|  | 7382626b92 | ||
|  | 6f6b59c74f | ||
|  | 4f1a378762 | ||
|  | 1a6688e85e | ||
|  | 5e81c1848a | ||
|  | 2bd9ca25b2 | ||
|  | b016a70691 | ||
|  | a0fb1ed08b | ||
|  | ffddcdcc98 | ||
|  | fe9ef275b8 | ||
|  | df89f0b6b9 | ||
|  | 534d2858af | ||
|  | 2a64deebb8 | ||
|  | e5e1d59327 | ||
|  | aa290615ca | ||
|  | ec6e46fe81 | ||
|  | ac128d67de | ||
|  | 07937a2f51 | ||
|  | d8e0bc7db8 | ||
|  | 1ce265781b | ||
|  | 60c5675cbf | ||
|  | 66b086378c | ||
|  | abd2a6ccbe | 
| @@ -1,5 +1,14 @@ | ||||
| # Changelog | ||||
|  | ||||
| ## [1.15.1](https://github.com/chartdb/chartdb/compare/v1.15.0...v1.15.1) (2025-08-27) | ||||
|  | ||||
|  | ||||
| ### Bug Fixes | ||||
|  | ||||
| * add actions menu to diagram list + add duplicate diagram ([#876](https://github.com/chartdb/chartdb/issues/876)) ([abd2a6c](https://github.com/chartdb/chartdb/commit/abd2a6ccbe1aa63db44ec28b3eff525cc5d3f8b0)) | ||||
| * **custom-types:** Make schema optional ([#866](https://github.com/chartdb/chartdb/issues/866)) ([60c5675](https://github.com/chartdb/chartdb/commit/60c5675cbfe205859d2d0c9848d8345a0a854671)) | ||||
| * handle quoted identifiers with special characters in SQL import/export and DBML generation ([#877](https://github.com/chartdb/chartdb/issues/877)) ([66b0863](https://github.com/chartdb/chartdb/commit/66b086378cd63347acab5fc7f13db7db4feaa872)) | ||||
|  | ||||
| ## [1.15.0](https://github.com/chartdb/chartdb/compare/v1.14.0...v1.15.0) (2025-08-26) | ||||
|  | ||||
|  | ||||
|   | ||||
							
								
								
									
										281
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							
							
						
						
									
										281
									
								
								package-lock.json
									
									
									
										generated
									
									
									
								
							| @@ -1,15 +1,15 @@ | ||||
| { | ||||
|     "name": "chartdb", | ||||
|     "version": "1.15.0", | ||||
|     "version": "1.15.1", | ||||
|     "lockfileVersion": 3, | ||||
|     "requires": true, | ||||
|     "packages": { | ||||
|         "": { | ||||
|             "name": "chartdb", | ||||
|             "version": "1.15.0", | ||||
|             "version": "1.15.1", | ||||
|             "dependencies": { | ||||
|                 "@ai-sdk/openai": "^0.0.51", | ||||
|                 "@dbml/core": "^3.9.5", | ||||
|                 "@dbml/core": "^3.13.9", | ||||
|                 "@dnd-kit/sortable": "^8.0.0", | ||||
|                 "@monaco-editor/react": "^4.6.0", | ||||
|                 "@radix-ui/react-accordion": "^1.2.0", | ||||
| @@ -586,15 +586,15 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/code-frame": { | ||||
|             "version": "7.26.2", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.26.2.tgz", | ||||
|             "integrity": "sha512-RJlIHRueQgwWitWgF8OdFYGZX328Ax5BCemNGlqHfplnRT9ESi8JkFlvaVYbS+UubVY6dpv87Fs2u5M29iNFVQ==", | ||||
|             "version": "7.27.1", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.27.1.tgz", | ||||
|             "integrity": "sha512-cjQ7ZlQ0Mv3b47hABuTevyTuYN4i+loJKGeV9flcCgIK37cCXRh+L1bd3iBHlynerhQ7BhCkn2BPbQUL+rGqFg==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@babel/helper-validator-identifier": "^7.25.9", | ||||
|                 "@babel/helper-validator-identifier": "^7.27.1", | ||||
|                 "js-tokens": "^4.0.0", | ||||
|                 "picocolors": "^1.0.0" | ||||
|                 "picocolors": "^1.1.1" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
| @@ -738,18 +738,18 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/helper-string-parser": { | ||||
|             "version": "7.25.9", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.25.9.tgz", | ||||
|             "integrity": "sha512-4A/SCr/2KLd5jrtOMFzaKjVtAei3+2r/NChoBNoZ3EyP/+GlhoaEGoWOZUmFmoITP7zOJyHIMm+DYRd8o3PvHA==", | ||||
|             "version": "7.27.1", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", | ||||
|             "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", | ||||
|             "license": "MIT", | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/helper-validator-identifier": { | ||||
|             "version": "7.25.9", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.25.9.tgz", | ||||
|             "integrity": "sha512-Ed61U6XJc3CVRfkERJWDz4dJwKe7iLmmJsbOGu9wSloNSFttHV0I8g6UAgb7qnK5ly5bGLPd4oXZlxCdANBOWQ==", | ||||
|             "version": "7.27.1", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.27.1.tgz", | ||||
|             "integrity": "sha512-D2hP9eA+Sqx1kBZgzxZh0y1trbuU+JoDkiEwqhQ36nodYqJwyEIhPSdMNd7lOm/4io72luTPWH20Yda0xOuUow==", | ||||
|             "license": "MIT", | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
| @@ -766,26 +766,26 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/helpers": { | ||||
|             "version": "7.26.7", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.26.7.tgz", | ||||
|             "integrity": "sha512-8NHiL98vsi0mbPQmYAGWwfcFaOy4j2HY49fXJCfuDcdE7fMIsH9a7GdaeXpIBsbT7307WU8KCMp5pUVDNL4f9A==", | ||||
|             "version": "7.28.4", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.4.tgz", | ||||
|             "integrity": "sha512-HFN59MmQXGHVyYadKLVumYsA9dBFun/ldYxipEjzA4196jpLZd8UjEEBLkbEkvfYreDqJhZxYAWFPtrfhNpj4w==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@babel/template": "^7.25.9", | ||||
|                 "@babel/types": "^7.26.7" | ||||
|                 "@babel/template": "^7.27.2", | ||||
|                 "@babel/types": "^7.28.4" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/parser": { | ||||
|             "version": "7.26.7", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.26.7.tgz", | ||||
|             "integrity": "sha512-kEvgGGgEjRUutvdVvZhbn/BxVt+5VSpwXz1j3WYXQbXDo8KzFOPNG2GQbdAiNq8g6wn1yKk7C/qrke03a84V+w==", | ||||
|             "version": "7.28.4", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.28.4.tgz", | ||||
|             "integrity": "sha512-yZbBqeM6TkpP9du/I2pUZnJsRMGGvOuIrhjzC1AwHwW+6he4mni6Bp/m8ijn0iOuZuPI2BfkCoSRunpyjnrQKg==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@babel/types": "^7.26.7" | ||||
|                 "@babel/types": "^7.28.4" | ||||
|             }, | ||||
|             "bin": { | ||||
|                 "parser": "bin/babel-parser.js" | ||||
| @@ -827,27 +827,24 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/runtime": { | ||||
|             "version": "7.26.7", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.26.7.tgz", | ||||
|             "integrity": "sha512-AOPI3D+a8dXnja+iwsUqGRjr1BbZIe771sXdapOtYI531gSqpi92vXivKcq2asu/DFpdl1ceFAKZyRzK2PCVcQ==", | ||||
|             "version": "7.28.4", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.28.4.tgz", | ||||
|             "integrity": "sha512-Q/N6JNWvIvPnLDvjlE1OUBLPQHH6l3CltCEsHIujp45zQUSSh8K+gHnaEX45yAT1nyngnINhvWtzN+Nb9D8RAQ==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "regenerator-runtime": "^0.14.0" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/template": { | ||||
|             "version": "7.25.9", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.25.9.tgz", | ||||
|             "integrity": "sha512-9DGttpmPvIxBb/2uwpVo3dqJ+O6RooAFOS+lB+xDqoE2PVCE8nfoHMdZLpfCQRLwvohzXISPZcgxt80xLfsuwg==", | ||||
|             "version": "7.27.2", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.27.2.tgz", | ||||
|             "integrity": "sha512-LPDZ85aEJyYSd18/DkjNh4/y1ntkE5KwUHWTiqgRxruuZL2F1yuHligVHLvcHY2vMHXttKFpJn6LwfI7cw7ODw==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@babel/code-frame": "^7.25.9", | ||||
|                 "@babel/parser": "^7.25.9", | ||||
|                 "@babel/types": "^7.25.9" | ||||
|                 "@babel/code-frame": "^7.27.1", | ||||
|                 "@babel/parser": "^7.27.2", | ||||
|                 "@babel/types": "^7.27.1" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
| @@ -883,25 +880,25 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@babel/types": { | ||||
|             "version": "7.26.7", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.26.7.tgz", | ||||
|             "integrity": "sha512-t8kDRGrKXyp6+tjUh7hw2RLyclsW4TRoRvRHtSyAX9Bb5ldlFh+90YAYY6awRXrlB4G5G2izNeGySpATlFzmOg==", | ||||
|             "version": "7.28.4", | ||||
|             "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.28.4.tgz", | ||||
|             "integrity": "sha512-bkFqkLhh3pMBUQQkpVgWDWq/lqzc2678eUyDlTBhRqhCHFguYYGM0Efga7tYk4TogG/3x0EEl66/OQ+WGbWB/Q==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@babel/helper-string-parser": "^7.25.9", | ||||
|                 "@babel/helper-validator-identifier": "^7.25.9" | ||||
|                 "@babel/helper-string-parser": "^7.27.1", | ||||
|                 "@babel/helper-validator-identifier": "^7.27.1" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=6.9.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@dbml/core": { | ||||
|             "version": "3.9.5", | ||||
|             "resolved": "https://registry.npmjs.org/@dbml/core/-/core-3.9.5.tgz", | ||||
|             "integrity": "sha512-lX/G5qer42irufv5rvx6Y3ISV2ZLDRlxj8R+OZMdhC6wAw0VYPYIts23MdMFPY39Iay0TDtfmwsbOsVy/yjSIg==", | ||||
|             "version": "3.13.9", | ||||
|             "resolved": "https://registry.npmjs.org/@dbml/core/-/core-3.13.9.tgz", | ||||
|             "integrity": "sha512-JgJ470yuTZU7tP64ZL5FpEh7zSXjSoKzkARmin8iVVhdsNM8Nq4e+FFhG6J6acPtGHtoLahOs9LqrC17B9MqYg==", | ||||
|             "license": "Apache-2.0", | ||||
|             "dependencies": { | ||||
|                 "@dbml/parse": "^3.9.5", | ||||
|                 "@dbml/parse": "^3.13.9", | ||||
|                 "antlr4": "^4.13.1", | ||||
|                 "lodash": "^4.17.15", | ||||
|                 "parsimmon": "^1.13.0", | ||||
| @@ -912,15 +909,15 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@dbml/parse": { | ||||
|             "version": "3.9.5", | ||||
|             "resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-3.9.5.tgz", | ||||
|             "integrity": "sha512-z8MjBYDFiYf7WtsagwGATEye81xQcO9VXFzttSjdJ+wgdSFzFSex9letJPIMIcYXBkm4Fg5qLDk+G9uq/413Dg==", | ||||
|             "version": "3.13.9", | ||||
|             "resolved": "https://registry.npmjs.org/@dbml/parse/-/parse-3.13.9.tgz", | ||||
|             "integrity": "sha512-JMfOxWquXMZpF/MTLy2xWLImx3z9D0t67T7x/BT892WvmhM+9cnJHFA2URT1NXu9jdajbTTFuoWSyzdsfNpaRw==", | ||||
|             "license": "Apache-2.0", | ||||
|             "dependencies": { | ||||
|                 "lodash": "^4.17.21" | ||||
|                 "lodash-es": "^4.17.21" | ||||
|             }, | ||||
|             "peerDependencies": { | ||||
|                 "lodash": "^4.17.21" | ||||
|             "engines": { | ||||
|                 "node": ">=18" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@dnd-kit/accessibility": { | ||||
| @@ -1370,9 +1367,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint-community/eslint-utils": { | ||||
|             "version": "4.4.1", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.4.1.tgz", | ||||
|             "integrity": "sha512-s3O3waFUrMV8P/XaF/+ZTp1X9XBZW1a4B97ZnjQF2KYWaFD2A8KyFBsrsfSjEmjn3RGWAIuvlneuZm3CUK3jbA==", | ||||
|             "version": "4.9.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.0.tgz", | ||||
|             "integrity": "sha512-ayVFHdtZ+hsq1t2Dy24wCmGXGe4q9Gu3smhLYALJrr473ZH27MsnSL+LKUlimp4BWJqMDMLmPpx/Q9R3OAlL4g==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
| @@ -1417,9 +1414,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/config-array": { | ||||
|             "version": "0.19.2", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.19.2.tgz", | ||||
|             "integrity": "sha512-GNKqxfHG2ySmJOBSHg7LxeUx4xpuCoFjacmlCoYWEbaPXLwvfIjixRI12xCQZeULksQb23uiA8F40w5TojpV7w==", | ||||
|             "version": "0.21.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.21.0.tgz", | ||||
|             "integrity": "sha512-ENIdc4iLu0d93HeYirvKmrzshzofPw6VkZRKQGe9Nv46ZnWUzcF1xV01dcvEg/1wXUR61OmmlSfyeyO7EvjLxQ==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "dependencies": { | ||||
| @@ -1431,10 +1428,20 @@ | ||||
|                 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/config-helpers": { | ||||
|             "version": "0.3.1", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.3.1.tgz", | ||||
|             "integrity": "sha512-xR93k9WhrDYpXHORXpxVL5oHj3Era7wo6k/Wd8/IsQNnZUTzkGS29lyn3nAT05v6ltUuTFVCCYDEGfy2Or/sPA==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "engines": { | ||||
|                 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/core": { | ||||
|             "version": "0.10.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.10.0.tgz", | ||||
|             "integrity": "sha512-gFHJ+xBOo4G3WRlR1e/3G8A6/KZAH6zcE/hkLRCZTi/B9avAG365QhFA8uOGzTMqgTghpn7/fSnscW++dpMSAw==", | ||||
|             "version": "0.15.2", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/core/-/core-0.15.2.tgz", | ||||
|             "integrity": "sha512-78Md3/Rrxh83gCxoUc0EiciuOHsIITzLy53m3d9UyiW8y9Dj2D29FeETqyKA+BRK76tnTp6RXWb3pCay8Oyomg==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "dependencies": { | ||||
| @@ -1445,9 +1452,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/eslintrc": { | ||||
|             "version": "3.2.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.2.0.tgz", | ||||
|             "integrity": "sha512-grOjVNN8P3hjJn/eIETF1wwd12DdnwFDoyceUJLYYdkpbwq3nLi+4fqrTAONx7XDALqlL220wC/RHSC/QTI/0w==", | ||||
|             "version": "3.3.1", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/eslintrc/-/eslintrc-3.3.1.tgz", | ||||
|             "integrity": "sha512-gtF186CXhIl1p4pJNGZw8Yc6RlshoePRvE0X91oPGb3vZ8pM3qOS9W9NGPat9LziaBV7XrJWGylNQXkGcnM3IQ==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
| @@ -1482,13 +1489,16 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/js": { | ||||
|             "version": "9.19.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.19.0.tgz", | ||||
|             "integrity": "sha512-rbq9/g38qjfqFLOVPvwjIvFFdNziEC5S65jmjPw5r6A//QH+W91akh9irMwjDN8zKUTak6W9EsAv4m/7Wnw0UQ==", | ||||
|             "version": "9.35.0", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/js/-/js-9.35.0.tgz", | ||||
|             "integrity": "sha512-30iXE9whjlILfWobBkNerJo+TXYsgVM5ERQwMcMKCHckHflCmf7wXDAHlARoWnh0s1U72WqlbeyE7iAcCzuCPw==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "engines": { | ||||
|                 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | ||||
|             }, | ||||
|             "funding": { | ||||
|                 "url": "https://eslint.org/donate" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/object-schema": { | ||||
| @@ -1502,13 +1512,13 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@eslint/plugin-kit": { | ||||
|             "version": "0.2.5", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.2.5.tgz", | ||||
|             "integrity": "sha512-lB05FkqEdUg2AA0xEbUz0SnkXT1LcCTa438W4IWTUh4hdOnVbQyOJ81OrDXsJk/LSiJHubgGEFoR5EHq1NsH1A==", | ||||
|             "version": "0.3.5", | ||||
|             "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.3.5.tgz", | ||||
|             "integrity": "sha512-Z5kJ+wU3oA7MMIqVR9tyZRtjYPr4OC004Q4Rw7pgOKUOKkJfZ3O24nz3WYfGRpMDNmcOi3TwQOmgm7B7Tpii0w==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "dependencies": { | ||||
|                 "@eslint/core": "^0.10.0", | ||||
|                 "@eslint/core": "^0.15.2", | ||||
|                 "levn": "^0.4.1" | ||||
|             }, | ||||
|             "engines": { | ||||
| @@ -1606,9 +1616,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@humanwhocodes/retry": { | ||||
|             "version": "0.4.1", | ||||
|             "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.1.tgz", | ||||
|             "integrity": "sha512-c7hNEllBlenFTHBky65mhq8WD2kbN9Q6gk0bTk8lSBvc554jpXSkST1iePudpt7+A/AQvuHs9EMqjHDXMY1lrA==", | ||||
|             "version": "0.4.3", | ||||
|             "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", | ||||
|             "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "engines": { | ||||
| @@ -4276,12 +4286,6 @@ | ||||
|                 "@types/deep-eql": "*" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@types/cookie": { | ||||
|             "version": "0.6.0", | ||||
|             "resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz", | ||||
|             "integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==", | ||||
|             "license": "MIT" | ||||
|         }, | ||||
|         "node_modules/@types/d3-color": { | ||||
|             "version": "3.1.3", | ||||
|             "resolved": "https://registry.npmjs.org/@types/d3-color/-/d3-color-3.1.3.tgz", | ||||
| @@ -4553,9 +4557,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": { | ||||
|             "version": "2.0.1", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", | ||||
|             "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", | ||||
|             "version": "2.0.2", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", | ||||
|             "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
| @@ -4961,9 +4965,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/acorn": { | ||||
|             "version": "8.14.0", | ||||
|             "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.14.0.tgz", | ||||
|             "integrity": "sha512-cl669nCJTZBsL97OF4kUQm5g5hC2uihk0NxY3WENAC0TYdILVkAyHymAntgxGkl7K+t0cXIrH5siy5S4XkFycA==", | ||||
|             "version": "8.15.0", | ||||
|             "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.15.0.tgz", | ||||
|             "integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==", | ||||
|             "license": "MIT", | ||||
|             "bin": { | ||||
|                 "acorn": "bin/acorn" | ||||
| @@ -5486,9 +5490,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/brace-expansion": { | ||||
|             "version": "1.1.11", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", | ||||
|             "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", | ||||
|             "version": "1.1.12", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", | ||||
|             "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
| @@ -6578,22 +6582,23 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/eslint": { | ||||
|             "version": "9.19.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.19.0.tgz", | ||||
|             "integrity": "sha512-ug92j0LepKlbbEv6hD911THhoRHmbdXt2gX+VDABAW/Ir7D3nqKdv5Pf5vtlyY6HQMTEP2skXY43ueqTCWssEA==", | ||||
|             "version": "9.35.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint/-/eslint-9.35.0.tgz", | ||||
|             "integrity": "sha512-QePbBFMJFjgmlE+cXAlbHZbHpdFVS2E/6vzCy7aKlebddvl1vadiC4JFV5u/wqTkNUwEV8WrQi257jf5f06hrg==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@eslint-community/eslint-utils": "^4.2.0", | ||||
|                 "@eslint-community/eslint-utils": "^4.8.0", | ||||
|                 "@eslint-community/regexpp": "^4.12.1", | ||||
|                 "@eslint/config-array": "^0.19.0", | ||||
|                 "@eslint/core": "^0.10.0", | ||||
|                 "@eslint/eslintrc": "^3.2.0", | ||||
|                 "@eslint/js": "9.19.0", | ||||
|                 "@eslint/plugin-kit": "^0.2.5", | ||||
|                 "@eslint/config-array": "^0.21.0", | ||||
|                 "@eslint/config-helpers": "^0.3.1", | ||||
|                 "@eslint/core": "^0.15.2", | ||||
|                 "@eslint/eslintrc": "^3.3.1", | ||||
|                 "@eslint/js": "9.35.0", | ||||
|                 "@eslint/plugin-kit": "^0.3.5", | ||||
|                 "@humanfs/node": "^0.16.6", | ||||
|                 "@humanwhocodes/module-importer": "^1.0.1", | ||||
|                 "@humanwhocodes/retry": "^0.4.1", | ||||
|                 "@humanwhocodes/retry": "^0.4.2", | ||||
|                 "@types/estree": "^1.0.6", | ||||
|                 "@types/json-schema": "^7.0.15", | ||||
|                 "ajv": "^6.12.4", | ||||
| @@ -6601,9 +6606,9 @@ | ||||
|                 "cross-spawn": "^7.0.6", | ||||
|                 "debug": "^4.3.2", | ||||
|                 "escape-string-regexp": "^4.0.0", | ||||
|                 "eslint-scope": "^8.2.0", | ||||
|                 "eslint-visitor-keys": "^4.2.0", | ||||
|                 "espree": "^10.3.0", | ||||
|                 "eslint-scope": "^8.4.0", | ||||
|                 "eslint-visitor-keys": "^4.2.1", | ||||
|                 "espree": "^10.4.0", | ||||
|                 "esquery": "^1.5.0", | ||||
|                 "esutils": "^2.0.2", | ||||
|                 "fast-deep-equal": "^3.1.3", | ||||
| @@ -6812,9 +6817,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/eslint-scope": { | ||||
|             "version": "8.2.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.2.0.tgz", | ||||
|             "integrity": "sha512-PHlWUfG6lvPc3yvP5A4PNyBL1W8fkDUccmI21JUu/+GKZBoH/W5u6usENXUrWFRsyoW5ACUjFGgAFQp5gUlb/A==", | ||||
|             "version": "8.4.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-8.4.0.tgz", | ||||
|             "integrity": "sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==", | ||||
|             "dev": true, | ||||
|             "license": "BSD-2-Clause", | ||||
|             "dependencies": { | ||||
| @@ -6842,9 +6847,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/eslint/node_modules/eslint-visitor-keys": { | ||||
|             "version": "4.2.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", | ||||
|             "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", | ||||
|             "version": "4.2.1", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", | ||||
|             "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "engines": { | ||||
| @@ -6862,15 +6867,15 @@ | ||||
|             "peer": true | ||||
|         }, | ||||
|         "node_modules/espree": { | ||||
|             "version": "10.3.0", | ||||
|             "resolved": "https://registry.npmjs.org/espree/-/espree-10.3.0.tgz", | ||||
|             "integrity": "sha512-0QYC8b24HWY8zjRnDTL6RiHfDbAWn63qb4LMj1Z4b076A4une81+z03Kg7l7mn/48PUTqoLptSXez8oknU8Clg==", | ||||
|             "version": "10.4.0", | ||||
|             "resolved": "https://registry.npmjs.org/espree/-/espree-10.4.0.tgz", | ||||
|             "integrity": "sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==", | ||||
|             "dev": true, | ||||
|             "license": "BSD-2-Clause", | ||||
|             "dependencies": { | ||||
|                 "acorn": "^8.14.0", | ||||
|                 "acorn": "^8.15.0", | ||||
|                 "acorn-jsx": "^5.3.2", | ||||
|                 "eslint-visitor-keys": "^4.2.0" | ||||
|                 "eslint-visitor-keys": "^4.2.1" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": "^18.18.0 || ^20.9.0 || >=21.1.0" | ||||
| @@ -6880,9 +6885,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/espree/node_modules/eslint-visitor-keys": { | ||||
|             "version": "4.2.0", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.0.tgz", | ||||
|             "integrity": "sha512-UyLnSehNt62FFhSwjZlHmeokpRK59rcz29j+F1/aDgbkbRTk7wIc9XzdoasMUbRNKDM0qQt/+BJ4BrpFeABemw==", | ||||
|             "version": "4.2.1", | ||||
|             "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-4.2.1.tgz", | ||||
|             "integrity": "sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==", | ||||
|             "dev": true, | ||||
|             "license": "Apache-2.0", | ||||
|             "engines": { | ||||
| @@ -7365,9 +7370,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/glob/node_modules/brace-expansion": { | ||||
|             "version": "2.0.1", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", | ||||
|             "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", | ||||
|             "version": "2.0.2", | ||||
|             "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.2.tgz", | ||||
|             "integrity": "sha512-Jt0vHyM+jmUBqojB7E1NIYadt0vI0Qxjxd2TErW94wDz+E2LAm5vKMXXwg6ZZBTHPuUlDgQHKXvjGBdfcF1ZDQ==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "balanced-match": "^1.0.0" | ||||
| @@ -8440,6 +8445,12 @@ | ||||
|             "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==", | ||||
|             "license": "MIT" | ||||
|         }, | ||||
|         "node_modules/lodash-es": { | ||||
|             "version": "4.17.21", | ||||
|             "resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz", | ||||
|             "integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==", | ||||
|             "license": "MIT" | ||||
|         }, | ||||
|         "node_modules/lodash.merge": { | ||||
|             "version": "4.6.2", | ||||
|             "resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz", | ||||
| @@ -9600,15 +9611,13 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/react-router": { | ||||
|             "version": "7.1.5", | ||||
|             "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.1.5.tgz", | ||||
|             "integrity": "sha512-8BUF+hZEU4/z/JD201yK6S+UYhsf58bzYIDq2NS1iGpwxSXDu7F+DeGSkIXMFBuHZB21FSiCzEcUb18cQNdRkA==", | ||||
|             "version": "7.8.2", | ||||
|             "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", | ||||
|             "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "@types/cookie": "^0.6.0", | ||||
|                 "cookie": "^1.0.1", | ||||
|                 "set-cookie-parser": "^2.6.0", | ||||
|                 "turbo-stream": "2.4.0" | ||||
|                 "set-cookie-parser": "^2.6.0" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=20.0.0" | ||||
| @@ -9624,12 +9633,12 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/react-router-dom": { | ||||
|             "version": "7.1.5", | ||||
|             "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.1.5.tgz", | ||||
|             "integrity": "sha512-/4f9+up0Qv92D3bB8iN5P1s3oHAepSGa9h5k6tpTFlixTTskJZwKGhJ6vRJ277tLD1zuaZTt95hyGWV1Z37csQ==", | ||||
|             "version": "7.8.2", | ||||
|             "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", | ||||
|             "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|                 "react-router": "7.1.5" | ||||
|                 "react-router": "7.8.2" | ||||
|             }, | ||||
|             "engines": { | ||||
|                 "node": ">=20.0.0" | ||||
| @@ -9760,12 +9769,6 @@ | ||||
|                 "url": "https://github.com/sponsors/ljharb" | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/regenerator-runtime": { | ||||
|             "version": "0.14.1", | ||||
|             "resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.14.1.tgz", | ||||
|             "integrity": "sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==", | ||||
|             "license": "MIT" | ||||
|         }, | ||||
|         "node_modules/regexp.prototype.flags": { | ||||
|             "version": "1.5.4", | ||||
|             "resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz", | ||||
| @@ -11026,12 +11029,6 @@ | ||||
|             "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", | ||||
|             "license": "0BSD" | ||||
|         }, | ||||
|         "node_modules/turbo-stream": { | ||||
|             "version": "2.4.0", | ||||
|             "resolved": "https://registry.npmjs.org/turbo-stream/-/turbo-stream-2.4.0.tgz", | ||||
|             "integrity": "sha512-FHncC10WpBd2eOmGwpmQsWLDoK4cqsA/UT/GqNoaKOQnT8uzhtCbg3EoUDMvqpOSAI0S26mr0rkjzbOO6S3v1g==", | ||||
|             "license": "ISC" | ||||
|         }, | ||||
|         "node_modules/type-check": { | ||||
|             "version": "0.4.0", | ||||
|             "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", | ||||
| @@ -11313,9 +11310,9 @@ | ||||
|             } | ||||
|         }, | ||||
|         "node_modules/vite": { | ||||
|             "version": "5.4.14", | ||||
|             "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.14.tgz", | ||||
|             "integrity": "sha512-EK5cY7Q1D8JNhSaPKVK4pwBFvaTmZxEnoKXLG/U9gmdDcihQGNzFlgIvaxezFR4glP1LsuiedwMBqCXH3wZccA==", | ||||
|             "version": "5.4.20", | ||||
|             "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.20.tgz", | ||||
|             "integrity": "sha512-j3lYzGC3P+B5Yfy/pfKNgVEg4+UtcIJcVRt2cDjIOmhLourAqPqf8P7acgxeiSgUB7E3p2P8/3gNIgDLpwzs4g==", | ||||
|             "dev": true, | ||||
|             "license": "MIT", | ||||
|             "dependencies": { | ||||
|   | ||||
| @@ -1,7 +1,7 @@ | ||||
| { | ||||
|     "name": "chartdb", | ||||
|     "private": true, | ||||
|     "version": "1.15.0", | ||||
|     "version": "1.15.1", | ||||
|     "type": "module", | ||||
|     "scripts": { | ||||
|         "dev": "vite", | ||||
| @@ -17,7 +17,7 @@ | ||||
|     }, | ||||
|     "dependencies": { | ||||
|         "@ai-sdk/openai": "^0.0.51", | ||||
|         "@dbml/core": "^3.9.5", | ||||
|         "@dbml/core": "^3.13.9", | ||||
|         "@dnd-kit/sortable": "^8.0.0", | ||||
|         "@monaco-editor/react": "^4.6.0", | ||||
|         "@radix-ui/react-accordion": "^1.2.0", | ||||
|   | ||||
| @@ -11,18 +11,26 @@ import { | ||||
|     DropdownMenuItem, | ||||
|     DropdownMenuTrigger, | ||||
| } from '@/components/dropdown-menu/dropdown-menu'; | ||||
| import { | ||||
|     Tooltip, | ||||
|     TooltipContent, | ||||
|     TooltipTrigger, | ||||
| } from '@/components/tooltip/tooltip'; | ||||
|  | ||||
| export interface ButtonAlternative { | ||||
|     label: string; | ||||
|     onClick: () => void; | ||||
|     disabled?: boolean; | ||||
|     icon?: React.ReactNode; | ||||
|     className?: string; | ||||
|     tooltip?: string; | ||||
| } | ||||
|  | ||||
| export interface ButtonWithAlternativesProps | ||||
|     extends React.ButtonHTMLAttributes<HTMLButtonElement>, | ||||
|         VariantProps<typeof buttonVariants> { | ||||
|     asChild?: boolean; | ||||
|     alternatives: Array<{ | ||||
|         label: string; | ||||
|         onClick: () => void; | ||||
|         disabled?: boolean; | ||||
|         icon?: React.ReactNode; | ||||
|         className?: string; | ||||
|     }>; | ||||
|     alternatives: Array<ButtonAlternative>; | ||||
|     dropdownTriggerClassName?: string; | ||||
|     chevronDownIconClassName?: string; | ||||
| } | ||||
| @@ -87,19 +95,36 @@ const ButtonWithAlternatives = React.forwardRef< | ||||
|                             </button> | ||||
|                         </DropdownMenuTrigger> | ||||
|                         <DropdownMenuContent align="end"> | ||||
|                             {alternatives.map((alternative, index) => ( | ||||
|                                 <DropdownMenuItem | ||||
|                                     key={index} | ||||
|                                     onClick={alternative.onClick} | ||||
|                                     disabled={alternative.disabled} | ||||
|                                     className={cn(alternative.className)} | ||||
|                                 > | ||||
|                                     <span className="flex w-full items-center justify-between gap-2"> | ||||
|                                         {alternative.label} | ||||
|                                         {alternative.icon} | ||||
|                                     </span> | ||||
|                                 </DropdownMenuItem> | ||||
|                             ))} | ||||
|                             {alternatives.map((alternative, index) => { | ||||
|                                 const menuItem = ( | ||||
|                                     <DropdownMenuItem | ||||
|                                         key={index} | ||||
|                                         onClick={alternative.onClick} | ||||
|                                         disabled={alternative.disabled} | ||||
|                                         className={cn(alternative.className)} | ||||
|                                     > | ||||
|                                         <span className="flex w-full items-center justify-between gap-2"> | ||||
|                                             {alternative.label} | ||||
|                                             {alternative.icon} | ||||
|                                         </span> | ||||
|                                     </DropdownMenuItem> | ||||
|                                 ); | ||||
|  | ||||
|                                 if (alternative.tooltip) { | ||||
|                                     return ( | ||||
|                                         <Tooltip key={index}> | ||||
|                                             <TooltipTrigger asChild> | ||||
|                                                 {menuItem} | ||||
|                                             </TooltipTrigger> | ||||
|                                             <TooltipContent side="left"> | ||||
|                                                 {alternative.tooltip} | ||||
|                                             </TooltipContent> | ||||
|                                         </Tooltip> | ||||
|                                     ); | ||||
|                                 } | ||||
|  | ||||
|                                 return menuItem; | ||||
|                             })} | ||||
|                         </DropdownMenuContent> | ||||
|                     </DropdownMenu> | ||||
|                 ) : null} | ||||
|   | ||||
| @@ -5,21 +5,33 @@ import { | ||||
|     PopoverTrigger, | ||||
| } from '@/components/popover/popover'; | ||||
| import { colorOptions } from '@/lib/colors'; | ||||
| import { cn } from '@/lib/utils'; | ||||
|  | ||||
| export interface ColorPickerProps { | ||||
|     color: string; | ||||
|     onChange: (color: string) => void; | ||||
|     disabled?: boolean; | ||||
| } | ||||
|  | ||||
| export const ColorPicker = React.forwardRef< | ||||
|     React.ElementRef<typeof PopoverTrigger>, | ||||
|     ColorPickerProps | ||||
| >(({ color, onChange }, ref) => { | ||||
| >(({ color, onChange, disabled }, ref) => { | ||||
|     return ( | ||||
|         <Popover> | ||||
|             <PopoverTrigger asChild ref={ref}> | ||||
|             <PopoverTrigger | ||||
|                 asChild | ||||
|                 ref={ref} | ||||
|                 disabled={disabled} | ||||
|                 {...(disabled ? { onClick: (e) => e.preventDefault() } : {})} | ||||
|             > | ||||
|                 <div | ||||
|                     className="h-6 w-8 cursor-pointer rounded-md border-2 border-muted transition-shadow hover:shadow-md" | ||||
|                     className={cn( | ||||
|                         'h-6 w-8 cursor-pointer rounded-md border-2 border-muted transition-shadow hover:shadow-md', | ||||
|                         { | ||||
|                             'hover:shadow-none cursor-default': disabled, | ||||
|                         } | ||||
|                     )} | ||||
|                     style={{ | ||||
|                         backgroundColor: color, | ||||
|                     }} | ||||
|   | ||||
| @@ -27,6 +27,7 @@ export interface SelectBoxOption { | ||||
|     regex?: string; | ||||
|     extractRegex?: RegExp; | ||||
|     group?: string; | ||||
|     icon?: React.ReactNode; | ||||
| } | ||||
|  | ||||
| export interface SelectBoxProps { | ||||
| @@ -53,6 +54,8 @@ export interface SelectBoxProps { | ||||
|     open?: boolean; | ||||
|     onOpenChange?: (open: boolean) => void; | ||||
|     popoverClassName?: string; | ||||
|     readonly?: boolean; | ||||
|     footerButtons?: React.ReactNode; | ||||
| } | ||||
|  | ||||
| export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
| @@ -78,6 +81,8 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|             open, | ||||
|             onOpenChange: setOpen, | ||||
|             popoverClassName, | ||||
|             readonly, | ||||
|             footerButtons, | ||||
|         }, | ||||
|         ref | ||||
|     ) => { | ||||
| @@ -152,18 +157,20 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                             className={`inline-flex min-w-0 shrink-0 items-center gap-1 rounded-md border py-0.5 pl-2 pr-1 text-xs font-medium text-foreground transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 ${oneLine ? 'mx-0.5' : ''}`} | ||||
|                         > | ||||
|                             <span>{option.label}</span> | ||||
|                             <span | ||||
|                                 onClick={(e) => { | ||||
|                                     e.preventDefault(); | ||||
|                                     handleSelect(option.value); | ||||
|                                 }} | ||||
|                                 className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground" | ||||
|                             > | ||||
|                                 <Cross2Icon /> | ||||
|                             </span> | ||||
|                             {!readonly ? ( | ||||
|                                 <span | ||||
|                                     onClick={(e) => { | ||||
|                                         e.preventDefault(); | ||||
|                                         handleSelect(option.value); | ||||
|                                     }} | ||||
|                                     className="flex items-center rounded-sm px-px text-muted-foreground/60 hover:bg-accent hover:text-muted-foreground" | ||||
|                                 > | ||||
|                                     <Cross2Icon /> | ||||
|                                 </span> | ||||
|                             ) : null} | ||||
|                         </span> | ||||
|                     )), | ||||
|             [options, value, handleSelect, oneLine, keepOrder] | ||||
|             [options, value, handleSelect, oneLine, keepOrder, readonly] | ||||
|         ); | ||||
|  | ||||
|         const isAllSelected = React.useMemo( | ||||
| @@ -250,6 +257,11 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                             </div> | ||||
|                         )} | ||||
|                         <div className="flex flex-1 items-center truncate"> | ||||
|                             {option.icon ? ( | ||||
|                                 <span className="mr-2 shrink-0"> | ||||
|                                     {option.icon} | ||||
|                                 </span> | ||||
|                             ) : null} | ||||
|                             <span> | ||||
|                                 {isRegexMatch ? searchTerm : option.label} | ||||
|                                 {!isRegexMatch && optionSuffix | ||||
| @@ -284,7 +296,7 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                 <PopoverTrigger asChild tabIndex={0} onKeyDown={handleKeyDown}> | ||||
|                     <div | ||||
|                         className={cn( | ||||
|                             `flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''}`, | ||||
|                             `flex min-h-[36px] cursor-pointer items-center justify-between rounded-md border px-3 py-1 data-[state=open]:border-ring ${disabled ? 'bg-muted pointer-events-none' : ''} ${readonly ? 'pointer-events-none' : ''}`, | ||||
|                             className | ||||
|                         )} | ||||
|                     > | ||||
| @@ -443,6 +455,9 @@ export const SelectBox = React.forwardRef<HTMLInputElement, SelectBoxProps>( | ||||
|                             </div> | ||||
|                         </ScrollArea> | ||||
|                     </Command> | ||||
|                     {footerButtons ? ( | ||||
|                         <div className="border-t">{footerButtons}</div> | ||||
|                     ) : null} | ||||
|                 </PopoverContent> | ||||
|             </Popover> | ||||
|         ); | ||||
|   | ||||
| @@ -41,8 +41,7 @@ export const ChartDBProvider: React.FC< | ||||
|     React.PropsWithChildren<ChartDBProviderProps> | ||||
| > = ({ children, diagram, readonly: readonlyProp }) => { | ||||
|     const { hasDiff } = useDiff(); | ||||
|     const dbStorage = useStorage(); | ||||
|     let db = dbStorage; | ||||
|     const storageDB = useStorage(); | ||||
|     const events = useEventEmitter<ChartDBEvent>(); | ||||
|     const { addUndoAction, resetRedoStack, resetUndoStack } = | ||||
|         useRedoUndoStack(); | ||||
| @@ -102,10 +101,6 @@ export const ChartDBProvider: React.FC< | ||||
|         [readonlyProp, hasDiff] | ||||
|     ); | ||||
|  | ||||
|     if (readonly) { | ||||
|         db = storageInitialValue; | ||||
|     } | ||||
|  | ||||
|     const schemas = useMemo( | ||||
|         () => | ||||
|             databasesWithSchemas.includes(databaseType) | ||||
| @@ -134,6 +129,11 @@ export const ChartDBProvider: React.FC< | ||||
|         [tables, defaultSchemaName, databaseType] | ||||
|     ); | ||||
|  | ||||
|     const db = useMemo( | ||||
|         () => (readonly ? storageInitialValue : storageDB), | ||||
|         [storageDB, readonly] | ||||
|     ); | ||||
|  | ||||
|     const currentDiagram: Diagram = useMemo( | ||||
|         () => ({ | ||||
|             id: diagramId, | ||||
| @@ -1580,17 +1580,17 @@ export const ChartDBProvider: React.FC< | ||||
|  | ||||
|     const updateDiagramData: ChartDBContext['updateDiagramData'] = useCallback( | ||||
|         async (diagram, options) => { | ||||
|             const st = options?.forceUpdateStorage ? dbStorage : db; | ||||
|             const st = options?.forceUpdateStorage ? storageDB : db; | ||||
|             await st.deleteDiagram(diagram.id); | ||||
|             await st.addDiagram({ diagram }); | ||||
|             loadDiagramFromData(diagram); | ||||
|         }, | ||||
|         [db, dbStorage, loadDiagramFromData] | ||||
|         [db, storageDB, loadDiagramFromData] | ||||
|     ); | ||||
|  | ||||
|     const loadDiagram: ChartDBContext['loadDiagram'] = useCallback( | ||||
|         async (diagramId: string) => { | ||||
|             const diagram = await db.getDiagram(diagramId, { | ||||
|             const diagram = await storageDB.getDiagram(diagramId, { | ||||
|                 includeRelationships: true, | ||||
|                 includeTables: true, | ||||
|                 includeDependencies: true, | ||||
| @@ -1604,7 +1604,7 @@ export const ChartDBProvider: React.FC< | ||||
|  | ||||
|             return diagram; | ||||
|         }, | ||||
|         [db, loadDiagramFromData] | ||||
|         [storageDB, loadDiagramFromData] | ||||
|     ); | ||||
|  | ||||
|     // Custom type operations | ||||
|   | ||||
| @@ -3,7 +3,7 @@ import { Dialog, DialogContent } from '@/components/dialog/dialog'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { useStorage } from '@/hooks/use-storage'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import { loadFromDatabaseMetadata } from '@/lib/domain/diagram'; | ||||
| import { loadFromDatabaseMetadata } from '@/lib/data/import-metadata/import'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import { useConfig } from '@/hooks/use-config'; | ||||
| import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; | ||||
|   | ||||
| @@ -69,6 +69,7 @@ export const SelectDatabase: React.FC<SelectDatabaseProps> = ({ | ||||
|                         type="button" | ||||
|                         variant="outline" | ||||
|                         onClick={createNewDiagram} | ||||
|                         disabled={databaseType === DatabaseType.GENERIC} | ||||
|                     > | ||||
|                         {t('new_diagram_dialog.empty_diagram')} | ||||
|                     </Button> | ||||
|   | ||||
| @@ -17,7 +17,7 @@ import { useDialog } from '@/hooks/use-dialog'; | ||||
| import { | ||||
|     exportBaseSQL, | ||||
|     exportSQL, | ||||
| } from '@/lib/data/export-metadata/export-sql-script'; | ||||
| } from '@/lib/data/sql-export/export-sql-script'; | ||||
| import { databaseTypeToLabelMap } from '@/lib/databases'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { Annoyed, Sparkles } from 'lucide-react'; | ||||
|   | ||||
| @@ -7,7 +7,7 @@ import type { DatabaseEdition } from '@/lib/domain/database-edition'; | ||||
| import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; | ||||
| import { loadDatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import { loadFromDatabaseMetadata } from '@/lib/domain/diagram'; | ||||
| import { loadFromDatabaseMetadata } from '@/lib/data/import-metadata/import'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import { useRedoUndoStack } from '@/hooks/use-redo-undo-stack'; | ||||
| import { Trans, useTranslation } from 'react-i18next'; | ||||
|   | ||||
| @@ -132,7 +132,7 @@ Ref: comments.user_id > users.id // Each comment is written by one user`; | ||||
|                 const preprocessedContent = preprocessDBML(content); | ||||
|                 const sanitizedContent = sanitizeDBML(preprocessedContent); | ||||
|                 const parser = new Parser(); | ||||
|                 parser.parse(sanitizedContent, 'dbml'); | ||||
|                 parser.parse(sanitizedContent, 'dbmlv2'); | ||||
|             } catch (e) { | ||||
|                 const parsedError = parseDBMLError(e); | ||||
|                 if (parsedError) { | ||||
|   | ||||
| @@ -0,0 +1,98 @@ | ||||
| import React, { useCallback } from 'react'; | ||||
| import { | ||||
|     DropdownMenu, | ||||
|     DropdownMenuContent, | ||||
|     DropdownMenuItem, | ||||
|     DropdownMenuSeparator, | ||||
|     DropdownMenuTrigger, | ||||
| } from '@/components/dropdown-menu/dropdown-menu'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { Ellipsis, Layers2, SquareArrowOutUpRight, Trash2 } from 'lucide-react'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import type { Diagram } from '@/lib/domain'; | ||||
| import { useStorage } from '@/hooks/use-storage'; | ||||
| import { cloneDiagram } from '@/lib/clone'; | ||||
| import { useTranslation } from 'react-i18next'; | ||||
|  | ||||
| interface DiagramRowActionsMenuProps { | ||||
|     diagram: Diagram; | ||||
|     onOpen: () => void; | ||||
|     refetch: () => void; | ||||
|     numberOfDiagrams: number; | ||||
| } | ||||
|  | ||||
| export const DiagramRowActionsMenu: React.FC<DiagramRowActionsMenuProps> = ({ | ||||
|     diagram, | ||||
|     onOpen, | ||||
|     refetch, | ||||
|     numberOfDiagrams, | ||||
| }) => { | ||||
|     const { diagramId } = useChartDB(); | ||||
|     const { deleteDiagram, addDiagram } = useStorage(); | ||||
|     const { t } = useTranslation(); | ||||
|  | ||||
|     const onDelete = useCallback(async () => { | ||||
|         deleteDiagram(diagram.id); | ||||
|         refetch(); | ||||
|  | ||||
|         if (diagram.id === diagramId || numberOfDiagrams <= 1) { | ||||
|             window.location.href = '/'; | ||||
|         } | ||||
|     }, [deleteDiagram, diagram.id, diagramId, refetch, numberOfDiagrams]); | ||||
|  | ||||
|     const onDuplicate = useCallback(async () => { | ||||
|         const duplicatedDiagram = cloneDiagram(diagram); | ||||
|  | ||||
|         const diagramToAdd = duplicatedDiagram.diagram; | ||||
|  | ||||
|         if (!diagramToAdd) { | ||||
|             return; | ||||
|         } | ||||
|  | ||||
|         diagramToAdd.name = `${diagram.name} (Copy)`; | ||||
|  | ||||
|         addDiagram({ diagram: diagramToAdd }); | ||||
|         refetch(); | ||||
|     }, [addDiagram, refetch, diagram]); | ||||
|  | ||||
|     return ( | ||||
|         <DropdownMenu> | ||||
|             <DropdownMenuTrigger asChild> | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="icon" | ||||
|                     className="size-8 p-0" | ||||
|                     onClick={(e) => e.stopPropagation()} | ||||
|                 > | ||||
|                     <Ellipsis className="size-4" /> | ||||
|                 </Button> | ||||
|             </DropdownMenuTrigger> | ||||
|             <DropdownMenuContent align="end"> | ||||
|                 <DropdownMenuItem | ||||
|                     onClick={onOpen} | ||||
|                     className="flex justify-between gap-4" | ||||
|                 > | ||||
|                     {t('open_diagram_dialog.diagram_actions.open')} | ||||
|                     <SquareArrowOutUpRight className="size-3.5" /> | ||||
|                 </DropdownMenuItem> | ||||
|  | ||||
|                 <DropdownMenuItem | ||||
|                     onClick={onDuplicate} | ||||
|                     className="flex justify-between gap-4" | ||||
|                 > | ||||
|                     {t('open_diagram_dialog.diagram_actions.duplicate')} | ||||
|                     <Layers2 className="size-3.5" /> | ||||
|                 </DropdownMenuItem> | ||||
|  | ||||
|                 <DropdownMenuSeparator /> | ||||
|                 <DropdownMenuItem | ||||
|                     onClick={onDelete} | ||||
|                     className="flex justify-between gap-4 text-red-700" | ||||
|                 > | ||||
|                     {t('open_diagram_dialog.diagram_actions.delete')} | ||||
|                     <Trash2 className="size-3.5 text-red-700" /> | ||||
|                 </DropdownMenuItem> | ||||
|             </DropdownMenuContent> | ||||
|         </DropdownMenu> | ||||
|     ); | ||||
| }; | ||||
| @@ -27,6 +27,7 @@ import { useTranslation } from 'react-i18next'; | ||||
| import { useNavigate } from 'react-router-dom'; | ||||
| import type { BaseDialogProps } from '../common/base-dialog-props'; | ||||
| import { useDebounce } from '@/hooks/use-debounce'; | ||||
| import { DiagramRowActionsMenu } from './diagram-row-actions-menu/diagram-row-actions-menu'; | ||||
|  | ||||
| export interface OpenDiagramDialogProps extends BaseDialogProps { | ||||
|     canClose?: boolean; | ||||
| @@ -46,21 +47,22 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({ | ||||
|         string | undefined | ||||
|     >(); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         setSelectedDiagramId(undefined); | ||||
|     }, [dialog.open]); | ||||
|     const fetchDiagrams = useCallback(async () => { | ||||
|         const diagrams = await listDiagrams({ includeTables: true }); | ||||
|         setDiagrams( | ||||
|             diagrams.sort( | ||||
|                 (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() | ||||
|             ) | ||||
|         ); | ||||
|     }, [listDiagrams]); | ||||
|  | ||||
|     useEffect(() => { | ||||
|         const fetchDiagrams = async () => { | ||||
|             const diagrams = await listDiagrams({ includeTables: true }); | ||||
|             setDiagrams( | ||||
|                 diagrams.sort( | ||||
|                     (a, b) => b.updatedAt.getTime() - a.updatedAt.getTime() | ||||
|                 ) | ||||
|             ); | ||||
|         }; | ||||
|         if (!dialog.open) { | ||||
|             return; | ||||
|         } | ||||
|         setSelectedDiagramId(undefined); | ||||
|         fetchDiagrams(); | ||||
|     }, [listDiagrams, setDiagrams, dialog.open]); | ||||
|     }, [dialog.open, fetchDiagrams]); | ||||
|  | ||||
|     const openDiagram = useCallback( | ||||
|         (diagramId: string) => { | ||||
| @@ -166,6 +168,7 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({ | ||||
|                                             'open_diagram_dialog.table_columns.tables_count' | ||||
|                                         )} | ||||
|                                     </TableHead> | ||||
|                                     <TableHead /> | ||||
|                                 </TableRow> | ||||
|                             </TableHeader> | ||||
|                             <TableBody> | ||||
| @@ -221,6 +224,19 @@ export const OpenDiagramDialog: React.FC<OpenDiagramDialogProps> = ({ | ||||
|                                         <TableCell className="text-center"> | ||||
|                                             {diagram.tables?.length} | ||||
|                                         </TableCell> | ||||
|                                         <TableCell className="items-center p-0 pr-1 text-right"> | ||||
|                                             <DiagramRowActionsMenu | ||||
|                                                 diagram={diagram} | ||||
|                                                 onOpen={() => { | ||||
|                                                     openDiagram(diagram.id); | ||||
|                                                     closeOpenDiagramDialog(); | ||||
|                                                 }} | ||||
|                                                 numberOfDiagrams={ | ||||
|                                                     diagrams.length | ||||
|                                                 } | ||||
|                                                 refetch={fetchDiagrams} | ||||
|                                             /> | ||||
|                                         </TableCell> | ||||
|                                     </TableRow> | ||||
|                                 ))} | ||||
|                             </TableBody> | ||||
|   | ||||
							
								
								
									
										142
									
								
								src/hooks/use-focus-on.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										142
									
								
								src/hooks/use-focus-on.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,142 @@ | ||||
| import { useCallback } from 'react'; | ||||
| import { useReactFlow } from '@xyflow/react'; | ||||
| import { useLayout } from '@/hooks/use-layout'; | ||||
| import { useBreakpoint } from '@/hooks/use-breakpoint'; | ||||
|  | ||||
| interface FocusOptions { | ||||
|     select?: boolean; | ||||
| } | ||||
|  | ||||
| export const useFocusOn = () => { | ||||
|     const { fitView, setNodes, setEdges } = useReactFlow(); | ||||
|     const { hideSidePanel } = useLayout(); | ||||
|     const { isMd: isDesktop } = useBreakpoint('md'); | ||||
|  | ||||
|     const focusOnArea = useCallback( | ||||
|         (areaId: string, options: FocusOptions = {}) => { | ||||
|             const { select = true } = options; | ||||
|  | ||||
|             if (select) { | ||||
|                 setNodes((nodes) => | ||||
|                     nodes.map((node) => | ||||
|                         node.id === areaId | ||||
|                             ? { | ||||
|                                   ...node, | ||||
|                                   selected: true, | ||||
|                               } | ||||
|                             : { | ||||
|                                   ...node, | ||||
|                                   selected: false, | ||||
|                               } | ||||
|                     ) | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             fitView({ | ||||
|                 duration: 500, | ||||
|                 maxZoom: 1, | ||||
|                 minZoom: 1, | ||||
|                 nodes: [ | ||||
|                     { | ||||
|                         id: areaId, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             if (!isDesktop) { | ||||
|                 hideSidePanel(); | ||||
|             } | ||||
|         }, | ||||
|         [fitView, setNodes, hideSidePanel, isDesktop] | ||||
|     ); | ||||
|  | ||||
|     const focusOnTable = useCallback( | ||||
|         (tableId: string, options: FocusOptions = {}) => { | ||||
|             const { select = true } = options; | ||||
|  | ||||
|             if (select) { | ||||
|                 setNodes((nodes) => | ||||
|                     nodes.map((node) => | ||||
|                         node.id === tableId | ||||
|                             ? { | ||||
|                                   ...node, | ||||
|                                   selected: true, | ||||
|                               } | ||||
|                             : { | ||||
|                                   ...node, | ||||
|                                   selected: false, | ||||
|                               } | ||||
|                     ) | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             fitView({ | ||||
|                 duration: 500, | ||||
|                 maxZoom: 1, | ||||
|                 minZoom: 1, | ||||
|                 nodes: [ | ||||
|                     { | ||||
|                         id: tableId, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             if (!isDesktop) { | ||||
|                 hideSidePanel(); | ||||
|             } | ||||
|         }, | ||||
|         [fitView, setNodes, hideSidePanel, isDesktop] | ||||
|     ); | ||||
|  | ||||
|     const focusOnRelationship = useCallback( | ||||
|         ( | ||||
|             relationshipId: string, | ||||
|             sourceTableId: string, | ||||
|             targetTableId: string, | ||||
|             options: FocusOptions = {} | ||||
|         ) => { | ||||
|             const { select = true } = options; | ||||
|  | ||||
|             if (select) { | ||||
|                 setEdges((edges) => | ||||
|                     edges.map((edge) => | ||||
|                         edge.id === relationshipId | ||||
|                             ? { | ||||
|                                   ...edge, | ||||
|                                   selected: true, | ||||
|                               } | ||||
|                             : { | ||||
|                                   ...edge, | ||||
|                                   selected: false, | ||||
|                               } | ||||
|                     ) | ||||
|                 ); | ||||
|             } | ||||
|  | ||||
|             fitView({ | ||||
|                 duration: 500, | ||||
|                 maxZoom: 1, | ||||
|                 minZoom: 1, | ||||
|                 nodes: [ | ||||
|                     { | ||||
|                         id: sourceTableId, | ||||
|                     }, | ||||
|                     { | ||||
|                         id: targetTableId, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             if (!isDesktop) { | ||||
|                 hideSidePanel(); | ||||
|             } | ||||
|         }, | ||||
|         [fitView, setEdges, hideSidePanel, isDesktop] | ||||
|     ); | ||||
|  | ||||
|     return { | ||||
|         focusOnArea, | ||||
|         focusOnTable, | ||||
|         focusOnRelationship, | ||||
|     }; | ||||
| }; | ||||
| @@ -12,15 +12,15 @@ export const ar: LanguageTranslation = { | ||||
|             custom_types: 'الأنواع المخصصة', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'قواعد البيانات', | ||||
|                 new: 'مخطط جديد', | ||||
|             actions: { | ||||
|                 actions: 'الإجراءات', | ||||
|                 new: 'جديد...', | ||||
|                 browse: 'تصفح...', | ||||
|                 save: 'حفظ', | ||||
|                 import: 'استيراد قاعدة بيانات', | ||||
|                 export_sql: 'SQL تصدير', | ||||
|                 export_as: 'تصدير كـ', | ||||
|                 delete_diagram: 'حذف الرسم البياني', | ||||
|                 delete_diagram: 'حذف', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'تحرير', | ||||
| @@ -74,10 +74,10 @@ export const ar: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'إعادة ترتيب الرسم البياني', | ||||
|             title: 'ترتيب تلقائي للرسم البياني', | ||||
|             description: | ||||
|                 'هذا الإجراء سيقوم بإعادة ترتيب الجداول في المخطط بشكل تلقائي. هل تريد المتابعة؟', | ||||
|             reorder: 'إعادة ترتيب', | ||||
|             reorder: 'ترتيب تلقائي', | ||||
|             cancel: 'إلغاء', | ||||
|         }, | ||||
|  | ||||
| @@ -248,6 +248,7 @@ export const ar: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'لم يتم تحديد قيم التعداد', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const ar: LanguageTranslation = { | ||||
|             show_all: 'عرض الكل', | ||||
|             undo: 'تراجع', | ||||
|             redo: 'إعادة', | ||||
|             reorder_diagram: 'إعادة ترتيب الرسم البياني', | ||||
|             reorder_diagram: 'ترتيب تلقائي للرسم البياني', | ||||
|             highlight_overlapping_tables: 'تمييز الجداول المتداخلة', | ||||
|             // TODO: Translate | ||||
|             filter: 'Filter Tables', | ||||
| @@ -313,7 +314,7 @@ export const ar: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'فتح مخطط', | ||||
|             title: 'فتح قاعدة بيانات', | ||||
|             description: 'اختر مخططًا لفتحه من القائمة ادناه', | ||||
|             table_columns: { | ||||
|                 name: 'الإسم', | ||||
| @@ -323,6 +324,12 @@ export const ar: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'إلغاء', | ||||
|             open: 'فتح', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'فتح', | ||||
|                 duplicate: 'تكرار', | ||||
|                 delete: 'حذف', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const bn: LanguageTranslation = { | ||||
|             custom_types: 'কাস্টম টাইপ', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'ডাটাবেস', | ||||
|                 new: 'নতুন ডায়াগ্রাম', | ||||
|             actions: { | ||||
|                 actions: 'কার্য', | ||||
|                 new: 'নতুন...', | ||||
|                 browse: 'ব্রাউজ করুন...', | ||||
|                 save: 'সংরক্ষণ করুন', | ||||
|                 import: 'ডাটাবেস আমদানি করুন', | ||||
|                 export_sql: 'SQL রপ্তানি করুন', | ||||
|                 export_as: 'রূপে রপ্তানি করুন', | ||||
|                 delete_diagram: 'ডায়াগ্রাম মুছুন', | ||||
|                 delete_diagram: 'মুছুন', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'সম্পাদনা', | ||||
| @@ -75,10 +75,10 @@ export const bn: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'ডায়াগ্রাম পুনর্বিন্যাস করুন', | ||||
|             title: 'স্বয়ংক্রিয় ডায়াগ্রাম সাজান', | ||||
|             description: | ||||
|                 'এই কাজটি ডায়াগ্রামের সমস্ত টেবিল পুনর্বিন্যাস করবে। আপনি কি চালিয়ে যেতে চান?', | ||||
|             reorder: 'পুনর্বিন্যাস করুন', | ||||
|             reorder: 'স্বয়ংক্রিয় সাজান', | ||||
|             cancel: 'বাতিল করুন', | ||||
|         }, | ||||
|  | ||||
| @@ -249,6 +249,7 @@ export const bn: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'কোন enum মান সংজ্ঞায়িত নেই', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -271,7 +272,7 @@ export const bn: LanguageTranslation = { | ||||
|             show_all: 'সব দেখান', | ||||
|             undo: 'পূর্বাবস্থায় ফিরুন', | ||||
|             redo: 'পুনরায় করুন', | ||||
|             reorder_diagram: 'ডায়াগ্রাম পুনর্বিন্যাস করুন', | ||||
|             reorder_diagram: 'স্বয়ংক্রিয় ডায়াগ্রাম সাজান', | ||||
|             highlight_overlapping_tables: 'ওভারল্যাপিং টেবিল হাইলাইট করুন', | ||||
|  | ||||
|             // TODO: Translate | ||||
| @@ -315,7 +316,7 @@ export const bn: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'চিত্র খুলুন', | ||||
|             title: 'ডেটাবেস খুলুন', | ||||
|             description: 'নিচের তালিকা থেকে একটি চিত্র নির্বাচন করুন।', | ||||
|             table_columns: { | ||||
|                 name: 'নাম', | ||||
| @@ -325,6 +326,12 @@ export const bn: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'বাতিল করুন', | ||||
|             open: 'খুলুন', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'খুলুন', | ||||
|                 duplicate: 'ডুপ্লিকেট', | ||||
|                 delete: 'মুছুন', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const de: LanguageTranslation = { | ||||
|             custom_types: 'Benutzerdefinierte Typen', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Datenbanken', | ||||
|                 new: 'Neues Diagramm', | ||||
|             actions: { | ||||
|                 actions: 'Aktionen', | ||||
|                 new: 'Neu...', | ||||
|                 browse: 'Durchsuchen...', | ||||
|                 save: 'Speichern', | ||||
|                 import: 'Datenbank importieren', | ||||
|                 export_sql: 'SQL exportieren', | ||||
|                 export_as: 'Exportieren als', | ||||
|                 delete_diagram: 'Diagramm löschen', | ||||
|                 delete_diagram: 'Löschen', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Bearbeiten', | ||||
| @@ -75,10 +75,10 @@ export const de: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Diagramm neu anordnen', | ||||
|             title: 'Diagramm automatisch anordnen', | ||||
|             description: | ||||
|                 'Diese Aktion wird alle Tabellen im Diagramm neu anordnen. Möchten Sie fortfahren?', | ||||
|             reorder: 'Neu anordnen', | ||||
|             reorder: 'Automatisch anordnen', | ||||
|             cancel: 'Abbrechen', | ||||
|         }, | ||||
|  | ||||
| @@ -250,6 +250,7 @@ export const de: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Keine Enum-Werte definiert', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -272,7 +273,7 @@ export const de: LanguageTranslation = { | ||||
|             show_all: 'Alle anzeigen', | ||||
|             undo: 'Rückgängig', | ||||
|             redo: 'Wiederholen', | ||||
|             reorder_diagram: 'Diagramm neu anordnen', | ||||
|             reorder_diagram: 'Diagramm automatisch anordnen', | ||||
|  | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
| @@ -318,7 +319,7 @@ export const de: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Diagramm öffnen', | ||||
|             title: 'Datenbank öffnen', | ||||
|             description: 'Wählen Sie ein Diagramm aus der Liste unten aus.', | ||||
|             table_columns: { | ||||
|                 name: 'Name', | ||||
| @@ -328,6 +329,12 @@ export const de: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Abbrechen', | ||||
|             open: 'Öffnen', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Öffnen', | ||||
|                 duplicate: 'Duplizieren', | ||||
|                 delete: 'Löschen', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const en = { | ||||
|             custom_types: 'Custom Types', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Databases', | ||||
|                 new: 'New Diagram', | ||||
|             actions: { | ||||
|                 actions: 'Actions', | ||||
|                 new: 'New...', | ||||
|                 browse: 'Browse...', | ||||
|                 save: 'Save', | ||||
|                 import: 'Import', | ||||
|                 export_sql: 'Export SQL', | ||||
|                 export_as: 'Export as', | ||||
|                 delete_diagram: 'Delete Diagram', | ||||
|                 delete_diagram: 'Delete', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Edit', | ||||
| @@ -73,10 +73,10 @@ export const en = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Reorder Diagram', | ||||
|             title: 'Auto Arrange Diagram', | ||||
|             description: | ||||
|                 'This action will rearrange all tables in the diagram. Do you want to continue?', | ||||
|             reorder: 'Reorder', | ||||
|             reorder: 'Auto Arrange', | ||||
|             cancel: 'Cancel', | ||||
|         }, | ||||
|  | ||||
| @@ -242,6 +242,7 @@ export const en = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'No enum values defined', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -264,7 +265,7 @@ export const en = { | ||||
|             show_all: 'Show All', | ||||
|             undo: 'Undo', | ||||
|             redo: 'Redo', | ||||
|             reorder_diagram: 'Reorder Diagram', | ||||
|             reorder_diagram: 'Auto Arrange Diagram', | ||||
|             highlight_overlapping_tables: 'Highlight Overlapping Tables', | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -306,7 +307,7 @@ export const en = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Open Diagram', | ||||
|             title: 'Open Database', | ||||
|             description: 'Select a diagram to open from the list below.', | ||||
|             table_columns: { | ||||
|                 name: 'Name', | ||||
| @@ -316,6 +317,12 @@ export const en = { | ||||
|             }, | ||||
|             cancel: 'Cancel', | ||||
|             open: 'Open', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Open', | ||||
|                 duplicate: 'Duplicate', | ||||
|                 delete: 'Delete', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const es: LanguageTranslation = { | ||||
|             custom_types: 'Tipos Personalizados', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Bases de Datos', | ||||
|                 new: 'Nuevo Diagrama', | ||||
|             actions: { | ||||
|                 actions: 'Acciones', | ||||
|                 new: 'Nuevo...', | ||||
|                 browse: 'Examinar...', | ||||
|                 save: 'Guardar', | ||||
|                 import: 'Importar Base de Datos', | ||||
|                 export_sql: 'Exportar SQL', | ||||
|                 export_as: 'Exportar como', | ||||
|                 delete_diagram: 'Eliminar Diagrama', | ||||
|                 delete_diagram: 'Eliminar', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Editar', | ||||
| @@ -74,10 +74,10 @@ export const es: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Reordenar Diagrama', | ||||
|             title: 'Organizar Diagrama Automáticamente', | ||||
|             description: | ||||
|                 'Esta acción reorganizará todas las tablas en el diagrama. ¿Deseas continuar?', | ||||
|             reorder: 'Reordenar', | ||||
|             reorder: 'Organizar Automáticamente', | ||||
|             cancel: 'Cancelar', | ||||
|         }, | ||||
|  | ||||
| @@ -248,6 +248,7 @@ export const es: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'No hay valores de enum definidos', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const es: LanguageTranslation = { | ||||
|             show_all: 'Mostrar Todo', | ||||
|             undo: 'Deshacer', | ||||
|             redo: 'Rehacer', | ||||
|             reorder_diagram: 'Reordenar Diagrama', | ||||
|             reorder_diagram: 'Organizar Diagrama Automáticamente', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -315,7 +316,7 @@ export const es: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Abrir Diagrama', | ||||
|             title: 'Abrir Base de Datos', | ||||
|             description: | ||||
|                 'Selecciona un diagrama para abrir de la lista a continuación.', | ||||
|             table_columns: { | ||||
| @@ -326,6 +327,12 @@ export const es: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Cancelar', | ||||
|             open: 'Abrir', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Abrir', | ||||
|                 duplicate: 'Duplicar', | ||||
|                 delete: 'Eliminar', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const fr: LanguageTranslation = { | ||||
|             custom_types: 'Types Personnalisés', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Bases de Données', | ||||
|                 new: 'Nouveau Diagramme', | ||||
|             actions: { | ||||
|                 actions: 'Actions', | ||||
|                 new: 'Nouveau...', | ||||
|                 browse: 'Parcourir...', | ||||
|                 save: 'Enregistrer', | ||||
|                 import: 'Importer Base de Données', | ||||
|                 export_sql: 'Exporter SQL', | ||||
|                 export_as: 'Exporter en tant que', | ||||
|                 delete_diagram: 'Supprimer le Diagramme', | ||||
|                 delete_diagram: 'Supprimer', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Édition', | ||||
| @@ -73,10 +73,10 @@ export const fr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Réorganiser le Diagramme', | ||||
|             title: 'Organiser Automatiquement le Diagramme', | ||||
|             description: | ||||
|                 'Cette action réorganisera toutes les tables dans le diagramme. Voulez-vous continuer ?', | ||||
|             reorder: 'Réorganiser', | ||||
|             reorder: 'Organiser Automatiquement', | ||||
|             cancel: 'Annuler', | ||||
|         }, | ||||
|  | ||||
| @@ -246,6 +246,7 @@ export const fr: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: "Aucune valeur d'énumération définie", | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -268,7 +269,7 @@ export const fr: LanguageTranslation = { | ||||
|             show_all: 'Afficher Tout', | ||||
|             undo: 'Annuler', | ||||
|             redo: 'Rétablir', | ||||
|             reorder_diagram: 'Réorganiser le Diagramme', | ||||
|             reorder_diagram: 'Organiser Automatiquement le Diagramme', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -312,7 +313,7 @@ export const fr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Ouvrir Diagramme', | ||||
|             title: 'Ouvrir Base de Données', | ||||
|             description: | ||||
|                 'Sélectionnez un diagramme à ouvrir dans la liste ci-dessous.', | ||||
|             table_columns: { | ||||
| @@ -323,6 +324,12 @@ export const fr: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Annuler', | ||||
|             open: 'Ouvrir', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Ouvrir', | ||||
|                 duplicate: 'Dupliquer', | ||||
|                 delete: 'Supprimer', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const gu: LanguageTranslation = { | ||||
|             custom_types: 'કસ્ટમ ટાઇપ', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'ડેટાબેસેસ', | ||||
|                 new: 'નવું ડાયાગ્રામ', | ||||
|             actions: { | ||||
|                 actions: 'ક્રિયાઓ', | ||||
|                 new: 'નવું...', | ||||
|                 browse: 'બ્રાઉજ કરો...', | ||||
|                 save: 'સાચવો', | ||||
|                 import: 'ડેટાબેસ આયાત કરો', | ||||
|                 export_sql: 'SQL નિકાસ કરો', | ||||
|                 export_as: 'રૂપે નિકાસ કરો', | ||||
|                 delete_diagram: 'ડાયાગ્રામ કાઢી નાખો', | ||||
|                 delete_diagram: 'કાઢી નાખો', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'ફેરફાર', | ||||
| @@ -75,10 +75,10 @@ export const gu: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'ડાયાગ્રામ ફરી વ્યવસ્થિત કરો', | ||||
|             title: 'ડાયાગ્રામ ઑટોમેટિક ગોઠવો', | ||||
|             description: | ||||
|                 'આ ક્રિયા ડાયાગ્રામમાં બધી ટેબલ્સને ફરીથી વ્યવસ્થિત કરશે. શું તમે ચાલુ રાખવા માંગો છો?', | ||||
|             reorder: 'ફરી વ્યવસ્થિત કરો', | ||||
|             reorder: 'ઑટોમેટિક ગોઠવો', | ||||
|             cancel: 'રદ કરો', | ||||
|         }, | ||||
|  | ||||
| @@ -250,6 +250,7 @@ export const gu: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'કોઈ enum મૂલ્યો વ્યાખ્યાયિત નથી', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -272,7 +273,7 @@ export const gu: LanguageTranslation = { | ||||
|             show_all: 'બધું બતાવો', | ||||
|             undo: 'અનડુ', | ||||
|             redo: 'રીડુ', | ||||
|             reorder_diagram: 'ડાયાગ્રામ ફરીથી વ્યવસ્થિત કરો', | ||||
|             reorder_diagram: 'ડાયાગ્રામ ઑટોમેટિક ગોઠવો', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -315,7 +316,7 @@ export const gu: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'ડાયાગ્રામ ખોલો', | ||||
|             title: 'ડેટાબેસ ખોલો', | ||||
|             description: 'નીચેની યાદીમાંથી એક ડાયાગ્રામ પસંદ કરો.', | ||||
|             table_columns: { | ||||
|                 name: 'નામ', | ||||
| @@ -325,6 +326,12 @@ export const gu: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'રદ કરો', | ||||
|             open: 'ખોલો', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'ખોલો', | ||||
|                 duplicate: 'ડુપ્લિકેટ', | ||||
|                 delete: 'કાઢી નાખો', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const hi: LanguageTranslation = { | ||||
|             custom_types: 'कस्टम टाइप', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'डेटाबेस', | ||||
|                 new: 'नया आरेख', | ||||
|             actions: { | ||||
|                 actions: 'कार्य', | ||||
|                 new: 'नया...', | ||||
|                 browse: 'ब्राउज़ करें...', | ||||
|                 save: 'सहेजें', | ||||
|                 import: 'डेटाबेस आयात करें', | ||||
|                 export_sql: 'SQL निर्यात करें', | ||||
|                 export_as: 'के रूप में निर्यात करें', | ||||
|                 delete_diagram: 'आरेख हटाएँ', | ||||
|                 delete_diagram: 'हटाएँ', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'संपादित करें', | ||||
| @@ -74,10 +74,10 @@ export const hi: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'आरेख पुनः व्यवस्थित करें', | ||||
|             title: 'आरेख स्वचालित व्यवस्थित करें', | ||||
|             description: | ||||
|                 'यह क्रिया आरेख में सभी तालिकाओं को पुनः व्यवस्थित कर देगी। क्या आप जारी रखना चाहते हैं?', | ||||
|             reorder: 'पुनः व्यवस्थित करें', | ||||
|             reorder: 'स्वचालित व्यवस्थित करें', | ||||
|             cancel: 'रद्द करें', | ||||
|         }, | ||||
|  | ||||
| @@ -249,6 +249,7 @@ export const hi: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'कोई enum मान परिभाषित नहीं', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -271,7 +272,7 @@ export const hi: LanguageTranslation = { | ||||
|             show_all: 'सभी दिखाएँ', | ||||
|             undo: 'पूर्ववत करें', | ||||
|             redo: 'पुनः करें', | ||||
|             reorder_diagram: 'आरेख पुनः व्यवस्थित करें', | ||||
|             reorder_diagram: 'आरेख स्वचालित व्यवस्थित करें', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -317,7 +318,7 @@ export const hi: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'आरेख खोलें', | ||||
|             title: 'डेटाबेस खोलें', | ||||
|             description: 'नीचे दी गई सूची से एक आरेख चुनें।', | ||||
|             table_columns: { | ||||
|                 name: 'नाम', | ||||
| @@ -327,6 +328,12 @@ export const hi: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'रद्द करें', | ||||
|             open: 'खोलें', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'खोलें', | ||||
|                 duplicate: 'डुप्लिकेट', | ||||
|                 delete: 'हटाएं', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const hr: LanguageTranslation = { | ||||
|             custom_types: 'Prilagođeni Tipovi', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Baze Podataka', | ||||
|                 new: 'Novi Dijagram', | ||||
|             actions: { | ||||
|                 actions: 'Akcije', | ||||
|                 new: 'Novi...', | ||||
|                 browse: 'Pregledaj...', | ||||
|                 save: 'Spremi', | ||||
|                 import: 'Uvezi', | ||||
|                 export_sql: 'Izvezi SQL', | ||||
|                 export_as: 'Izvezi kao', | ||||
|                 delete_diagram: 'Izbriši dijagram', | ||||
|                 delete_diagram: 'Izbriši', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Uredi', | ||||
| @@ -73,10 +73,10 @@ export const hr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Preuredi dijagram', | ||||
|             title: 'Automatski preuredi dijagram', | ||||
|             description: | ||||
|                 'Ova radnja će preurediti sve tablice u dijagramu. Želite li nastaviti?', | ||||
|             reorder: 'Preuredi', | ||||
|             reorder: 'Automatski preuredi', | ||||
|             cancel: 'Odustani', | ||||
|         }, | ||||
|  | ||||
| @@ -245,6 +245,7 @@ export const hr: LanguageTranslation = { | ||||
|                     enum_values: 'Enum vrijednosti', | ||||
|                     composite_fields: 'Polja', | ||||
|                     no_fields: 'Nema definiranih polja', | ||||
|                     no_values: 'Nema definiranih enum vrijednosti', | ||||
|                     field_name_placeholder: 'Naziv polja', | ||||
|                     field_type_placeholder: 'Odaberi tip', | ||||
|                     add_field: 'Dodaj polje', | ||||
| @@ -268,7 +269,7 @@ export const hr: LanguageTranslation = { | ||||
|             show_all: 'Prikaži sve', | ||||
|             undo: 'Poništi', | ||||
|             redo: 'Ponovi', | ||||
|             reorder_diagram: 'Preuredi dijagram', | ||||
|             reorder_diagram: 'Automatski preuredi dijagram', | ||||
|             highlight_overlapping_tables: 'Istakni preklapajuće tablice', | ||||
|             clear_custom_type_highlight: 'Ukloni isticanje za "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -310,7 +311,7 @@ export const hr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Otvori dijagram', | ||||
|             title: 'Otvori bazu podataka', | ||||
|             description: 'Odaberite dijagram za otvaranje iz popisa ispod.', | ||||
|             table_columns: { | ||||
|                 name: 'Naziv', | ||||
| @@ -320,6 +321,12 @@ export const hr: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Odustani', | ||||
|             open: 'Otvori', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Otvori', | ||||
|                 duplicate: 'Dupliciraj', | ||||
|                 delete: 'Obriši', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const id_ID: LanguageTranslation = { | ||||
|             custom_types: 'Tipe Kustom', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Basis Data', | ||||
|                 new: 'Diagram Baru', | ||||
|             actions: { | ||||
|                 actions: 'Aksi', | ||||
|                 new: 'Baru...', | ||||
|                 browse: 'Jelajahi...', | ||||
|                 save: 'Simpan', | ||||
|                 import: 'Impor Database', | ||||
|                 export_sql: 'Ekspor SQL', | ||||
|                 export_as: 'Ekspor Sebagai', | ||||
|                 delete_diagram: 'Hapus Diagram', | ||||
|                 delete_diagram: 'Hapus', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Ubah', | ||||
| @@ -74,10 +74,10 @@ export const id_ID: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Atur Ulang Diagram', | ||||
|             title: 'Atur Otomatis Diagram', | ||||
|             description: | ||||
|                 'Tindakan ini akan mengatur ulang semua tabel di diagram. Apakah Anda ingin melanjutkan?', | ||||
|             reorder: 'Atur Ulang', | ||||
|             reorder: 'Atur Otomatis', | ||||
|             cancel: 'Batal', | ||||
|         }, | ||||
|  | ||||
| @@ -248,6 +248,7 @@ export const id_ID: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Tidak ada nilai enum yang ditentukan', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const id_ID: LanguageTranslation = { | ||||
|             show_all: 'Tampilkan Semua', | ||||
|             undo: 'Undo', | ||||
|             redo: 'Redo', | ||||
|             reorder_diagram: 'Atur Ulang Diagram', | ||||
|             reorder_diagram: 'Atur Otomatis Diagram', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -314,7 +315,7 @@ export const id_ID: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Buka Diagram', | ||||
|             title: 'Buka Database', | ||||
|             description: 'Pilih diagram untuk dibuka dari daftar di bawah.', | ||||
|             table_columns: { | ||||
|                 name: 'Name', | ||||
| @@ -324,6 +325,12 @@ export const id_ID: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Batal', | ||||
|             open: 'Buka', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Buka', | ||||
|                 duplicate: 'Duplikat', | ||||
|                 delete: 'Hapus', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const ja: LanguageTranslation = { | ||||
|             custom_types: 'カスタムタイプ', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'データベース', | ||||
|                 new: '新しいダイアグラム', | ||||
|             actions: { | ||||
|                 actions: 'アクション', | ||||
|                 new: '新規...', | ||||
|                 browse: '参照...', | ||||
|                 save: '保存', | ||||
|                 import: 'データベースをインポート', | ||||
|                 export_sql: 'SQLをエクスポート', | ||||
|                 export_as: '形式を指定してエクスポート', | ||||
|                 delete_diagram: 'ダイアグラムを削除', | ||||
|                 delete_diagram: '削除', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: '編集', | ||||
| @@ -76,10 +76,10 @@ export const ja: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'ダイアグラムを並べ替え', | ||||
|             title: 'ダイアグラムを自動配置', | ||||
|             description: | ||||
|                 'この操作によりダイアグラム内のすべてのテーブルが再配置されます。続行しますか?', | ||||
|             reorder: '並べ替え', | ||||
|             reorder: '自動配置', | ||||
|             cancel: 'キャンセル', | ||||
|         }, | ||||
|  | ||||
| @@ -253,6 +253,7 @@ export const ja: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: '列挙値が定義されていません', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -275,7 +276,7 @@ export const ja: LanguageTranslation = { | ||||
|             show_all: 'すべて表示', | ||||
|             undo: '元に戻す', | ||||
|             redo: 'やり直し', | ||||
|             reorder_diagram: 'ダイアグラムを並べ替え', | ||||
|             reorder_diagram: 'ダイアグラムを自動配置', | ||||
|             // TODO: Translate | ||||
|             highlight_overlapping_tables: 'Highlight Overlapping Tables', | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
| @@ -319,7 +320,7 @@ export const ja: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'ダイアグラムを開く', | ||||
|             title: 'データベースを開く', | ||||
|             description: '以下のリストからダイアグラムを選択してください。', | ||||
|             table_columns: { | ||||
|                 name: '名前', | ||||
| @@ -329,6 +330,12 @@ export const ja: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'キャンセル', | ||||
|             open: '開く', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: '開く', | ||||
|                 duplicate: '複製', | ||||
|                 delete: '削除', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const ko_KR: LanguageTranslation = { | ||||
|             custom_types: '사용자 지정 타입', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: '데이터베이스', | ||||
|                 new: '새 다이어그램', | ||||
|             actions: { | ||||
|                 actions: '작업', | ||||
|                 new: '새로 만들기...', | ||||
|                 browse: '찾아보기...', | ||||
|                 save: '저장', | ||||
|                 import: '데이터베이스 가져오기', | ||||
|                 export_sql: 'SQL로 저장', | ||||
|                 export_as: '다른 형식으로 저장', | ||||
|                 delete_diagram: '다이어그램 삭제', | ||||
|                 delete_diagram: '삭제', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: '편집', | ||||
| @@ -74,10 +74,10 @@ export const ko_KR: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: '다이어그램 재정렬', | ||||
|             title: '다이어그램 자동 정렬', | ||||
|             description: | ||||
|                 '이 작업은 모든 다이어그램이 재정렬됩니다. 계속하시겠습니까?', | ||||
|             reorder: '재정렬', | ||||
|             reorder: '자동 정렬', | ||||
|             cancel: '취소', | ||||
|         }, | ||||
|  | ||||
| @@ -248,6 +248,7 @@ export const ko_KR: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: '정의된 열거형 값이 없습니다', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const ko_KR: LanguageTranslation = { | ||||
|             show_all: '전체 저장', | ||||
|             undo: '실행 취소', | ||||
|             redo: '다시 실행', | ||||
|             reorder_diagram: '다이어그램 재정렬', | ||||
|             reorder_diagram: '다이어그램 자동 정렬', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -314,7 +315,7 @@ export const ko_KR: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: '다이어그램 열기', | ||||
|             title: '데이터베이스 열기', | ||||
|             description: '아래의 목록에서 다이어그램을 선택하세요.', | ||||
|             table_columns: { | ||||
|                 name: '이름', | ||||
| @@ -324,6 +325,12 @@ export const ko_KR: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: '취소', | ||||
|             open: '열기', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: '열기', | ||||
|                 duplicate: '복제', | ||||
|                 delete: '삭제', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const mr: LanguageTranslation = { | ||||
|             custom_types: 'कस्टम प्रकार', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'डेटाबेस', | ||||
|                 new: 'नवीन आरेख', | ||||
|             actions: { | ||||
|                 actions: 'क्रिया', | ||||
|                 new: 'नवीन...', | ||||
|                 browse: 'ब्राउज करा...', | ||||
|                 save: 'जतन करा', | ||||
|                 import: 'डेटाबेस इम्पोर्ट करा', | ||||
|                 export_sql: 'SQL एक्स्पोर्ट करा', | ||||
|                 export_as: 'म्हणून एक्स्पोर्ट करा', | ||||
|                 delete_diagram: 'आरेख हटवा', | ||||
|                 delete_diagram: 'हटवा', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'संपादन करा', | ||||
| @@ -75,10 +75,10 @@ export const mr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'आरेख पुनःक्रमित करा', | ||||
|             title: 'आरेख स्वयंचलित व्यवस्थित करा', | ||||
|             description: | ||||
|                 'ही क्रिया आरेखातील सर्व टेबल्सची पुनर्रचना करेल. तुम्हाला पुढे जायचे आहे का?', | ||||
|             reorder: 'पुनःक्रमित करा', | ||||
|             reorder: 'स्वयंचलित व्यवस्थित करा', | ||||
|             cancel: 'रद्द करा', | ||||
|         }, | ||||
|  | ||||
| @@ -252,6 +252,7 @@ export const mr: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'कोणतीही enum मूल्ये परिभाषित नाहीत', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -274,7 +275,7 @@ export const mr: LanguageTranslation = { | ||||
|             show_all: 'सर्व दाखवा', | ||||
|             undo: 'पूर्ववत करा', | ||||
|             redo: 'पुन्हा करा', | ||||
|             reorder_diagram: 'आरेख पुनःक्रमित करा', | ||||
|             reorder_diagram: 'आरेख स्वयंचलित व्यवस्थित करा', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -320,7 +321,7 @@ export const mr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'आरेख उघडा', | ||||
|             title: 'डेटाबेस उघडा', | ||||
|             description: 'खालील यादीतून उघडण्यासाठी एक आरेख निवडा.', | ||||
|             table_columns: { | ||||
|                 name: 'नाव', | ||||
| @@ -330,6 +331,12 @@ export const mr: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'रद्द करा', | ||||
|             open: 'उघडा', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'उघडा', | ||||
|                 duplicate: 'डुप्लिकेट', | ||||
|                 delete: 'हटवा', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const ne: LanguageTranslation = { | ||||
|             custom_types: 'कस्टम प्रकारहरू', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'डाटाबेसहरू', | ||||
|                 new: 'नयाँ डायाग्राम', | ||||
|             actions: { | ||||
|                 actions: 'कार्यहरू', | ||||
|                 new: 'नयाँ...', | ||||
|                 browse: 'ब्राउज गर्नुहोस्...', | ||||
|                 save: 'सुरक्षित गर्नुहोस्', | ||||
|                 import: 'डाटाबेस आयात गर्नुहोस्', | ||||
|                 export_sql: 'SQL निर्यात गर्नुहोस्', | ||||
|                 export_as: 'निर्यात गर्नुहोस्', | ||||
|                 delete_diagram: 'डायाग्राम हटाउनुहोस्', | ||||
|                 delete_diagram: 'हटाउनुहोस्', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'सम्पादन', | ||||
| @@ -75,10 +75,10 @@ export const ne: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'डायाग्राम पुनः क्रमबद्ध गर्नुहोस्', | ||||
|             title: 'डायाग्राम स्वचालित मिलाउनुहोस्', | ||||
|             description: | ||||
|                 'यो कार्य पूर्ववत गर्न सकिँदैन। यो डायाग्राम स्थायी रूपमा हटाउनेछ।', | ||||
|             reorder: 'पुनः क्रमबद्ध गर्नुहोस्', | ||||
|             reorder: 'स्वचालित मिलाउनुहोस्', | ||||
|             cancel: 'रद्द गर्नुहोस्', | ||||
|         }, | ||||
|  | ||||
| @@ -249,6 +249,7 @@ export const ne: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'कुनै enum मानहरू परिभाषित छैनन्', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -271,7 +272,7 @@ export const ne: LanguageTranslation = { | ||||
|             show_all: 'सबै देखाउनुहोस्', | ||||
|             undo: 'पूर्ववत', | ||||
|             redo: 'पुनः गर्नुहोस्', | ||||
|             reorder_diagram: 'पुनः क्रमबद्ध गर्नुहोस्', | ||||
|             reorder_diagram: 'डायाग्राम स्वचालित मिलाउनुहोस्', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -316,7 +317,7 @@ export const ne: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'डायाग्राम खोल्नुहोस्', | ||||
|             title: 'डाटाबेस खोल्नुहोस्', | ||||
|             description: | ||||
|                 'तलको सूचीबाट खोल्नका लागि एक डायाग्राम चयन गर्नुहोस्।', | ||||
|             table_columns: { | ||||
| @@ -327,6 +328,12 @@ export const ne: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'रद्द गर्नुहोस्', | ||||
|             open: 'खोल्नुहोस्', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'खोल्नुहोस्', | ||||
|                 duplicate: 'डुप्लिकेट', | ||||
|                 delete: 'मेटाउनुहोस्', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const pt_BR: LanguageTranslation = { | ||||
|             custom_types: 'Tipos Personalizados', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Bancos de Dados', | ||||
|                 new: 'Novo Diagrama', | ||||
|             actions: { | ||||
|                 actions: 'Ações', | ||||
|                 new: 'Novo...', | ||||
|                 browse: 'Navegar...', | ||||
|                 save: 'Salvar', | ||||
|                 import: 'Importar Banco de Dados', | ||||
|                 export_sql: 'Exportar SQL', | ||||
|                 export_as: 'Exportar como', | ||||
|                 delete_diagram: 'Excluir Diagrama', | ||||
|                 delete_diagram: 'Excluir', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Editar', | ||||
| @@ -75,10 +75,10 @@ export const pt_BR: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Reordenar Diagrama', | ||||
|             title: 'Organizar Diagrama Automaticamente', | ||||
|             description: | ||||
|                 'Esta ação reorganizará todas as tabelas no diagrama. Deseja continuar?', | ||||
|             reorder: 'Reordenar', | ||||
|             reorder: 'Organizar Automaticamente', | ||||
|             cancel: 'Cancelar', | ||||
|         }, | ||||
|  | ||||
| @@ -249,6 +249,7 @@ export const pt_BR: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Nenhum valor de enum definido', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -271,7 +272,7 @@ export const pt_BR: LanguageTranslation = { | ||||
|             show_all: 'Mostrar Tudo', | ||||
|             undo: 'Desfazer', | ||||
|             redo: 'Refazer', | ||||
|             reorder_diagram: 'Reordenar Diagrama', | ||||
|             reorder_diagram: 'Organizar Diagrama Automaticamente', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -316,7 +317,7 @@ export const pt_BR: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Abrir Diagrama', | ||||
|             title: 'Abrir Banco de Dados', | ||||
|             description: 'Selecione um diagrama para abrir da lista abaixo.', | ||||
|             table_columns: { | ||||
|                 name: 'Nome', | ||||
| @@ -326,6 +327,12 @@ export const pt_BR: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Cancelar', | ||||
|             open: 'Abrir', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Abrir', | ||||
|                 duplicate: 'Duplicar', | ||||
|                 delete: 'Excluir', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const ru: LanguageTranslation = { | ||||
|             custom_types: 'Пользовательские типы', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Базы данных', | ||||
|                 new: 'Новая диаграмма', | ||||
|             actions: { | ||||
|                 actions: 'Действия', | ||||
|                 new: 'Новая...', | ||||
|                 browse: 'Обзор...', | ||||
|                 save: 'Сохранить', | ||||
|                 import: 'Импортировать базу данных', | ||||
|                 export_sql: 'Экспорт SQL', | ||||
|                 export_as: 'Экспортировать как', | ||||
|                 delete_diagram: 'Удалить диаграмму', | ||||
|                 delete_diagram: 'Удалить', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Изменение', | ||||
| @@ -73,10 +73,10 @@ export const ru: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Переупорядочить диаграмму', | ||||
|             title: 'Автоматическая расстановка диаграммы', | ||||
|             description: | ||||
|                 'Это действие переставит все таблицы на диаграмме. Хотите продолжить?', | ||||
|             reorder: 'Изменить порядок', | ||||
|             reorder: 'Автоматическая расстановка', | ||||
|             cancel: 'Отменить', | ||||
|         }, | ||||
|  | ||||
| @@ -246,6 +246,7 @@ export const ru: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Значения перечисления не определены', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -268,7 +269,7 @@ export const ru: LanguageTranslation = { | ||||
|             show_all: 'Показать все', | ||||
|             undo: 'Отменить', | ||||
|             redo: 'Вернуть', | ||||
|             reorder_diagram: 'Переупорядочить диаграмму', | ||||
|             reorder_diagram: 'Автоматическая расстановка диаграммы', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -312,7 +313,7 @@ export const ru: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Открыть диаграмму', | ||||
|             title: 'Открыть базу данных', | ||||
|             description: | ||||
|                 'Выберите диаграмму, которую нужно открыть, из списка ниже.', | ||||
|             table_columns: { | ||||
| @@ -323,6 +324,12 @@ export const ru: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Отмена', | ||||
|             open: 'Открыть', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Открыть', | ||||
|                 duplicate: 'Дублировать', | ||||
|                 delete: 'Удалить', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const te: LanguageTranslation = { | ||||
|             custom_types: 'కస్టమ్ టైప్స్', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'డేటాబేస్లు', | ||||
|                 new: 'కొత్త డైగ్రాం', | ||||
|             actions: { | ||||
|                 actions: 'చర్యలు', | ||||
|                 new: 'కొత్తది...', | ||||
|                 browse: 'బ్రాఉజ్ చేయండి...', | ||||
|                 save: 'సేవ్', | ||||
|                 import: 'డేటాబేస్ను దిగుమతి చేసుకోండి', | ||||
|                 export_sql: 'SQL ఎగుమతి', | ||||
|                 export_as: 'వగా ఎగుమతి చేయండి', | ||||
|                 delete_diagram: 'చిత్రాన్ని తొలగించండి', | ||||
|                 delete_diagram: 'తొలగించండి', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'సవరించు', | ||||
| @@ -75,10 +75,10 @@ export const te: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'చిత్రాన్ని పునఃసరిచేయండి', | ||||
|             title: 'చిత్రాన్ని స్వయంచాలకంగా అమర్చండి', | ||||
|             description: | ||||
|                 'ఈ చర్య చిత్రంలోని అన్ని పట్టికలను పునఃస్థాపిస్తుంది. మీరు కొనసాగించాలనుకుంటున్నారా?', | ||||
|             reorder: 'పునఃసరిచేయండి', | ||||
|             reorder: 'స్వయంచాలకంగా అమర్చండి', | ||||
|             cancel: 'రద్దు', | ||||
|         }, | ||||
|  | ||||
| @@ -250,6 +250,7 @@ export const te: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'ఏ enum విలువలు నిర్వచించబడలేదు', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -272,7 +273,7 @@ export const te: LanguageTranslation = { | ||||
|             show_all: 'అన్ని చూపించు', | ||||
|             undo: 'తిరిగి చేయు', | ||||
|             redo: 'మరలా చేయు', | ||||
|             reorder_diagram: 'చిత్రాన్ని పునఃసరిచేయండి', | ||||
|             reorder_diagram: 'చిత్రాన్ని స్వయంచాలకంగా అమర్చండి', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -317,7 +318,7 @@ export const te: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'చిత్రం తెరవండి', | ||||
|             title: 'డేటాబేస్ తెరవండి', | ||||
|             description: 'కింద ఉన్న జాబితా నుండి చిత్రాన్ని ఎంచుకోండి.', | ||||
|             table_columns: { | ||||
|                 name: 'పేరు', | ||||
| @@ -327,6 +328,12 @@ export const te: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'రద్దు', | ||||
|             open: 'తెరవు', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'తెరవు', | ||||
|                 duplicate: 'నకలు', | ||||
|                 delete: 'తొలగించు', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const tr: LanguageTranslation = { | ||||
|             custom_types: 'Özel Tipler', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Veritabanları', | ||||
|                 new: 'Yeni Diyagram', | ||||
|             actions: { | ||||
|                 actions: 'Eylemler', | ||||
|                 new: 'Yeni...', | ||||
|                 browse: 'Gözat...', | ||||
|                 save: 'Kaydet', | ||||
|                 import: 'Veritabanı İçe Aktar', | ||||
|                 export_sql: 'SQL Olarak Dışa Aktar', | ||||
|                 export_as: 'Olarak Dışa Aktar', | ||||
|                 delete_diagram: 'Diyagramı Sil', | ||||
|                 delete_diagram: 'Sil', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Düzenle', | ||||
| @@ -75,10 +75,10 @@ export const tr: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Diyagramı Yeniden Sırala', | ||||
|             title: 'Diyagramı Otomatik Düzenle', | ||||
|             description: | ||||
|                 'Bu işlem tüm tabloları yeniden düzenleyecektir. Devam etmek istiyor musunuz?', | ||||
|             reorder: 'Yeniden Sırala', | ||||
|             reorder: 'Otomatik Düzenle', | ||||
|             cancel: 'İptal', | ||||
|         }, | ||||
|  | ||||
| @@ -249,6 +249,7 @@ export const tr: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Tanımlanmış enum değeri yok', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const tr: LanguageTranslation = { | ||||
|             show_all: 'Hepsini Gör', | ||||
|             undo: 'Geri Al', | ||||
|             redo: 'Yinele', | ||||
|             reorder_diagram: 'Diyagramı Yeniden Sırala', | ||||
|             reorder_diagram: 'Diyagramı Otomatik Düzenle', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -312,7 +313,7 @@ export const tr: LanguageTranslation = { | ||||
|             import: 'İçe Aktar', | ||||
|         }, | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Diyagramı Aç', | ||||
|             title: 'Veritabanı Aç', | ||||
|             description: 'Aşağıdaki listeden açmak için bir diyagram seçin.', | ||||
|             table_columns: { | ||||
|                 name: 'Ad', | ||||
| @@ -322,6 +323,12 @@ export const tr: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'İptal', | ||||
|             open: 'Aç', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Aç', | ||||
|                 duplicate: 'Kopyala', | ||||
|                 delete: 'Sil', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const uk: LanguageTranslation = { | ||||
|             custom_types: 'Користувацькі типи', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Бази даних', | ||||
|                 new: 'Нова діаграма', | ||||
|             actions: { | ||||
|                 actions: 'Дії', | ||||
|                 new: 'Нова...', | ||||
|                 browse: 'Огляд...', | ||||
|                 save: 'Зберегти', | ||||
|                 import: 'Імпорт бази даних', | ||||
|                 export_sql: 'Експорт SQL', | ||||
|                 export_as: 'Експортувати як', | ||||
|                 delete_diagram: 'Видалити діаграму', | ||||
|                 delete_diagram: 'Видалити', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Редагувати', | ||||
| @@ -73,10 +73,10 @@ export const uk: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Перевпорядкувати діаграму', | ||||
|             title: 'Автоматичне розміщення діаграми', | ||||
|             description: | ||||
|                 'Ця дія перевпорядкує всі таблиці на діаграмі. Хочете продовжити?', | ||||
|             reorder: 'Перевпорядкувати', | ||||
|             reorder: 'Автоматичне розміщення', | ||||
|             cancel: 'Скасувати', | ||||
|         }, | ||||
|  | ||||
| @@ -247,6 +247,7 @@ export const uk: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Значення переліку не визначені', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -269,7 +270,7 @@ export const uk: LanguageTranslation = { | ||||
|             show_all: 'Показати все', | ||||
|             undo: 'Скасувати', | ||||
|             redo: 'Повторити', | ||||
|             reorder_diagram: 'Перевпорядкувати діаграму', | ||||
|             reorder_diagram: 'Автоматичне розміщення діаграми', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -313,7 +314,7 @@ export const uk: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Відкрити діаграму', | ||||
|             title: 'Відкрити базу даних', | ||||
|             description: | ||||
|                 'Виберіть діаграму, яку потрібно відкрити, зі списку нижче.', | ||||
|             table_columns: { | ||||
| @@ -324,6 +325,12 @@ export const uk: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Скасувати', | ||||
|             open: 'Відкрити', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Відкрити', | ||||
|                 duplicate: 'Дублювати', | ||||
|                 delete: 'Видалити', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const vi: LanguageTranslation = { | ||||
|             custom_types: 'Kiểu tùy chỉnh', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: 'Cơ sở dữ liệu', | ||||
|                 new: 'Sơ đồ mới', | ||||
|             actions: { | ||||
|                 actions: 'Hành động', | ||||
|                 new: 'Mới...', | ||||
|                 browse: 'Duyệt...', | ||||
|                 save: 'Lưu', | ||||
|                 import: 'Nhập cơ sở dữ liệu', | ||||
|                 export_sql: 'Xuất SQL', | ||||
|                 export_as: 'Xuất thành', | ||||
|                 delete_diagram: 'Xóa sơ đồ', | ||||
|                 delete_diagram: 'Xóa', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: 'Sửa', | ||||
| @@ -74,10 +74,10 @@ export const vi: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: 'Sắp xếp lại sơ đồ', | ||||
|             title: 'Tự động sắp xếp sơ đồ', | ||||
|             description: | ||||
|                 'Hành động này sẽ sắp xếp lại tất cả các bảng trong sơ đồ. Bạn có muốn tiếp tục không?', | ||||
|             reorder: 'Sắp xếp', | ||||
|             reorder: 'Tự động sắp xếp', | ||||
|             cancel: 'Hủy', | ||||
|         }, | ||||
|  | ||||
| @@ -248,6 +248,7 @@ export const vi: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: 'Không có giá trị enum được định nghĩa', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -270,7 +271,7 @@ export const vi: LanguageTranslation = { | ||||
|             show_all: 'Hiển thị tất cả', | ||||
|             undo: 'Hoàn tác', | ||||
|             redo: 'Làm lại', | ||||
|             reorder_diagram: 'Sắp xếp lại sơ đồ', | ||||
|             reorder_diagram: 'Tự động sắp xếp sơ đồ', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -314,7 +315,7 @@ export const vi: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: 'Mở sơ đồ', | ||||
|             title: 'Mở cơ sở dữ liệu', | ||||
|             description: 'Chọn sơ đồ để mở từ danh sách bên dưới.', | ||||
|             table_columns: { | ||||
|                 name: 'Tên', | ||||
| @@ -324,6 +325,12 @@ export const vi: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: 'Hủy', | ||||
|             open: 'Mở', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: 'Mở', | ||||
|                 duplicate: 'Nhân bản', | ||||
|                 delete: 'Xóa', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const zh_CN: LanguageTranslation = { | ||||
|             custom_types: '自定义类型', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: '数据库', | ||||
|                 new: '新建关系图', | ||||
|             actions: { | ||||
|                 actions: '操作', | ||||
|                 new: '新建...', | ||||
|                 browse: '浏览...', | ||||
|                 save: '保存', | ||||
|                 import: '导入数据库', | ||||
|                 export_sql: '导出 SQL 语句', | ||||
|                 export_as: '导出为', | ||||
|                 delete_diagram: '删除关系图', | ||||
|                 delete_diagram: '删除', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: '编辑', | ||||
| @@ -72,9 +72,9 @@ export const zh_CN: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: '重新排列关系图', | ||||
|             title: '自动排列关系图', | ||||
|             description: '此操作将重新排列关系图中的所有表。是否要继续?', | ||||
|             reorder: '重新排列', | ||||
|             reorder: '自动排列', | ||||
|             cancel: '取消', | ||||
|         }, | ||||
|  | ||||
| @@ -245,6 +245,7 @@ export const zh_CN: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: '没有定义枚举值', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -267,7 +268,7 @@ export const zh_CN: LanguageTranslation = { | ||||
|             show_all: '展示全部', | ||||
|             undo: '撤销', | ||||
|             redo: '重做', | ||||
|             reorder_diagram: '重新排列关系图', | ||||
|             reorder_diagram: '自动排列关系图', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -311,7 +312,7 @@ export const zh_CN: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: '打开关系图', | ||||
|             title: '打开数据库', | ||||
|             description: '从下面的列表中选择一个图表打开。', | ||||
|             table_columns: { | ||||
|                 name: '名称', | ||||
| @@ -321,6 +322,12 @@ export const zh_CN: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: '取消', | ||||
|             open: '打开', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: '打开', | ||||
|                 duplicate: '复制', | ||||
|                 delete: '删除', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
| @@ -12,15 +12,15 @@ export const zh_TW: LanguageTranslation = { | ||||
|             custom_types: '自定義類型', | ||||
|         }, | ||||
|         menu: { | ||||
|             databases: { | ||||
|                 databases: '資料庫', | ||||
|                 new: '新增圖表', | ||||
|             actions: { | ||||
|                 actions: '操作', | ||||
|                 new: '新增...', | ||||
|                 browse: '瀏覽...', | ||||
|                 save: '儲存', | ||||
|                 import: '匯入資料庫', | ||||
|                 export_sql: '匯出 SQL', | ||||
|                 export_as: '匯出為特定格式', | ||||
|                 delete_diagram: '刪除圖表', | ||||
|                 delete_diagram: '刪除', | ||||
|             }, | ||||
|             edit: { | ||||
|                 edit: '編輯', | ||||
| @@ -72,9 +72,9 @@ export const zh_TW: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         reorder_diagram_alert: { | ||||
|             title: '重新排列圖表', | ||||
|             title: '自動排列圖表', | ||||
|             description: '此操作將重新排列圖表中的所有表格。是否繼續?', | ||||
|             reorder: '重新排列', | ||||
|             reorder: '自動排列', | ||||
|             cancel: '取消', | ||||
|         }, | ||||
|  | ||||
| @@ -245,6 +245,7 @@ export const zh_TW: LanguageTranslation = { | ||||
|                     enum_values: 'Enum Values', | ||||
|                     composite_fields: 'Fields', | ||||
|                     no_fields: 'No fields defined', | ||||
|                     no_values: '沒有定義列舉值', | ||||
|                     field_name_placeholder: 'Field name', | ||||
|                     field_type_placeholder: 'Select type', | ||||
|                     add_field: 'Add Field', | ||||
| @@ -267,7 +268,7 @@ export const zh_TW: LanguageTranslation = { | ||||
|             show_all: '顯示全部', | ||||
|             undo: '復原', | ||||
|             redo: '重做', | ||||
|             reorder_diagram: '重新排列圖表', | ||||
|             reorder_diagram: '自動排列圖表', | ||||
|             // TODO: Translate | ||||
|             clear_custom_type_highlight: 'Clear highlight for "{{typeName}}"', | ||||
|             custom_type_highlight_tooltip: | ||||
| @@ -310,7 +311,7 @@ export const zh_TW: LanguageTranslation = { | ||||
|         }, | ||||
|  | ||||
|         open_diagram_dialog: { | ||||
|             title: '開啟圖表', | ||||
|             title: '開啟資料庫', | ||||
|             description: '請從以下列表中選擇一個圖表。', | ||||
|             table_columns: { | ||||
|                 name: '名稱', | ||||
| @@ -320,6 +321,12 @@ export const zh_TW: LanguageTranslation = { | ||||
|             }, | ||||
|             cancel: '取消', | ||||
|             open: '開啟', | ||||
|  | ||||
|             diagram_actions: { | ||||
|                 open: '開啟', | ||||
|                 duplicate: '複製', | ||||
|                 delete: '刪除', | ||||
|             }, | ||||
|         }, | ||||
|  | ||||
|         export_sql_dialog: { | ||||
|   | ||||
							
								
								
									
										21
									
								
								src/lib/data/import-metadata/import/custom-types.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										21
									
								
								src/lib/data/import-metadata/import/custom-types.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,21 @@ | ||||
| import type { DBCustomType, DBCustomTypeKind } from '@/lib/domain'; | ||||
| import { schemaNameToDomainSchemaName } from '@/lib/domain'; | ||||
| import type { DBCustomTypeInfo } from '../metadata-types/custom-type-info'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
|  | ||||
| export const createCustomTypesFromMetadata = ({ | ||||
|     customTypes, | ||||
| }: { | ||||
|     customTypes: DBCustomTypeInfo[]; | ||||
| }): DBCustomType[] => { | ||||
|     return customTypes.map((customType) => { | ||||
|         return { | ||||
|             id: generateId(), | ||||
|             schema: schemaNameToDomainSchemaName(customType.schema), | ||||
|             name: customType.type, | ||||
|             kind: customType.kind as DBCustomTypeKind, | ||||
|             values: customType.values, | ||||
|             fields: customType.fields, | ||||
|         }; | ||||
|     }); | ||||
| }; | ||||
							
								
								
									
										351
									
								
								src/lib/data/import-metadata/import/dependencies.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										351
									
								
								src/lib/data/import-metadata/import/dependencies.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,351 @@ | ||||
| import { generateId } from '@/lib/utils'; | ||||
| import type { AST } from 'node-sql-parser'; | ||||
| import type { DBDependency, DBTable } from '@/lib/domain'; | ||||
| import { DatabaseType, schemaNameToDomainSchemaName } from '@/lib/domain'; | ||||
| import type { ViewInfo } from '../metadata-types/view-info'; | ||||
| import { decodeViewDefinition } from './tables'; | ||||
|  | ||||
| const astDatabaseTypes: Record<DatabaseType, string> = { | ||||
|     [DatabaseType.POSTGRESQL]: 'postgresql', | ||||
|     [DatabaseType.MYSQL]: 'postgresql', | ||||
|     [DatabaseType.MARIADB]: 'postgresql', | ||||
|     [DatabaseType.GENERIC]: 'postgresql', | ||||
|     [DatabaseType.SQLITE]: 'postgresql', | ||||
|     [DatabaseType.SQL_SERVER]: 'postgresql', | ||||
|     [DatabaseType.CLICKHOUSE]: 'postgresql', | ||||
|     [DatabaseType.COCKROACHDB]: 'postgresql', | ||||
|     [DatabaseType.ORACLE]: 'postgresql', | ||||
| }; | ||||
|  | ||||
| export const createDependenciesFromMetadata = async ({ | ||||
|     views, | ||||
|     tables, | ||||
|     databaseType, | ||||
| }: { | ||||
|     views: ViewInfo[]; | ||||
|     tables: DBTable[]; | ||||
|     databaseType: DatabaseType; | ||||
| }): Promise<DBDependency[]> => { | ||||
|     if (!views || views.length === 0) { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     const { Parser } = await import('node-sql-parser'); | ||||
|     const parser = new Parser(); | ||||
|  | ||||
|     const dependencies = views | ||||
|         .flatMap((view) => { | ||||
|             const viewSchema = schemaNameToDomainSchemaName(view.schema); | ||||
|             const viewTable = tables.find( | ||||
|                 (table) => | ||||
|                     table.name === view.view_name && viewSchema === table.schema | ||||
|             ); | ||||
|  | ||||
|             if (!viewTable) { | ||||
|                 console.warn( | ||||
|                     `Source table for view ${view.view_name} not found (schema: ${viewSchema})` | ||||
|                 ); | ||||
|                 return []; // Skip this view and proceed to the next | ||||
|             } | ||||
|  | ||||
|             if (view.view_definition) { | ||||
|                 try { | ||||
|                     const decodedViewDefinition = decodeViewDefinition( | ||||
|                         databaseType, | ||||
|                         view.view_definition | ||||
|                     ); | ||||
|  | ||||
|                     let modifiedViewDefinition = ''; | ||||
|                     if ( | ||||
|                         databaseType === DatabaseType.MYSQL || | ||||
|                         databaseType === DatabaseType.MARIADB | ||||
|                     ) { | ||||
|                         modifiedViewDefinition = preprocessViewDefinitionMySQL( | ||||
|                             decodedViewDefinition | ||||
|                         ); | ||||
|                     } else if (databaseType === DatabaseType.SQL_SERVER) { | ||||
|                         modifiedViewDefinition = | ||||
|                             preprocessViewDefinitionSQLServer( | ||||
|                                 decodedViewDefinition | ||||
|                             ); | ||||
|                     } else { | ||||
|                         modifiedViewDefinition = preprocessViewDefinition( | ||||
|                             decodedViewDefinition | ||||
|                         ); | ||||
|                     } | ||||
|  | ||||
|                     // Parse using the appropriate dialect | ||||
|                     const ast = parser.astify(modifiedViewDefinition, { | ||||
|                         database: astDatabaseTypes[databaseType], | ||||
|                         type: 'select', // Parsing a SELECT statement | ||||
|                     }); | ||||
|  | ||||
|                     let relatedTables = extractTablesFromAST(ast); | ||||
|  | ||||
|                     // Filter out duplicate tables without schema | ||||
|                     relatedTables = filterDuplicateTables(relatedTables); | ||||
|  | ||||
|                     return relatedTables.map((relTable) => { | ||||
|                         const relSchema = relTable.schema || view.schema; // Use view's schema if relSchema is undefined | ||||
|                         const relTableName = relTable.tableName; | ||||
|  | ||||
|                         const table = tables.find( | ||||
|                             (table) => | ||||
|                                 table.name === relTableName && | ||||
|                                 (table.schema || '') === relSchema | ||||
|                         ); | ||||
|  | ||||
|                         if (table) { | ||||
|                             const dependency: DBDependency = { | ||||
|                                 id: generateId(), | ||||
|                                 schema: view.schema, | ||||
|                                 tableId: table.id, // related table | ||||
|                                 dependentSchema: table.schema, | ||||
|                                 dependentTableId: viewTable.id, // dependent view | ||||
|                                 createdAt: Date.now(), | ||||
|                             }; | ||||
|  | ||||
|                             return dependency; | ||||
|                         } else { | ||||
|                             console.warn( | ||||
|                                 `Dependent table ${relSchema}.${relTableName} not found for view ${view.schema}.${view.view_name}` | ||||
|                             ); | ||||
|                             return null; | ||||
|                         } | ||||
|                     }); | ||||
|                 } catch (error) { | ||||
|                     console.error( | ||||
|                         `Error parsing view ${view.schema}.${view.view_name}:`, | ||||
|                         error | ||||
|                     ); | ||||
|                     return []; | ||||
|                 } | ||||
|             } else { | ||||
|                 console.warn( | ||||
|                     `View definition missing for ${view.schema}.${view.view_name}` | ||||
|                 ); | ||||
|                 return []; | ||||
|             } | ||||
|         }) | ||||
|         .filter((dependency) => dependency !== null); | ||||
|  | ||||
|     return dependencies; | ||||
| }; | ||||
|  | ||||
| // Add this new function to filter out duplicate tables | ||||
| function filterDuplicateTables( | ||||
|     tables: { schema?: string; tableName: string }[] | ||||
| ): { schema?: string; tableName: string }[] { | ||||
|     const tableMap = new Map<string, { schema?: string; tableName: string }>(); | ||||
|  | ||||
|     for (const table of tables) { | ||||
|         const key = table.tableName; | ||||
|         const existingTable = tableMap.get(key); | ||||
|  | ||||
|         if (!existingTable || (table.schema && !existingTable.schema)) { | ||||
|             tableMap.set(key, table); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return Array.from(tableMap.values()); | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition to remove schema from CREATE VIEW | ||||
| function preprocessViewDefinition(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove leading and trailing whitespace | ||||
|     viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim(); | ||||
|  | ||||
|     // Replace escaped double quotes with regular ones | ||||
|     viewDefinition = viewDefinition.replace(/\\"/g, '"'); | ||||
|  | ||||
|     // Replace 'CREATE MATERIALIZED VIEW' with 'CREATE VIEW' | ||||
|     viewDefinition = viewDefinition.replace( | ||||
|         /CREATE\s+MATERIALIZED\s+VIEW/i, | ||||
|         'CREATE VIEW' | ||||
|     ); | ||||
|  | ||||
|     // Regular expression to match 'CREATE VIEW [schema.]view_name [ (column definitions) ] AS' | ||||
|     // This regex captures the view name and skips any content between the view name and 'AS' | ||||
|     const regex = | ||||
|         /CREATE\s+VIEW\s+(?:(?:`[^`]+`|"[^"]+"|\w+)\.)?(?:`([^`]+)`|"([^"]+)"|(\w+))[\s\S]*?\bAS\b\s+/i; | ||||
|  | ||||
|     const match = viewDefinition.match(regex); | ||||
|     let modifiedDefinition: string; | ||||
|  | ||||
|     if (match) { | ||||
|         const viewName = match[1] || match[2] || match[3]; | ||||
|         // Extract the SQL after the 'AS' keyword | ||||
|         const restOfDefinition = viewDefinition.substring( | ||||
|             match.index! + match[0].length | ||||
|         ); | ||||
|  | ||||
|         // Replace double-quoted identifiers with unquoted ones | ||||
|         let modifiedSQL = restOfDefinition.replace(/"(\w+)"/g, '$1'); | ||||
|  | ||||
|         // Replace '::' type casts with 'CAST' expressions | ||||
|         modifiedSQL = modifiedSQL.replace( | ||||
|             /\(([^()]+)\)::(\w+)/g, | ||||
|             'CAST($1 AS $2)' | ||||
|         ); | ||||
|  | ||||
|         // Remove ClickHouse-specific syntax that may still be present | ||||
|         // For example, remove SETTINGS clauses inside the SELECT statement | ||||
|         modifiedSQL = modifiedSQL.replace(/\bSETTINGS\b[\s\S]*$/i, ''); | ||||
|  | ||||
|         modifiedDefinition = `CREATE VIEW ${viewName} AS ${modifiedSQL}`; | ||||
|     } else { | ||||
|         console.warn('Could not preprocess view definition:', viewDefinition); | ||||
|         modifiedDefinition = viewDefinition; | ||||
|     } | ||||
|  | ||||
|     return modifiedDefinition; | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition for SQL Server | ||||
| function preprocessViewDefinitionSQLServer(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove BOM if present | ||||
|     viewDefinition = viewDefinition.replace(/^\uFEFF/, ''); | ||||
|  | ||||
|     // Normalize whitespace | ||||
|     viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim(); | ||||
|  | ||||
|     // Remove square brackets and replace with double quotes | ||||
|     viewDefinition = viewDefinition.replace(/\[([^\]]+)\]/g, '"$1"'); | ||||
|  | ||||
|     // Remove database names from fully qualified identifiers | ||||
|     viewDefinition = viewDefinition.replace( | ||||
|         /"([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"/g, | ||||
|         '"$2"."$3"' | ||||
|     ); | ||||
|  | ||||
|     // Replace SQL Server functions with PostgreSQL equivalents | ||||
|     viewDefinition = viewDefinition.replace(/\bGETDATE\(\)/gi, 'NOW()'); | ||||
|     viewDefinition = viewDefinition.replace(/\bISNULL\(/gi, 'COALESCE('); | ||||
|  | ||||
|     // Replace 'TOP N' with 'LIMIT N' at the end of the query | ||||
|     const topMatch = viewDefinition.match(/SELECT\s+TOP\s+(\d+)/i); | ||||
|     if (topMatch) { | ||||
|         const topN = topMatch[1]; | ||||
|         viewDefinition = viewDefinition.replace( | ||||
|             /SELECT\s+TOP\s+\d+/i, | ||||
|             'SELECT' | ||||
|         ); | ||||
|         viewDefinition = viewDefinition.replace(/;+\s*$/, ''); // Remove semicolons at the end | ||||
|         viewDefinition += ` LIMIT ${topN}`; | ||||
|     } | ||||
|  | ||||
|     viewDefinition = viewDefinition.replace(/\n/g, ''); // Remove newlines | ||||
|  | ||||
|     // Adjust CREATE VIEW syntax | ||||
|     const regex = | ||||
|         /CREATE\s+VIEW\s+(?:"?([^".\s]+)"?\.)?"?([^".\s]+)"?\s+AS\s+/i; | ||||
|     const match = viewDefinition.match(regex); | ||||
|     let modifiedDefinition: string; | ||||
|  | ||||
|     if (match) { | ||||
|         const viewName = match[2]; | ||||
|         const modifiedSQL = viewDefinition.substring( | ||||
|             match.index! + match[0].length | ||||
|         ); | ||||
|  | ||||
|         // Remove semicolons at the end | ||||
|         const finalSQL = modifiedSQL.replace(/;+\s*$/, ''); | ||||
|  | ||||
|         modifiedDefinition = `CREATE VIEW "${viewName}" AS ${finalSQL}`; | ||||
|     } else { | ||||
|         console.warn('Could not preprocess view definition:', viewDefinition); | ||||
|         modifiedDefinition = viewDefinition; | ||||
|     } | ||||
|  | ||||
|     return modifiedDefinition; | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition to remove schema from CREATE VIEW | ||||
| function preprocessViewDefinitionMySQL(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove any trailing semicolons | ||||
|     viewDefinition = viewDefinition.replace(/;\s*$/, ''); | ||||
|  | ||||
|     // Remove backticks from identifiers | ||||
|     viewDefinition = viewDefinition.replace(/`/g, ''); | ||||
|  | ||||
|     // Remove unnecessary parentheses around joins and ON clauses | ||||
|     viewDefinition = removeRedundantParentheses(viewDefinition); | ||||
|  | ||||
|     return viewDefinition; | ||||
| } | ||||
|  | ||||
| function removeRedundantParentheses(sql: string): string { | ||||
|     // Regular expressions to match unnecessary parentheses | ||||
|     const patterns = [ | ||||
|         /\(\s*(JOIN\s+[^()]+?)\s*\)/gi, | ||||
|         /\(\s*(ON\s+[^()]+?)\s*\)/gi, | ||||
|         // Additional patterns if necessary | ||||
|     ]; | ||||
|  | ||||
|     let prevSql; | ||||
|     do { | ||||
|         prevSql = sql; | ||||
|         patterns.forEach((pattern) => { | ||||
|             sql = sql.replace(pattern, '$1'); | ||||
|         }); | ||||
|     } while (sql !== prevSql); | ||||
|  | ||||
|     return sql; | ||||
| } | ||||
|  | ||||
| function extractTablesFromAST( | ||||
|     ast: AST | AST[] | ||||
| ): { schema?: string; tableName: string }[] { | ||||
|     const tablesMap = new Map<string, { schema: string; tableName: string }>(); | ||||
|     const visitedNodes = new Set(); | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     function traverse(node: any) { | ||||
|         if (!node || visitedNodes.has(node)) return; | ||||
|         visitedNodes.add(node); | ||||
|  | ||||
|         if (Array.isArray(node)) { | ||||
|             node.forEach(traverse); | ||||
|         } else if (typeof node === 'object') { | ||||
|             // Check if node represents a table | ||||
|             if ( | ||||
|                 Object.hasOwnProperty.call(node, 'table') && | ||||
|                 typeof node.table === 'string' | ||||
|             ) { | ||||
|                 let schema = node.db || node.schema; | ||||
|                 const tableName = node.table; | ||||
|                 if (tableName) { | ||||
|                     // Assign default schema if undefined | ||||
|                     schema = schemaNameToDomainSchemaName(schema) || ''; | ||||
|                     const key = `${schema}.${tableName}`; | ||||
|                     if (!tablesMap.has(key)) { | ||||
|                         tablesMap.set(key, { schema, tableName }); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Recursively traverse all properties | ||||
|             for (const key in node) { | ||||
|                 if (Object.hasOwnProperty.call(node, key)) { | ||||
|                     traverse(node[key]); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     traverse(ast); | ||||
|  | ||||
|     return Array.from(tablesMap.values()); | ||||
| } | ||||
							
								
								
									
										64
									
								
								src/lib/data/import-metadata/import/fields.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										64
									
								
								src/lib/data/import-metadata/import/fields.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,64 @@ | ||||
| import type { DBField } from '@/lib/domain'; | ||||
| import type { ColumnInfo } from '../metadata-types/column-info'; | ||||
| import type { AggregatedIndexInfo } from '../metadata-types/index-info'; | ||||
| import type { PrimaryKeyInfo } from '../metadata-types/primary-key-info'; | ||||
| import type { TableInfo } from '../metadata-types/table-info'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
|  | ||||
| export const createFieldsFromMetadata = ({ | ||||
|     tableColumns, | ||||
|     tablePrimaryKeys, | ||||
|     aggregatedIndexes, | ||||
| }: { | ||||
|     tableColumns: ColumnInfo[]; | ||||
|     tableSchema?: string; | ||||
|     tableInfo: TableInfo; | ||||
|     tablePrimaryKeys: PrimaryKeyInfo[]; | ||||
|     aggregatedIndexes: AggregatedIndexInfo[]; | ||||
| }) => { | ||||
|     const uniqueColumns = tableColumns.reduce((acc, col) => { | ||||
|         if (!acc.has(col.name)) { | ||||
|             acc.set(col.name, col); | ||||
|         } | ||||
|         return acc; | ||||
|     }, new Map<string, ColumnInfo>()); | ||||
|  | ||||
|     const sortedColumns = Array.from(uniqueColumns.values()).sort( | ||||
|         (a, b) => a.ordinal_position - b.ordinal_position | ||||
|     ); | ||||
|  | ||||
|     const tablePrimaryKeysColumns = tablePrimaryKeys.map((pk) => | ||||
|         pk.column.trim() | ||||
|     ); | ||||
|  | ||||
|     return sortedColumns.map( | ||||
|         (col: ColumnInfo): DBField => ({ | ||||
|             id: generateId(), | ||||
|             name: col.name, | ||||
|             type: { | ||||
|                 id: col.type.split(' ').join('_').toLowerCase(), | ||||
|                 name: col.type.toLowerCase(), | ||||
|             }, | ||||
|             primaryKey: tablePrimaryKeysColumns.includes(col.name), | ||||
|             unique: Object.values(aggregatedIndexes).some( | ||||
|                 (idx) => | ||||
|                     idx.unique && | ||||
|                     idx.columns.length === 1 && | ||||
|                     idx.columns[0].name === col.name | ||||
|             ), | ||||
|             nullable: Boolean(col.nullable), | ||||
|             ...(col.character_maximum_length && | ||||
|             col.character_maximum_length !== 'null' | ||||
|                 ? { characterMaximumLength: col.character_maximum_length } | ||||
|                 : {}), | ||||
|             ...(col.precision?.precision | ||||
|                 ? { precision: col.precision.precision } | ||||
|                 : {}), | ||||
|             ...(col.precision?.scale ? { scale: col.precision.scale } : {}), | ||||
|             ...(col.default ? { default: col.default } : {}), | ||||
|             ...(col.collation ? { collation: col.collation } : {}), | ||||
|             createdAt: Date.now(), | ||||
|             comments: col.comment ? col.comment : undefined, | ||||
|         }) | ||||
|     ); | ||||
| }; | ||||
							
								
								
									
										82
									
								
								src/lib/data/import-metadata/import/index.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										82
									
								
								src/lib/data/import-metadata/import/index.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,82 @@ | ||||
| import type { DatabaseEdition, Diagram } from '@/lib/domain'; | ||||
| import { adjustTablePositions, DatabaseType } from '@/lib/domain'; | ||||
| import { generateDiagramId } from '@/lib/utils'; | ||||
| import type { DatabaseMetadata } from '../metadata-types/database-metadata'; | ||||
| import { createCustomTypesFromMetadata } from './custom-types'; | ||||
| import { createRelationshipsFromMetadata } from './relationships'; | ||||
| import { createTablesFromMetadata } from './tables'; | ||||
| import { createDependenciesFromMetadata } from './dependencies'; | ||||
|  | ||||
| export const loadFromDatabaseMetadata = async ({ | ||||
|     databaseType, | ||||
|     databaseMetadata, | ||||
|     diagramNumber, | ||||
|     databaseEdition, | ||||
| }: { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseMetadata: DatabaseMetadata; | ||||
|     diagramNumber?: number; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
| }): Promise<Diagram> => { | ||||
|     const { | ||||
|         fk_info: foreignKeys, | ||||
|         views: views, | ||||
|         custom_types: customTypes, | ||||
|     } = databaseMetadata; | ||||
|  | ||||
|     const tables = createTablesFromMetadata({ | ||||
|         databaseMetadata, | ||||
|         databaseType, | ||||
|     }); | ||||
|  | ||||
|     const relationships = createRelationshipsFromMetadata({ | ||||
|         foreignKeys, | ||||
|         tables, | ||||
|     }); | ||||
|  | ||||
|     const dependencies = await createDependenciesFromMetadata({ | ||||
|         views, | ||||
|         tables, | ||||
|         databaseType, | ||||
|     }); | ||||
|  | ||||
|     const dbCustomTypes = customTypes | ||||
|         ? createCustomTypesFromMetadata({ | ||||
|               customTypes, | ||||
|           }) | ||||
|         : []; | ||||
|  | ||||
|     const adjustedTables = adjustTablePositions({ | ||||
|         tables, | ||||
|         relationships, | ||||
|         mode: 'perSchema', | ||||
|     }); | ||||
|  | ||||
|     const sortedTables = adjustedTables.sort((a, b) => { | ||||
|         if (a.isView === b.isView) { | ||||
|             // Both are either tables or views, so sort alphabetically by name | ||||
|             return a.name.localeCompare(b.name); | ||||
|         } | ||||
|         // If one is a view and the other is not, put tables first | ||||
|         return a.isView ? 1 : -1; | ||||
|     }); | ||||
|  | ||||
|     const diagram: Diagram = { | ||||
|         id: generateDiagramId(), | ||||
|         name: databaseMetadata.database_name | ||||
|             ? `${databaseMetadata.database_name}-db` | ||||
|             : diagramNumber | ||||
|               ? `Diagram ${diagramNumber}` | ||||
|               : 'New Diagram', | ||||
|         databaseType: databaseType ?? DatabaseType.GENERIC, | ||||
|         databaseEdition, | ||||
|         tables: sortedTables, | ||||
|         relationships, | ||||
|         dependencies, | ||||
|         customTypes: dbCustomTypes, | ||||
|         createdAt: new Date(), | ||||
|         updatedAt: new Date(), | ||||
|     }; | ||||
|  | ||||
|     return diagram; | ||||
| }; | ||||
							
								
								
									
										24
									
								
								src/lib/data/import-metadata/import/indexes.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										24
									
								
								src/lib/data/import-metadata/import/indexes.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,24 @@ | ||||
| import type { DBField, DBIndex, IndexType } from '@/lib/domain'; | ||||
| import type { AggregatedIndexInfo } from '../metadata-types/index-info'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
|  | ||||
| export const createIndexesFromMetadata = ({ | ||||
|     aggregatedIndexes, | ||||
|     fields, | ||||
| }: { | ||||
|     aggregatedIndexes: AggregatedIndexInfo[]; | ||||
|     fields: DBField[]; | ||||
| }): DBIndex[] => | ||||
|     aggregatedIndexes.map( | ||||
|         (idx): DBIndex => ({ | ||||
|             id: generateId(), | ||||
|             name: idx.name, | ||||
|             unique: Boolean(idx.unique), | ||||
|             fieldIds: idx.columns | ||||
|                 .sort((a, b) => a.position - b.position) | ||||
|                 .map((c) => fields.find((f) => f.name === c.name)?.id) | ||||
|                 .filter((id): id is string => id !== undefined), | ||||
|             createdAt: Date.now(), | ||||
|             type: idx.index_type?.toLowerCase() as IndexType, | ||||
|         }) | ||||
|     ); | ||||
							
								
								
									
										85
									
								
								src/lib/data/import-metadata/import/relationships.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										85
									
								
								src/lib/data/import-metadata/import/relationships.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,85 @@ | ||||
| import type { | ||||
|     Cardinality, | ||||
|     DBField, | ||||
|     DBRelationship, | ||||
|     DBTable, | ||||
| } from '@/lib/domain'; | ||||
| import { schemaNameToDomainSchemaName } from '@/lib/domain'; | ||||
| import type { ForeignKeyInfo } from '../metadata-types/foreign-key-info'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
|  | ||||
| const determineCardinality = ( | ||||
|     field: DBField, | ||||
|     isTablePKComplex: boolean | ||||
| ): Cardinality => { | ||||
|     return field.unique || (field.primaryKey && !isTablePKComplex) | ||||
|         ? 'one' | ||||
|         : 'many'; | ||||
| }; | ||||
|  | ||||
| export const createRelationshipsFromMetadata = ({ | ||||
|     foreignKeys, | ||||
|     tables, | ||||
| }: { | ||||
|     foreignKeys: ForeignKeyInfo[]; | ||||
|     tables: DBTable[]; | ||||
| }): DBRelationship[] => { | ||||
|     return foreignKeys | ||||
|         .map((fk: ForeignKeyInfo): DBRelationship | null => { | ||||
|             const schema = schemaNameToDomainSchemaName(fk.schema); | ||||
|             const sourceTable = tables.find( | ||||
|                 (table) => table.name === fk.table && table.schema === schema | ||||
|             ); | ||||
|  | ||||
|             const targetSchema = schemaNameToDomainSchemaName( | ||||
|                 fk.reference_schema | ||||
|             ); | ||||
|  | ||||
|             const targetTable = tables.find( | ||||
|                 (table) => | ||||
|                     table.name === fk.reference_table && | ||||
|                     table.schema === targetSchema | ||||
|             ); | ||||
|             const sourceField = sourceTable?.fields.find( | ||||
|                 (field) => field.name === fk.column | ||||
|             ); | ||||
|             const targetField = targetTable?.fields.find( | ||||
|                 (field) => field.name === fk.reference_column | ||||
|             ); | ||||
|  | ||||
|             const isSourceTablePKComplex = | ||||
|                 (sourceTable?.fields.filter((field) => field.primaryKey) ?? []) | ||||
|                     .length > 1; | ||||
|             const isTargetTablePKComplex = | ||||
|                 (targetTable?.fields.filter((field) => field.primaryKey) ?? []) | ||||
|                     .length > 1; | ||||
|  | ||||
|             if (sourceTable && targetTable && sourceField && targetField) { | ||||
|                 const sourceCardinality = determineCardinality( | ||||
|                     sourceField, | ||||
|                     isSourceTablePKComplex | ||||
|                 ); | ||||
|                 const targetCardinality = determineCardinality( | ||||
|                     targetField, | ||||
|                     isTargetTablePKComplex | ||||
|                 ); | ||||
|  | ||||
|                 return { | ||||
|                     id: generateId(), | ||||
|                     name: fk.foreign_key_name, | ||||
|                     sourceSchema: schema, | ||||
|                     targetSchema: targetSchema, | ||||
|                     sourceTableId: sourceTable.id, | ||||
|                     targetTableId: targetTable.id, | ||||
|                     sourceFieldId: sourceField.id, | ||||
|                     targetFieldId: targetField.id, | ||||
|                     sourceCardinality, | ||||
|                     targetCardinality, | ||||
|                     createdAt: Date.now(), | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         }) | ||||
|         .filter((rel) => rel !== null) as DBRelationship[]; | ||||
| }; | ||||
							
								
								
									
										228
									
								
								src/lib/data/import-metadata/import/tables.ts
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										228
									
								
								src/lib/data/import-metadata/import/tables.ts
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,228 @@ | ||||
| import type { DBIndex, DBTable } from '@/lib/domain'; | ||||
| import { | ||||
|     DatabaseType, | ||||
|     generateTableKey, | ||||
|     schemaNameToDomainSchemaName, | ||||
| } from '@/lib/domain'; | ||||
| import type { DatabaseMetadata } from '../metadata-types/database-metadata'; | ||||
| import type { TableInfo } from '../metadata-types/table-info'; | ||||
| import { createAggregatedIndexes } from '../metadata-types/index-info'; | ||||
| import { | ||||
|     decodeBase64ToUtf16LE, | ||||
|     decodeBase64ToUtf8, | ||||
|     generateId, | ||||
| } from '@/lib/utils'; | ||||
| import { | ||||
|     defaultTableColor, | ||||
|     materializedViewColor, | ||||
|     viewColor, | ||||
| } from '@/lib/colors'; | ||||
| import { createFieldsFromMetadata } from './fields'; | ||||
| import { createIndexesFromMetadata } from './indexes'; | ||||
|  | ||||
| export const decodeViewDefinition = ( | ||||
|     databaseType: DatabaseType, | ||||
|     viewDefinition?: string | ||||
| ): string => { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     let decodedViewDefinition: string; | ||||
|     if (databaseType === DatabaseType.SQL_SERVER) { | ||||
|         decodedViewDefinition = decodeBase64ToUtf16LE(viewDefinition); | ||||
|     } else { | ||||
|         decodedViewDefinition = decodeBase64ToUtf8(viewDefinition); | ||||
|     } | ||||
|  | ||||
|     return decodedViewDefinition; | ||||
| }; | ||||
|  | ||||
| export const createTablesFromMetadata = ({ | ||||
|     databaseMetadata, | ||||
|     databaseType, | ||||
| }: { | ||||
|     databaseMetadata: DatabaseMetadata; | ||||
|     databaseType: DatabaseType; | ||||
| }): DBTable[] => { | ||||
|     const { | ||||
|         tables: tableInfos, | ||||
|         pk_info: primaryKeys, | ||||
|         columns, | ||||
|         indexes, | ||||
|         views: views, | ||||
|     } = databaseMetadata; | ||||
|  | ||||
|     // Pre-compute view names for faster lookup if there are views | ||||
|     const viewNamesSet = new Set<string>(); | ||||
|     const materializedViewNamesSet = new Set<string>(); | ||||
|  | ||||
|     if (views && views.length > 0) { | ||||
|         views.forEach((view) => { | ||||
|             const key = generateTableKey({ | ||||
|                 schemaName: view.schema, | ||||
|                 tableName: view.view_name, | ||||
|             }); | ||||
|             viewNamesSet.add(key); | ||||
|  | ||||
|             if ( | ||||
|                 view.view_definition && | ||||
|                 decodeViewDefinition(databaseType, view.view_definition) | ||||
|                     .toLowerCase() | ||||
|                     .includes('materialized') | ||||
|             ) { | ||||
|                 materializedViewNamesSet.add(key); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Pre-compute lookup maps for better performance | ||||
|     const columnsByTable = new Map<string, (typeof columns)[0][]>(); | ||||
|     const indexesByTable = new Map<string, (typeof indexes)[0][]>(); | ||||
|     const primaryKeysByTable = new Map<string, (typeof primaryKeys)[0][]>(); | ||||
|  | ||||
|     // Group columns by table | ||||
|     columns.forEach((col) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: col.schema, | ||||
|             tableName: col.table, | ||||
|         }); | ||||
|         if (!columnsByTable.has(key)) { | ||||
|             columnsByTable.set(key, []); | ||||
|         } | ||||
|         columnsByTable.get(key)!.push(col); | ||||
|     }); | ||||
|  | ||||
|     // Group indexes by table | ||||
|     indexes.forEach((idx) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: idx.schema, | ||||
|             tableName: idx.table, | ||||
|         }); | ||||
|         if (!indexesByTable.has(key)) { | ||||
|             indexesByTable.set(key, []); | ||||
|         } | ||||
|         indexesByTable.get(key)!.push(idx); | ||||
|     }); | ||||
|  | ||||
|     // Group primary keys by table | ||||
|     primaryKeys.forEach((pk) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: pk.schema, | ||||
|             tableName: pk.table, | ||||
|         }); | ||||
|         if (!primaryKeysByTable.has(key)) { | ||||
|             primaryKeysByTable.set(key, []); | ||||
|         } | ||||
|         primaryKeysByTable.get(key)!.push(pk); | ||||
|     }); | ||||
|  | ||||
|     const result = tableInfos.map((tableInfo: TableInfo) => { | ||||
|         const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema); | ||||
|         const tableKey = generateTableKey({ | ||||
|             schemaName: tableInfo.schema, | ||||
|             tableName: tableInfo.table, | ||||
|         }); | ||||
|  | ||||
|         // Use pre-computed lookups instead of filtering entire arrays | ||||
|         const tableIndexes = indexesByTable.get(tableKey) || []; | ||||
|         const tablePrimaryKeys = primaryKeysByTable.get(tableKey) || []; | ||||
|         const tableColumns = columnsByTable.get(tableKey) || []; | ||||
|  | ||||
|         // Aggregate indexes with multiple columns | ||||
|         const aggregatedIndexes = createAggregatedIndexes({ | ||||
|             tableInfo, | ||||
|             tableSchema, | ||||
|             tableIndexes, | ||||
|         }); | ||||
|  | ||||
|         const fields = createFieldsFromMetadata({ | ||||
|             aggregatedIndexes, | ||||
|             tableColumns, | ||||
|             tablePrimaryKeys, | ||||
|             tableInfo, | ||||
|             tableSchema, | ||||
|         }); | ||||
|  | ||||
|         // Check for composite primary key and find matching index name | ||||
|         const primaryKeyFields = fields.filter((f) => f.primaryKey); | ||||
|         let pkMatchingIndexName: string | undefined; | ||||
|         let pkIndex: DBIndex | undefined; | ||||
|  | ||||
|         if (primaryKeyFields.length >= 1) { | ||||
|             // We have a composite primary key, look for an index that matches all PK columns | ||||
|             const pkFieldNames = primaryKeyFields.map((f) => f.name).sort(); | ||||
|  | ||||
|             // Find an index that matches the primary key columns exactly | ||||
|             const matchingIndex = aggregatedIndexes.find((index) => { | ||||
|                 const indexColumnNames = index.columns | ||||
|                     .map((c) => c.name) | ||||
|                     .sort(); | ||||
|                 return ( | ||||
|                     indexColumnNames.length === pkFieldNames.length && | ||||
|                     indexColumnNames.every((col, i) => col === pkFieldNames[i]) | ||||
|                 ); | ||||
|             }); | ||||
|  | ||||
|             if (matchingIndex) { | ||||
|                 pkMatchingIndexName = matchingIndex.name; | ||||
|                 // Create a special PK index | ||||
|                 pkIndex = { | ||||
|                     id: generateId(), | ||||
|                     name: matchingIndex.name, | ||||
|                     unique: true, | ||||
|                     fieldIds: primaryKeyFields.map((f) => f.id), | ||||
|                     createdAt: Date.now(), | ||||
|                     isPrimaryKey: true, | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Filter out the index that matches the composite PK (to avoid duplication) | ||||
|         const filteredAggregatedIndexes = pkMatchingIndexName | ||||
|             ? aggregatedIndexes.filter( | ||||
|                   (idx) => idx.name !== pkMatchingIndexName | ||||
|               ) | ||||
|             : aggregatedIndexes; | ||||
|  | ||||
|         const dbIndexes = createIndexesFromMetadata({ | ||||
|             aggregatedIndexes: filteredAggregatedIndexes, | ||||
|             fields, | ||||
|         }); | ||||
|  | ||||
|         // Add the PK index if it exists | ||||
|         if (pkIndex) { | ||||
|             dbIndexes.push(pkIndex); | ||||
|         } | ||||
|  | ||||
|         // Determine if the current table is a view by checking against pre-computed sets | ||||
|         const viewKey = generateTableKey({ | ||||
|             schemaName: tableSchema, | ||||
|             tableName: tableInfo.table, | ||||
|         }); | ||||
|         const isView = viewNamesSet.has(viewKey); | ||||
|         const isMaterializedView = materializedViewNamesSet.has(viewKey); | ||||
|  | ||||
|         // Initial random positions; these will be adjusted later | ||||
|         return { | ||||
|             id: generateId(), | ||||
|             name: tableInfo.table, | ||||
|             schema: tableSchema, | ||||
|             x: Math.random() * 1000, // Placeholder X | ||||
|             y: Math.random() * 800, // Placeholder Y | ||||
|             fields, | ||||
|             indexes: dbIndexes, | ||||
|             color: isMaterializedView | ||||
|                 ? materializedViewColor | ||||
|                 : isView | ||||
|                   ? viewColor | ||||
|                   : defaultTableColor, | ||||
|             isView: isView, | ||||
|             isMaterializedView: isMaterializedView, | ||||
|             createdAt: Date.now(), | ||||
|             comments: tableInfo.comment ? tableInfo.comment : undefined, | ||||
|         }; | ||||
|     }); | ||||
|  | ||||
|     return result; | ||||
| }; | ||||
| @@ -1,20 +1,10 @@ | ||||
| import { describe, it, expect, vi } from 'vitest'; | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { exportBaseSQL } from '../export-sql-script'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import type { DBTable } from '@/lib/domain/db-table'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
| 
 | ||||
| // Mock the dbml/core importer
 | ||||
| vi.mock('@dbml/core', () => ({ | ||||
|     importer: { | ||||
|         import: vi.fn((sql: string) => { | ||||
|             // Return a simplified DBML for testing
 | ||||
|             return sql; | ||||
|         }), | ||||
|     }, | ||||
| })); | ||||
| 
 | ||||
| describe('DBML Export - SQL Generation Tests', () => { | ||||
|     // Helper to generate test IDs and timestamps
 | ||||
|     let idCounter = 0; | ||||
| @@ -116,7 +106,7 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should contain composite primary key syntax
 | ||||
|             expect(sql).toContain('PRIMARY KEY (spell_id, component_id)'); | ||||
|             expect(sql).toContain('PRIMARY KEY ("spell_id", "component_id")'); | ||||
|             // Should NOT contain individual PRIMARY KEY constraints
 | ||||
|             expect(sql).not.toMatch(/spell_id\s+uuid\s+NOT NULL\s+PRIMARY KEY/); | ||||
|             expect(sql).not.toMatch( | ||||
| @@ -202,7 +192,7 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
| 
 | ||||
|             // Should contain composite primary key constraint
 | ||||
|             expect(sql).toContain( | ||||
|                 'PRIMARY KEY (master_user_id, tenant_id, tenant_user_id)' | ||||
|                 'PRIMARY KEY ("master_user_id", "tenant_id", "tenant_user_id")' | ||||
|             ); | ||||
| 
 | ||||
|             // Should NOT contain the duplicate index for the primary key fields
 | ||||
| @@ -255,7 +245,7 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should contain inline PRIMARY KEY
 | ||||
|             expect(sql).toMatch(/id\s+uuid\s+NOT NULL\s+PRIMARY KEY/); | ||||
|             expect(sql).toMatch(/"id"\s+uuid\s+NOT NULL\s+PRIMARY KEY/); | ||||
|             // Should NOT contain separate PRIMARY KEY constraint
 | ||||
|             expect(sql).not.toContain('PRIMARY KEY (id)'); | ||||
|         }); | ||||
| @@ -316,8 +306,8 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             expect(sql).not.toContain('DEFAULT has default'); | ||||
|             expect(sql).not.toContain('DEFAULT DEFAULT has default'); | ||||
|             // The fields should still be in the table
 | ||||
|             expect(sql).toContain('is_active boolean'); | ||||
|             expect(sql).toContain('stock_count integer NOT NULL'); // integer gets simplified to int
 | ||||
|             expect(sql).toContain('"is_active" boolean'); | ||||
|             expect(sql).toContain('"stock_count" integer NOT NULL'); // integer gets simplified to int
 | ||||
|         }); | ||||
| 
 | ||||
|         it('should handle valid default values correctly', () => { | ||||
| @@ -439,8 +429,8 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should convert NOW to NOW() and ('now') to now()
 | ||||
|             expect(sql).toContain('created_at timestamp DEFAULT NOW'); | ||||
|             expect(sql).toContain('updated_at timestamp DEFAULT now()'); | ||||
|             expect(sql).toContain('"created_at" timestamp DEFAULT NOW'); | ||||
|             expect(sql).toContain('"updated_at" timestamp DEFAULT now()'); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
| @@ -495,9 +485,9 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should handle char with explicit length
 | ||||
|             expect(sql).toContain('element_code char(2)'); | ||||
|             expect(sql).toContain('"element_code" char(2)'); | ||||
|             // Should add default length for char without length
 | ||||
|             expect(sql).toContain('status char(1)'); | ||||
|             expect(sql).toContain('"status" char(1)'); | ||||
|         }); | ||||
| 
 | ||||
|         it('should not have spaces between char and parentheses', () => { | ||||
| @@ -606,7 +596,7 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should create a valid table without primary key
 | ||||
|             expect(sql).toContain('CREATE TABLE experiment_logs'); | ||||
|             expect(sql).toContain('CREATE TABLE "experiment_logs"'); | ||||
|             expect(sql).not.toContain('PRIMARY KEY'); | ||||
|         }); | ||||
| 
 | ||||
| @@ -721,11 +711,11 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should create both tables
 | ||||
|             expect(sql).toContain('CREATE TABLE guilds'); | ||||
|             expect(sql).toContain('CREATE TABLE guild_members'); | ||||
|             expect(sql).toContain('CREATE TABLE "guilds"'); | ||||
|             expect(sql).toContain('CREATE TABLE "guild_members"'); | ||||
|             // Should create foreign key
 | ||||
|             expect(sql).toContain( | ||||
|                 'ALTER TABLE guild_members ADD CONSTRAINT fk_guild_members_guild FOREIGN KEY (guild_id) REFERENCES guilds (id)' | ||||
|                 'ALTER TABLE "guild_members" ADD CONSTRAINT fk_guild_members_guild FOREIGN KEY ("guild_id") REFERENCES "guilds" ("id");' | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
| @@ -799,12 +789,9 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|                 isDBMLFlow: true, | ||||
|             }); | ||||
| 
 | ||||
|             // Should create schemas
 | ||||
|             expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS transportation'); | ||||
|             expect(sql).toContain('CREATE SCHEMA IF NOT EXISTS magic'); | ||||
|             // Should use schema-qualified table names
 | ||||
|             expect(sql).toContain('CREATE TABLE transportation.portals'); | ||||
|             expect(sql).toContain('CREATE TABLE magic.spells'); | ||||
|             expect(sql).toContain('CREATE TABLE "transportation"."portals"'); | ||||
|             expect(sql).toContain('CREATE TABLE "magic"."spells"'); | ||||
|         }); | ||||
|     }); | ||||
| 
 | ||||
| @@ -851,7 +838,7 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should still create table structure
 | ||||
|             expect(sql).toContain('CREATE TABLE empty_table'); | ||||
|             expect(sql).toContain('CREATE TABLE "empty_table"'); | ||||
|             expect(sql).toContain('(\n\n)'); | ||||
|         }); | ||||
| 
 | ||||
| @@ -952,9 +939,9 @@ describe('DBML Export - SQL Generation Tests', () => { | ||||
|             }); | ||||
| 
 | ||||
|             // Should include precision and scale
 | ||||
|             expect(sql).toContain('amount numeric(15, 2)'); | ||||
|             expect(sql).toContain('"amount" numeric(15, 2)'); | ||||
|             // Should include precision only when scale is not provided
 | ||||
|             expect(sql).toContain('interest_rate numeric(5)'); | ||||
|             expect(sql).toContain('"interest_rate" numeric(5)'); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| @@ -20,6 +20,61 @@ const simplifyDataType = (typeName: string): string => { | ||||
|     return typeMap[typeName.toLowerCase()] || typeName; | ||||
| }; | ||||
| 
 | ||||
| // Helper function to properly quote table/schema names with special characters
 | ||||
| const getQuotedTableName = ( | ||||
|     table: DBTable, | ||||
|     isDBMLFlow: boolean = false | ||||
| ): string => { | ||||
|     // Check if a name is already quoted
 | ||||
|     const isAlreadyQuoted = (name: string) => { | ||||
|         return ( | ||||
|             (name.startsWith('"') && name.endsWith('"')) || | ||||
|             (name.startsWith('`') && name.endsWith('`')) || | ||||
|             (name.startsWith('[') && name.endsWith(']')) | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
|     // Only add quotes if needed and not already quoted
 | ||||
|     const quoteIfNeeded = (name: string) => { | ||||
|         if (isAlreadyQuoted(name)) { | ||||
|             return name; | ||||
|         } | ||||
|         const needsQuoting = /[^a-zA-Z0-9_]/.test(name) || isDBMLFlow; | ||||
|         return needsQuoting ? `"${name}"` : name; | ||||
|     }; | ||||
| 
 | ||||
|     if (table.schema) { | ||||
|         const quotedSchema = quoteIfNeeded(table.schema); | ||||
|         const quotedTable = quoteIfNeeded(table.name); | ||||
|         return `${quotedSchema}.${quotedTable}`; | ||||
|     } else { | ||||
|         return quoteIfNeeded(table.name); | ||||
|     } | ||||
| }; | ||||
| 
 | ||||
| const getQuotedFieldName = ( | ||||
|     fieldName: string, | ||||
|     isDBMLFlow: boolean = false | ||||
| ): string => { | ||||
|     // Check if a name is already quoted
 | ||||
|     const isAlreadyQuoted = (name: string) => { | ||||
|         return ( | ||||
|             (name.startsWith('"') && name.endsWith('"')) || | ||||
|             (name.startsWith('`') && name.endsWith('`')) || | ||||
|             (name.startsWith('[') && name.endsWith(']')) | ||||
|         ); | ||||
|     }; | ||||
| 
 | ||||
|     if (isAlreadyQuoted(fieldName)) { | ||||
|         return fieldName; | ||||
|     } | ||||
| 
 | ||||
|     // For DBML flow, always quote field names
 | ||||
|     // Otherwise, only quote if it contains special characters
 | ||||
|     const needsQuoting = /[^a-zA-Z0-9_]/.test(fieldName) || isDBMLFlow; | ||||
|     return needsQuoting ? `"${fieldName}"` : fieldName; | ||||
| }; | ||||
| 
 | ||||
| export const exportBaseSQL = ({ | ||||
|     diagram, | ||||
|     targetDatabaseType, | ||||
| @@ -63,18 +118,21 @@ export const exportBaseSQL = ({ | ||||
|     let sqlScript = ''; | ||||
| 
 | ||||
|     // First create the CREATE SCHEMA statements for all the found schemas based on tables
 | ||||
|     const schemas = new Set<string>(); | ||||
|     tables.forEach((table) => { | ||||
|         if (table.schema) { | ||||
|             schemas.add(table.schema); | ||||
|         } | ||||
|     }); | ||||
|     // Skip schema creation for DBML flow as DBML doesn't support CREATE SCHEMA syntax
 | ||||
|     if (!isDBMLFlow) { | ||||
|         const schemas = new Set<string>(); | ||||
|         tables.forEach((table) => { | ||||
|             if (table.schema) { | ||||
|                 schemas.add(table.schema); | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
|     // Add CREATE SCHEMA statements if any schemas exist
 | ||||
|     schemas.forEach((schema) => { | ||||
|         sqlScript += `CREATE SCHEMA IF NOT EXISTS ${schema};\n`; | ||||
|     }); | ||||
|     if (schemas.size > 0) sqlScript += '\n'; // Add newline only if schemas were added
 | ||||
|         // Add CREATE SCHEMA statements if any schemas exist
 | ||||
|         schemas.forEach((schema) => { | ||||
|             sqlScript += `CREATE SCHEMA IF NOT EXISTS "${schema}";\n`; | ||||
|         }); | ||||
|         if (schemas.size > 0) sqlScript += '\n'; // Add newline only if schemas were added
 | ||||
|     } | ||||
| 
 | ||||
|     // Add CREATE TYPE statements for ENUMs and COMPOSITE types from diagram.customTypes
 | ||||
|     if (diagram.customTypes && diagram.customTypes.length > 0) { | ||||
| @@ -166,9 +224,7 @@ export const exportBaseSQL = ({ | ||||
| 
 | ||||
|     // Loop through each non-view table to generate the SQL statements
 | ||||
|     nonViewTables.forEach((table) => { | ||||
|         const tableName = table.schema | ||||
|             ? `${table.schema}.${table.name}` | ||||
|             : table.name; | ||||
|         const tableName = getQuotedTableName(table, isDBMLFlow); | ||||
|         sqlScript += `CREATE TABLE ${tableName} (\n`; | ||||
| 
 | ||||
|         // Check for composite primary keys
 | ||||
| @@ -237,7 +293,8 @@ export const exportBaseSQL = ({ | ||||
|                 typeName = 'char'; | ||||
|             } | ||||
| 
 | ||||
|             sqlScript += `  ${field.name} ${typeName}`; | ||||
|             const quotedFieldName = getQuotedFieldName(field.name, isDBMLFlow); | ||||
|             sqlScript += `  ${quotedFieldName} ${typeName}`; | ||||
| 
 | ||||
|             // Add size for character types
 | ||||
|             if ( | ||||
| @@ -334,7 +391,9 @@ export const exportBaseSQL = ({ | ||||
|             hasCompositePrimaryKey || | ||||
|             (primaryKeyFields.length === 1 && pkIndex?.name) | ||||
|         ) { | ||||
|             const pkFieldNames = primaryKeyFields.map((f) => f.name).join(', '); | ||||
|             const pkFieldNames = primaryKeyFields | ||||
|                 .map((f) => getQuotedFieldName(f.name, isDBMLFlow)) | ||||
|                 .join(', '); | ||||
|             if (pkIndex?.name) { | ||||
|                 sqlScript += `\n  CONSTRAINT ${pkIndex.name} PRIMARY KEY (${pkFieldNames})`; | ||||
|             } else { | ||||
| @@ -355,7 +414,11 @@ export const exportBaseSQL = ({ | ||||
|         table.fields.forEach((field) => { | ||||
|             // Add column comment (only for databases that support COMMENT ON syntax)
 | ||||
|             if (field.comments && supportsCommentOn) { | ||||
|                 sqlScript += `COMMENT ON COLUMN ${tableName}.${field.name} IS '${escapeSQLComment(field.comments)}';\n`; | ||||
|                 const quotedFieldName = getQuotedFieldName( | ||||
|                     field.name, | ||||
|                     isDBMLFlow | ||||
|                 ); | ||||
|                 sqlScript += `COMMENT ON COLUMN ${tableName}.${quotedFieldName} IS '${escapeSQLComment(field.comments)}';\n`; | ||||
|             } | ||||
|         }); | ||||
| 
 | ||||
| @@ -387,7 +450,7 @@ export const exportBaseSQL = ({ | ||||
|             } | ||||
| 
 | ||||
|             const fieldNames = indexFields | ||||
|                 .map((field) => field.name) | ||||
|                 .map((field) => getQuotedFieldName(field.name, isDBMLFlow)) | ||||
|                 .join(', '); | ||||
| 
 | ||||
|             if (fieldNames) { | ||||
| @@ -465,13 +528,18 @@ export const exportBaseSQL = ({ | ||||
|                 return; | ||||
|             } | ||||
| 
 | ||||
|             const fkTableName = fkTable.schema | ||||
|                 ? `${fkTable.schema}.${fkTable.name}` | ||||
|                 : fkTable.name; | ||||
|             const refTableName = refTable.schema | ||||
|                 ? `${refTable.schema}.${refTable.name}` | ||||
|                 : refTable.name; | ||||
|             sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${fkField.name}) REFERENCES ${refTableName} (${refField.name});\n`; | ||||
|             const fkTableName = getQuotedTableName(fkTable, isDBMLFlow); | ||||
|             const refTableName = getQuotedTableName(refTable, isDBMLFlow); | ||||
|             const quotedFkFieldName = getQuotedFieldName( | ||||
|                 fkField.name, | ||||
|                 isDBMLFlow | ||||
|             ); | ||||
|             const quotedRefFieldName = getQuotedFieldName( | ||||
|                 refField.name, | ||||
|                 isDBMLFlow | ||||
|             ); | ||||
| 
 | ||||
|             sqlScript += `ALTER TABLE ${fkTableName} ADD CONSTRAINT ${relationship.name} FOREIGN KEY (${quotedFkFieldName}) REFERENCES ${refTableName} (${quotedRefFieldName});\n`; | ||||
|         } | ||||
|     }); | ||||
| 
 | ||||
| @@ -86,7 +86,7 @@ export interface SQLBinaryExpr extends SQLASTNode { | ||||
|  | ||||
| export interface SQLFunctionNode extends SQLASTNode { | ||||
|     type: 'function'; | ||||
|     name: string; | ||||
|     name: string | { name: Array<{ value: string }> }; | ||||
|     args?: { | ||||
|         value: SQLASTArg[]; | ||||
|     }; | ||||
| @@ -108,6 +108,31 @@ export interface SQLStringLiteral extends SQLASTNode { | ||||
|     value: string; | ||||
| } | ||||
|  | ||||
| export interface SQLDefaultNode extends SQLASTNode { | ||||
|     type: 'default'; | ||||
|     value: SQLASTNode; | ||||
| } | ||||
|  | ||||
| export interface SQLCastNode extends SQLASTNode { | ||||
|     type: 'cast'; | ||||
|     expr: SQLASTNode; | ||||
|     target: Array<{ dataType: string }>; | ||||
| } | ||||
|  | ||||
| export interface SQLBooleanNode extends SQLASTNode { | ||||
|     type: 'bool'; | ||||
|     value: boolean; | ||||
| } | ||||
|  | ||||
| export interface SQLNullNode extends SQLASTNode { | ||||
|     type: 'null'; | ||||
| } | ||||
|  | ||||
| export interface SQLNumberNode extends SQLASTNode { | ||||
|     type: 'number'; | ||||
|     value: number; | ||||
| } | ||||
|  | ||||
| export type SQLASTArg = | ||||
|     | SQLColumnRef | ||||
|     | SQLStringLiteral | ||||
| @@ -146,6 +171,22 @@ export function buildSQLFromAST( | ||||
| ): string { | ||||
|     if (!ast) return ''; | ||||
|  | ||||
|     // Handle default value wrapper | ||||
|     if (ast.type === 'default' && 'value' in ast) { | ||||
|         const defaultNode = ast as SQLDefaultNode; | ||||
|         return buildSQLFromAST(defaultNode.value, dbType); | ||||
|     } | ||||
|  | ||||
|     // Handle PostgreSQL cast expressions (e.g., 'value'::type) | ||||
|     if (ast.type === 'cast' && 'expr' in ast && 'target' in ast) { | ||||
|         const castNode = ast as SQLCastNode; | ||||
|         const expr = buildSQLFromAST(castNode.expr, dbType); | ||||
|         if (castNode.target.length > 0 && castNode.target[0].dataType) { | ||||
|             return `${expr}::${castNode.target[0].dataType.toLowerCase()}`; | ||||
|         } | ||||
|         return expr; | ||||
|     } | ||||
|  | ||||
|     if (ast.type === 'binary_expr') { | ||||
|         const expr = ast as SQLBinaryExpr; | ||||
|         const leftSQL = buildSQLFromAST(expr.left, dbType); | ||||
| @@ -155,7 +196,59 @@ export function buildSQLFromAST( | ||||
|  | ||||
|     if (ast.type === 'function') { | ||||
|         const func = ast as SQLFunctionNode; | ||||
|         let expr = func.name; | ||||
|         let funcName = ''; | ||||
|  | ||||
|         // Handle nested function name structure | ||||
|         if (typeof func.name === 'object' && func.name && 'name' in func.name) { | ||||
|             const nameObj = func.name as { name: Array<{ value: string }> }; | ||||
|             if (nameObj.name.length > 0) { | ||||
|                 funcName = nameObj.name[0].value || ''; | ||||
|             } | ||||
|         } else if (typeof func.name === 'string') { | ||||
|             funcName = func.name; | ||||
|         } | ||||
|  | ||||
|         if (!funcName) return ''; | ||||
|  | ||||
|         // Normalize PostgreSQL function names to uppercase for consistency | ||||
|         if (dbType === DatabaseType.POSTGRESQL) { | ||||
|             const pgFunctions = [ | ||||
|                 'now', | ||||
|                 'current_timestamp', | ||||
|                 'current_date', | ||||
|                 'current_time', | ||||
|                 'gen_random_uuid', | ||||
|                 'random', | ||||
|                 'nextval', | ||||
|                 'currval', | ||||
|             ]; | ||||
|             if (pgFunctions.includes(funcName.toLowerCase())) { | ||||
|                 funcName = funcName.toUpperCase(); | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Some PostgreSQL functions don't have parentheses (like CURRENT_TIMESTAMP) | ||||
|         if (funcName === 'CURRENT_TIMESTAMP' && !func.args) { | ||||
|             return funcName; | ||||
|         } | ||||
|  | ||||
|         // Handle SQL Server function defaults that were preprocessed as strings | ||||
|         // The preprocessor converts NEWID() to 'newid', GETDATE() to 'getdate', etc. | ||||
|         if (dbType === DatabaseType.SQL_SERVER) { | ||||
|             const sqlServerFunctions: Record<string, string> = { | ||||
|                 newid: 'NEWID()', | ||||
|                 newsequentialid: 'NEWSEQUENTIALID()', | ||||
|                 getdate: 'GETDATE()', | ||||
|                 sysdatetime: 'SYSDATETIME()', | ||||
|             }; | ||||
|  | ||||
|             const lowerFuncName = funcName.toLowerCase(); | ||||
|             if (sqlServerFunctions[lowerFuncName]) { | ||||
|                 return sqlServerFunctions[lowerFuncName]; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         let expr = funcName; | ||||
|         if (func.args) { | ||||
|             expr += | ||||
|                 '(' + | ||||
| @@ -175,12 +268,31 @@ export function buildSQLFromAST( | ||||
|                     }) | ||||
|                     .join(', ') + | ||||
|                 ')'; | ||||
|         } else { | ||||
|             expr += '()'; | ||||
|         } | ||||
|         return expr; | ||||
|     } else if (ast.type === 'column_ref') { | ||||
|         return quoteIdentifier((ast as SQLColumnRef).column, dbType); | ||||
|     } else if (ast.type === 'expr_list') { | ||||
|         return (ast as SQLExprList).value.map((v) => v.value).join(' AND '); | ||||
|     } else if (ast.type === 'single_quote_string') { | ||||
|         // String literal with single quotes | ||||
|         const strNode = ast as SQLStringLiteral; | ||||
|         return `'${strNode.value}'`; | ||||
|     } else if (ast.type === 'double_quote_string') { | ||||
|         // String literal with double quotes | ||||
|         const strNode = ast as SQLStringLiteral; | ||||
|         return `"${strNode.value}"`; | ||||
|     } else if (ast.type === 'bool') { | ||||
|         // Boolean value | ||||
|         const boolNode = ast as SQLBooleanNode; | ||||
|         return boolNode.value ? 'TRUE' : 'FALSE'; | ||||
|     } else if (ast.type === 'null') { | ||||
|         return 'NULL'; | ||||
|     } else if (ast.type === 'number') { | ||||
|         const numNode = ast as SQLNumberNode; | ||||
|         return String(numNode.value); | ||||
|     } else { | ||||
|         const valueNode = ast as { type: string; value: string | number }; | ||||
|         return typeof valueNode.value === 'string' | ||||
| @@ -779,10 +891,10 @@ export function convertToChartDBDiagram( | ||||
|         } | ||||
|  | ||||
|         const sourceField = sourceTable.fields.find( | ||||
|             (f) => f.name === rel.sourceColumn | ||||
|             (f) => f.name.toLowerCase() === rel.sourceColumn.toLowerCase() | ||||
|         ); | ||||
|         const targetField = targetTable.fields.find( | ||||
|             (f) => f.name === rel.targetColumn | ||||
|             (f) => f.name.toLowerCase() === rel.targetColumn.toLowerCase() | ||||
|         ); | ||||
|  | ||||
|         if (!sourceField || !targetField) { | ||||
|   | ||||
| @@ -0,0 +1,228 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromMySQL } from '../mysql'; | ||||
|  | ||||
| describe('MySQL Default Value Import', () => { | ||||
|     describe('String Default Values', () => { | ||||
|         it('should parse simple string defaults with single quotes', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE tavern_patrons ( | ||||
|                     patron_id INT NOT NULL, | ||||
|                     membership_status VARCHAR(50) DEFAULT 'regular', | ||||
|                     PRIMARY KEY (patron_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const statusColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'membership_status' | ||||
|             ); | ||||
|             expect(statusColumn?.default).toBe("'regular'"); | ||||
|         }); | ||||
|  | ||||
|         it('should parse string defaults with escaped quotes', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE wizard_spellbooks ( | ||||
|                     spellbook_id INT NOT NULL, | ||||
|                     incantation VARCHAR(255) DEFAULT 'Dragon\\'s flame', | ||||
|                     spell_metadata TEXT DEFAULT '{"type": "fire"}', | ||||
|                     PRIMARY KEY (spellbook_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const incantationColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'incantation' | ||||
|             ); | ||||
|             expect(incantationColumn?.default).toBeTruthy(); | ||||
|             const metadataColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'spell_metadata' | ||||
|             ); | ||||
|             expect(metadataColumn?.default).toBeTruthy(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Numeric Default Values', () => { | ||||
|         it('should parse integer defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE dungeon_levels ( | ||||
|                     level_id INT NOT NULL, | ||||
|                     monster_count INT DEFAULT 0, | ||||
|                     max_treasure INT DEFAULT 1000, | ||||
|                     PRIMARY KEY (level_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const monsterColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'monster_count' | ||||
|             ); | ||||
|             expect(monsterColumn?.default).toBe('0'); | ||||
|             const treasureColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'max_treasure' | ||||
|             ); | ||||
|             expect(treasureColumn?.default).toBe('1000'); | ||||
|         }); | ||||
|  | ||||
|         it('should parse decimal defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE merchant_inventory ( | ||||
|                     item_id INT NOT NULL, | ||||
|                     base_price DECIMAL(10, 2) DEFAULT 99.99, | ||||
|                     loyalty_discount FLOAT DEFAULT 0.15, | ||||
|                     PRIMARY KEY (item_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const priceColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'base_price' | ||||
|             ); | ||||
|             expect(priceColumn?.default).toBe('99.99'); | ||||
|             const discountColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'loyalty_discount' | ||||
|             ); | ||||
|             expect(discountColumn?.default).toBe('0.15'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Boolean Default Values', () => { | ||||
|         it('should parse boolean defaults in MySQL (using TINYINT)', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE character_status ( | ||||
|                     character_id INT NOT NULL, | ||||
|                     is_alive TINYINT(1) DEFAULT 1, | ||||
|                     is_cursed TINYINT(1) DEFAULT 0, | ||||
|                     has_magic BOOLEAN DEFAULT TRUE, | ||||
|                     PRIMARY KEY (character_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const aliveColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_alive' | ||||
|             ); | ||||
|             expect(aliveColumn?.default).toBe('1'); | ||||
|             const cursedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_cursed' | ||||
|             ); | ||||
|             expect(cursedColumn?.default).toBe('0'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('NULL Default Values', () => { | ||||
|         it('should parse NULL defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE companion_animals ( | ||||
|                     companion_id INT NOT NULL, | ||||
|                     special_trait VARCHAR(255) DEFAULT NULL, | ||||
|                     PRIMARY KEY (companion_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const traitColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'special_trait' | ||||
|             ); | ||||
|             expect(traitColumn?.default).toBe('NULL'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Function Default Values', () => { | ||||
|         it('should parse function defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE quest_entries ( | ||||
|                     entry_id INT NOT NULL AUTO_INCREMENT, | ||||
|                     quest_accepted TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|                     last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
|                     quest_uuid VARCHAR(36) DEFAULT (UUID()), | ||||
|                     PRIMARY KEY (entry_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const acceptedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'quest_accepted' | ||||
|             ); | ||||
|             expect(acceptedColumn?.default).toBe('CURRENT_TIMESTAMP'); | ||||
|             const updatedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'last_updated' | ||||
|             ); | ||||
|             expect(updatedColumn?.default).toBe('CURRENT_TIMESTAMP'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('AUTO_INCREMENT', () => { | ||||
|         it('should handle AUTO_INCREMENT columns correctly', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE hero_registry ( | ||||
|                     hero_id INT NOT NULL AUTO_INCREMENT, | ||||
|                     hero_name VARCHAR(100), | ||||
|                     PRIMARY KEY (hero_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromMySQL(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const idColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'hero_id' | ||||
|             ); | ||||
|             expect(idColumn?.increment).toBe(true); | ||||
|             // AUTO_INCREMENT columns typically don't have a default value | ||||
|             expect(idColumn?.default).toBeUndefined(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Complex Real-World Example', () => { | ||||
|         it('should handle complex table with multiple default types', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE adventurer_profiles ( | ||||
|                     adventurer_id BIGINT NOT NULL AUTO_INCREMENT, | ||||
|                     character_name VARCHAR(50) NOT NULL, | ||||
|                     guild_email VARCHAR(255) NOT NULL, | ||||
|                     rank VARCHAR(20) DEFAULT 'novice', | ||||
|                     is_guild_verified TINYINT(1) DEFAULT 0, | ||||
|                     gold_coins INT DEFAULT 100, | ||||
|                     account_balance DECIMAL(10, 2) DEFAULT 0.00, | ||||
|                     joined_realm TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|                     last_quest TIMESTAMP DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, | ||||
|                     inventory_data JSON DEFAULT NULL, | ||||
|                     PRIMARY KEY (adventurer_id), | ||||
|                     UNIQUE KEY uk_guild_email (guild_email), | ||||
|                     INDEX idx_rank (rank) | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromMySQL(sql); | ||||
|             const table = result.tables[0]; | ||||
|             expect(table).toBeDefined(); | ||||
|  | ||||
|             // Check various default values | ||||
|             const rankColumn = table.columns.find((c) => c.name === 'rank'); | ||||
|             expect(rankColumn?.default).toBe("'novice'"); | ||||
|  | ||||
|             const verifiedColumn = table.columns.find( | ||||
|                 (c) => c.name === 'is_guild_verified' | ||||
|             ); | ||||
|             expect(verifiedColumn?.default).toBe('0'); | ||||
|  | ||||
|             const goldColumn = table.columns.find( | ||||
|                 (c) => c.name === 'gold_coins' | ||||
|             ); | ||||
|             expect(goldColumn?.default).toBe('100'); | ||||
|  | ||||
|             const balanceColumn = table.columns.find( | ||||
|                 (c) => c.name === 'account_balance' | ||||
|             ); | ||||
|             expect(balanceColumn?.default).toBe('0.00'); | ||||
|  | ||||
|             const joinedColumn = table.columns.find( | ||||
|                 (c) => c.name === 'joined_realm' | ||||
|             ); | ||||
|             expect(joinedColumn?.default).toBe('CURRENT_TIMESTAMP'); | ||||
|  | ||||
|             const inventoryColumn = table.columns.find( | ||||
|                 (c) => c.name === 'inventory_data' | ||||
|             ); | ||||
|             expect(inventoryColumn?.default).toBe('NULL'); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -101,12 +101,28 @@ function extractColumnsFromCreateTable(statement: string): SQLColumn[] { | ||||
|             const typeMatch = definition.match(/^([^\s(]+)(?:\(([^)]+)\))?/); | ||||
|             const dataType = typeMatch ? typeMatch[1] : ''; | ||||
|  | ||||
|             // Extract default value | ||||
|             let defaultValue: string | undefined; | ||||
|             const defaultMatch = definition.match( | ||||
|                 /DEFAULT\s+('[^']*'|"[^"]*"|NULL|CURRENT_TIMESTAMP|\S+)/i | ||||
|             ); | ||||
|             if (defaultMatch) { | ||||
|                 defaultValue = defaultMatch[1]; | ||||
|             } | ||||
|  | ||||
|             // Check for AUTO_INCREMENT | ||||
|             const increment = definition | ||||
|                 .toUpperCase() | ||||
|                 .includes('AUTO_INCREMENT'); | ||||
|  | ||||
|             columns.push({ | ||||
|                 name: columnName, | ||||
|                 type: dataType, | ||||
|                 nullable, | ||||
|                 primaryKey, | ||||
|                 unique: definition.toUpperCase().includes('UNIQUE'), | ||||
|                 default: defaultValue, | ||||
|                 increment, | ||||
|             }); | ||||
|         } | ||||
|     } | ||||
| @@ -721,7 +737,28 @@ export async function fromMySQL(sqlContent: string): Promise<SQLParserResult> { | ||||
|                         parseError | ||||
|                     ); | ||||
|  | ||||
|                     // Error handling without logging | ||||
|                     // Try fallback parser when main parser fails | ||||
|                     const tableMatch = trimmedStmt.match( | ||||
|                         /CREATE\s+TABLE\s+(?:IF\s+NOT\s+EXISTS\s+)?`?([^`\s(]+)`?\s*\(/i | ||||
|                     ); | ||||
|                     if (tableMatch) { | ||||
|                         const tableName = tableMatch[1]; | ||||
|                         const tableId = generateId(); | ||||
|                         tableMap[tableName] = tableId; | ||||
|  | ||||
|                         const extractedColumns = | ||||
|                             extractColumnsFromCreateTable(trimmedStmt); | ||||
|                         if (extractedColumns.length > 0) { | ||||
|                             tables.push({ | ||||
|                                 id: tableId, | ||||
|                                 name: tableName, | ||||
|                                 schema: undefined, | ||||
|                                 columns: extractedColumns, | ||||
|                                 indexes: [], | ||||
|                                 order: tables.length, | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|   | ||||
| @@ -0,0 +1,215 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromPostgres } from '../postgresql'; | ||||
|  | ||||
| describe('PostgreSQL ALTER TABLE ADD COLUMN Tests', () => { | ||||
|     it('should handle ALTER TABLE ADD COLUMN statements', async () => { | ||||
|         const sql = ` | ||||
|             CREATE SCHEMA IF NOT EXISTS "public"; | ||||
|  | ||||
|             CREATE TABLE "public"."location" ( | ||||
|                 "id" bigint NOT NULL, | ||||
|                 CONSTRAINT "pk_table_7_id" PRIMARY KEY ("id") | ||||
|             ); | ||||
|  | ||||
|             -- Add new fields to existing location table | ||||
|             ALTER TABLE location ADD COLUMN country_id INT; | ||||
|             ALTER TABLE location ADD COLUMN state_id INT; | ||||
|             ALTER TABLE location ADD COLUMN location_type_id INT; | ||||
|             ALTER TABLE location ADD COLUMN city_id INT; | ||||
|             ALTER TABLE location ADD COLUMN street TEXT; | ||||
|             ALTER TABLE location ADD COLUMN block TEXT; | ||||
|             ALTER TABLE location ADD COLUMN building TEXT; | ||||
|             ALTER TABLE location ADD COLUMN floor TEXT; | ||||
|             ALTER TABLE location ADD COLUMN apartment TEXT; | ||||
|             ALTER TABLE location ADD COLUMN lat INT; | ||||
|             ALTER TABLE location ADD COLUMN long INT; | ||||
|             ALTER TABLE location ADD COLUMN elevation INT; | ||||
|             ALTER TABLE location ADD COLUMN erp_site_id INT; | ||||
|             ALTER TABLE location ADD COLUMN is_active TEXT; | ||||
|             ALTER TABLE location ADD COLUMN remarks TEXT; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const locationTable = result.tables[0]; | ||||
|  | ||||
|         expect(locationTable.name).toBe('location'); | ||||
|         expect(locationTable.schema).toBe('public'); | ||||
|  | ||||
|         // Should have the original id column plus all the added columns | ||||
|         expect(locationTable.columns).toHaveLength(16); | ||||
|  | ||||
|         // Check that the id column is present | ||||
|         const idColumn = locationTable.columns.find((col) => col.name === 'id'); | ||||
|         expect(idColumn).toBeDefined(); | ||||
|         expect(idColumn?.type).toBe('BIGINT'); | ||||
|         expect(idColumn?.primaryKey).toBe(true); | ||||
|  | ||||
|         // Check some of the added columns | ||||
|         const countryIdColumn = locationTable.columns.find( | ||||
|             (col) => col.name === 'country_id' | ||||
|         ); | ||||
|         expect(countryIdColumn).toBeDefined(); | ||||
|         expect(countryIdColumn?.type).toBe('INTEGER'); | ||||
|  | ||||
|         const streetColumn = locationTable.columns.find( | ||||
|             (col) => col.name === 'street' | ||||
|         ); | ||||
|         expect(streetColumn).toBeDefined(); | ||||
|         expect(streetColumn?.type).toBe('TEXT'); | ||||
|  | ||||
|         const remarksColumn = locationTable.columns.find( | ||||
|             (col) => col.name === 'remarks' | ||||
|         ); | ||||
|         expect(remarksColumn).toBeDefined(); | ||||
|         expect(remarksColumn?.type).toBe('TEXT'); | ||||
|     }); | ||||
|  | ||||
|     it('should handle ALTER TABLE ADD COLUMN with schema qualification', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE public.users ( | ||||
|                 id INTEGER PRIMARY KEY | ||||
|             ); | ||||
|  | ||||
|             ALTER TABLE public.users ADD COLUMN email VARCHAR(255); | ||||
|             ALTER TABLE public.users ADD COLUMN created_at TIMESTAMP; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const usersTable = result.tables[0]; | ||||
|  | ||||
|         expect(usersTable.columns).toHaveLength(3); | ||||
|  | ||||
|         const emailColumn = usersTable.columns.find( | ||||
|             (col) => col.name === 'email' | ||||
|         ); | ||||
|         expect(emailColumn).toBeDefined(); | ||||
|         expect(emailColumn?.type).toBe('VARCHAR(255)'); | ||||
|  | ||||
|         const createdAtColumn = usersTable.columns.find( | ||||
|             (col) => col.name === 'created_at' | ||||
|         ); | ||||
|         expect(createdAtColumn).toBeDefined(); | ||||
|         expect(createdAtColumn?.type).toBe('TIMESTAMP'); | ||||
|     }); | ||||
|  | ||||
|     it('should handle ALTER TABLE ADD COLUMN with constraints', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE products ( | ||||
|                 id SERIAL PRIMARY KEY | ||||
|             ); | ||||
|  | ||||
|             ALTER TABLE products ADD COLUMN name VARCHAR(100) NOT NULL; | ||||
|             ALTER TABLE products ADD COLUMN sku VARCHAR(50) UNIQUE; | ||||
|             ALTER TABLE products ADD COLUMN price DECIMAL(10,2) DEFAULT 0.00; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const productsTable = result.tables[0]; | ||||
|  | ||||
|         expect(productsTable.columns).toHaveLength(4); | ||||
|  | ||||
|         const nameColumn = productsTable.columns.find( | ||||
|             (col) => col.name === 'name' | ||||
|         ); | ||||
|         expect(nameColumn).toBeDefined(); | ||||
|         expect(nameColumn?.nullable).toBe(false); | ||||
|  | ||||
|         const skuColumn = productsTable.columns.find( | ||||
|             (col) => col.name === 'sku' | ||||
|         ); | ||||
|         expect(skuColumn).toBeDefined(); | ||||
|         expect(skuColumn?.unique).toBe(true); | ||||
|  | ||||
|         const priceColumn = productsTable.columns.find( | ||||
|             (col) => col.name === 'price' | ||||
|         ); | ||||
|         expect(priceColumn).toBeDefined(); | ||||
|         expect(priceColumn?.default).toBe('0'); | ||||
|     }); | ||||
|  | ||||
|     it('should not add duplicate columns', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE items ( | ||||
|                 id INTEGER PRIMARY KEY, | ||||
|                 name VARCHAR(100) | ||||
|             ); | ||||
|  | ||||
|             ALTER TABLE items ADD COLUMN description TEXT; | ||||
|             ALTER TABLE items ADD COLUMN name VARCHAR(200); -- Should not be added as duplicate | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const itemsTable = result.tables[0]; | ||||
|  | ||||
|         // Should only have 3 columns: id, name (original), and description | ||||
|         expect(itemsTable.columns).toHaveLength(3); | ||||
|  | ||||
|         const nameColumns = itemsTable.columns.filter( | ||||
|             (col) => col.name === 'name' | ||||
|         ); | ||||
|         expect(nameColumns).toHaveLength(1); | ||||
|         expect(nameColumns[0].type).toBe('VARCHAR(100)'); // Should keep original type | ||||
|     }); | ||||
|  | ||||
|     it('should use default schema when not specified', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE test_table ( | ||||
|                 id INTEGER PRIMARY KEY | ||||
|             ); | ||||
|  | ||||
|             ALTER TABLE test_table ADD COLUMN value TEXT; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const testTable = result.tables[0]; | ||||
|  | ||||
|         expect(testTable.schema).toBe('public'); | ||||
|         expect(testTable.columns).toHaveLength(2); | ||||
|  | ||||
|         const valueColumn = testTable.columns.find( | ||||
|             (col) => col.name === 'value' | ||||
|         ); | ||||
|         expect(valueColumn).toBeDefined(); | ||||
|     }); | ||||
|  | ||||
|     it('should handle quoted identifiers in ALTER TABLE ADD COLUMN', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE "my-table" ( | ||||
|                 "id" INTEGER PRIMARY KEY | ||||
|             ); | ||||
|  | ||||
|             ALTER TABLE "my-table" ADD COLUMN "my-column" VARCHAR(50); | ||||
|             ALTER TABLE "my-table" ADD COLUMN "another-column" INTEGER; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const myTable = result.tables[0]; | ||||
|  | ||||
|         expect(myTable.name).toBe('my-table'); | ||||
|         expect(myTable.columns).toHaveLength(3); | ||||
|  | ||||
|         const myColumn = myTable.columns.find( | ||||
|             (col) => col.name === 'my-column' | ||||
|         ); | ||||
|         expect(myColumn).toBeDefined(); | ||||
|         expect(myColumn?.type).toBe('VARCHAR(50)'); | ||||
|  | ||||
|         const anotherColumn = myTable.columns.find( | ||||
|             (col) => col.name === 'another-column' | ||||
|         ); | ||||
|         expect(anotherColumn).toBeDefined(); | ||||
|         expect(anotherColumn?.type).toBe('INTEGER'); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,118 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromPostgres } from '../postgresql'; | ||||
|  | ||||
| describe('PostgreSQL ALTER TABLE ALTER COLUMN TYPE', () => { | ||||
|     it('should handle ALTER TABLE ALTER COLUMN TYPE statements', async () => { | ||||
|         const sql = ` | ||||
| CREATE SCHEMA IF NOT EXISTS "public"; | ||||
|  | ||||
| CREATE TABLE "public"."table_12" ( | ||||
|     "id" SERIAL, | ||||
|     "field1" varchar(200), | ||||
|     "field2" varchar(200), | ||||
|     "field3" varchar(200), | ||||
|     PRIMARY KEY ("id") | ||||
| ); | ||||
|  | ||||
| ALTER TABLE table_12 ALTER COLUMN field1 TYPE VARCHAR(254); | ||||
| ALTER TABLE table_12 ALTER COLUMN field2 TYPE VARCHAR(254); | ||||
| ALTER TABLE table_12 ALTER COLUMN field3 TYPE VARCHAR(254); | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const table = result.tables[0]; | ||||
|  | ||||
|         expect(table.name).toBe('table_12'); | ||||
|         expect(table.columns).toHaveLength(4); // id, field1, field2, field3 | ||||
|  | ||||
|         // Check that the columns have the updated type | ||||
|         const field1 = table.columns.find((col) => col.name === 'field1'); | ||||
|         expect(field1).toBeDefined(); | ||||
|         expect(field1?.type).toBe('VARCHAR(254)'); // Should be updated from 200 to 254 | ||||
|  | ||||
|         const field2 = table.columns.find((col) => col.name === 'field2'); | ||||
|         expect(field2).toBeDefined(); | ||||
|         expect(field2?.type).toBe('VARCHAR(254)'); | ||||
|  | ||||
|         const field3 = table.columns.find((col) => col.name === 'field3'); | ||||
|         expect(field3).toBeDefined(); | ||||
|         expect(field3?.type).toBe('VARCHAR(254)'); | ||||
|     }); | ||||
|  | ||||
|     it('should handle various ALTER COLUMN TYPE scenarios', async () => { | ||||
|         const sql = ` | ||||
| CREATE TABLE test_table ( | ||||
|     id INTEGER PRIMARY KEY, | ||||
|     name VARCHAR(50), | ||||
|     age SMALLINT, | ||||
|     score NUMERIC(5,2) | ||||
| ); | ||||
|  | ||||
| -- Change varchar length | ||||
| ALTER TABLE test_table ALTER COLUMN name TYPE VARCHAR(100); | ||||
|  | ||||
| -- Change numeric type | ||||
| ALTER TABLE test_table ALTER COLUMN age TYPE INTEGER; | ||||
|  | ||||
| -- Change precision | ||||
| ALTER TABLE test_table ALTER COLUMN score TYPE NUMERIC(10,4); | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         const table = result.tables[0]; | ||||
|  | ||||
|         const nameCol = table.columns.find((col) => col.name === 'name'); | ||||
|         expect(nameCol?.type).toBe('VARCHAR(100)'); | ||||
|  | ||||
|         const ageCol = table.columns.find((col) => col.name === 'age'); | ||||
|         expect(ageCol?.type).toBe('INTEGER'); | ||||
|  | ||||
|         const scoreCol = table.columns.find((col) => col.name === 'score'); | ||||
|         expect(scoreCol?.type).toBe('NUMERIC(10,4)'); | ||||
|     }); | ||||
|  | ||||
|     it('should handle multiple type changes on the same column', async () => { | ||||
|         const sql = ` | ||||
| CREATE SCHEMA IF NOT EXISTS "public"; | ||||
|  | ||||
| CREATE TABLE "public"."table_12" ( | ||||
|     "id" SERIAL, | ||||
|     "field1" varchar(200), | ||||
|     "field2" varchar(200), | ||||
|     "field3" varchar(200), | ||||
|     PRIMARY KEY ("id") | ||||
| ); | ||||
|  | ||||
| ALTER TABLE table_12 ALTER COLUMN field1 TYPE VARCHAR(254); | ||||
| ALTER TABLE table_12 ALTER COLUMN field2 TYPE VARCHAR(254); | ||||
| ALTER TABLE table_12 ALTER COLUMN field3 TYPE VARCHAR(254); | ||||
| ALTER TABLE table_12 ALTER COLUMN field1 TYPE BIGINT; | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(1); | ||||
|         const table = result.tables[0]; | ||||
|  | ||||
|         expect(table.name).toBe('table_12'); | ||||
|         expect(table.schema).toBe('public'); | ||||
|         expect(table.columns).toHaveLength(4); | ||||
|  | ||||
|         // Check that field1 has the final type (BIGINT), not the intermediate VARCHAR(254) | ||||
|         const field1 = table.columns.find((col) => col.name === 'field1'); | ||||
|         expect(field1).toBeDefined(); | ||||
|         expect(field1?.type).toBe('BIGINT'); // Should be BIGINT, not VARCHAR(254) | ||||
|  | ||||
|         // Check that field2 and field3 still have VARCHAR(254) | ||||
|         const field2 = table.columns.find((col) => col.name === 'field2'); | ||||
|         expect(field2).toBeDefined(); | ||||
|         expect(field2?.type).toBe('VARCHAR(254)'); | ||||
|  | ||||
|         const field3 = table.columns.find((col) => col.name === 'field3'); | ||||
|         expect(field3).toBeDefined(); | ||||
|         expect(field3?.type).toBe('VARCHAR(254)'); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,117 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromPostgres } from '../postgresql'; | ||||
|  | ||||
| describe('PostgreSQL ALTER TABLE with Foreign Keys', () => { | ||||
|     it('should handle ALTER TABLE ADD COLUMN followed by ALTER TABLE ADD FOREIGN KEY', async () => { | ||||
|         const sql = ` | ||||
| CREATE SCHEMA IF NOT EXISTS "public"; | ||||
|  | ||||
| CREATE TABLE "public"."location" ( | ||||
|     "id" bigint NOT NULL, | ||||
|     CONSTRAINT "pk_table_7_id" PRIMARY KEY ("id") | ||||
| ); | ||||
|  | ||||
| -- Add new fields to existing location table | ||||
| ALTER TABLE location ADD COLUMN country_id INT; | ||||
| ALTER TABLE location ADD COLUMN state_id INT; | ||||
| ALTER TABLE location ADD COLUMN location_type_id INT; | ||||
| ALTER TABLE location ADD COLUMN city_id INT; | ||||
| ALTER TABLE location ADD COLUMN street TEXT; | ||||
| ALTER TABLE location ADD COLUMN block TEXT; | ||||
| ALTER TABLE location ADD COLUMN building TEXT; | ||||
| ALTER TABLE location ADD COLUMN floor TEXT; | ||||
| ALTER TABLE location ADD COLUMN apartment TEXT; | ||||
| ALTER TABLE location ADD COLUMN lat INT; | ||||
| ALTER TABLE location ADD COLUMN long INT; | ||||
| ALTER TABLE location ADD COLUMN elevation INT; | ||||
| ALTER TABLE location ADD COLUMN erp_site_id INT; | ||||
| ALTER TABLE location ADD COLUMN is_active TEXT; | ||||
| ALTER TABLE location ADD COLUMN remarks TEXT; | ||||
|  | ||||
| -- Create lookup tables | ||||
| CREATE TABLE country ( | ||||
|     id SERIAL PRIMARY KEY, | ||||
|     name VARCHAR(100) NOT NULL, | ||||
|     code VARCHAR(3) UNIQUE | ||||
| ); | ||||
|  | ||||
| CREATE TABLE state ( | ||||
|     id SERIAL PRIMARY KEY, | ||||
|     name VARCHAR(100) NOT NULL, | ||||
|     country_id INT NOT NULL, | ||||
|     FOREIGN KEY (country_id) REFERENCES country(id) | ||||
| ); | ||||
|  | ||||
| CREATE TABLE location_type ( | ||||
|     id SERIAL PRIMARY KEY, | ||||
|     name VARCHAR(100) NOT NULL | ||||
| ); | ||||
|  | ||||
| CREATE TABLE city ( | ||||
|     id SERIAL PRIMARY KEY, | ||||
|     name VARCHAR(100) NOT NULL, | ||||
|     state_id INT NOT NULL, | ||||
|     FOREIGN KEY (state_id) REFERENCES state(id) | ||||
| ); | ||||
|  | ||||
| -- Add foreign key constraints from location to lookup tables | ||||
| ALTER TABLE location ADD CONSTRAINT fk_location_country  | ||||
|     FOREIGN KEY (country_id) REFERENCES country(id); | ||||
| ALTER TABLE location ADD CONSTRAINT fk_location_state  | ||||
|     FOREIGN KEY (state_id) REFERENCES state(id); | ||||
| ALTER TABLE location ADD CONSTRAINT fk_location_location_type  | ||||
|     FOREIGN KEY (location_type_id) REFERENCES location_type(id); | ||||
| ALTER TABLE location ADD CONSTRAINT fk_location_city  | ||||
|     FOREIGN KEY (city_id) REFERENCES city(id); | ||||
|         `; | ||||
|  | ||||
|         const result = await fromPostgres(sql); | ||||
|  | ||||
|         const locationTable = result.tables.find((t) => t.name === 'location'); | ||||
|  | ||||
|         // Check tables | ||||
|         expect(result.tables).toHaveLength(5); // location, country, state, location_type, city | ||||
|  | ||||
|         // Check location table has all columns | ||||
|         expect(locationTable).toBeDefined(); | ||||
|         expect(locationTable?.columns).toHaveLength(16); // id + 15 added columns | ||||
|  | ||||
|         // Check foreign key relationships | ||||
|         const locationRelationships = result.relationships.filter( | ||||
|             (r) => r.sourceTable === 'location' | ||||
|         ); | ||||
|  | ||||
|         // Should have 4 FKs from location to lookup tables + 2 from state/city | ||||
|         expect(result.relationships.length).toBeGreaterThanOrEqual(6); | ||||
|  | ||||
|         // Check specific foreign keys from location | ||||
|         expect( | ||||
|             locationRelationships.some( | ||||
|                 (r) => | ||||
|                     r.sourceColumn === 'country_id' && | ||||
|                     r.targetTable === 'country' | ||||
|             ) | ||||
|         ).toBe(true); | ||||
|  | ||||
|         expect( | ||||
|             locationRelationships.some( | ||||
|                 (r) => | ||||
|                     r.sourceColumn === 'state_id' && r.targetTable === 'state' | ||||
|             ) | ||||
|         ).toBe(true); | ||||
|  | ||||
|         expect( | ||||
|             locationRelationships.some( | ||||
|                 (r) => | ||||
|                     r.sourceColumn === 'location_type_id' && | ||||
|                     r.targetTable === 'location_type' | ||||
|             ) | ||||
|         ).toBe(true); | ||||
|  | ||||
|         expect( | ||||
|             locationRelationships.some( | ||||
|                 (r) => r.sourceColumn === 'city_id' && r.targetTable === 'city' | ||||
|             ) | ||||
|         ).toBe(true); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,395 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromPostgres } from '../postgresql'; | ||||
|  | ||||
| describe('PostgreSQL Default Value Import', () => { | ||||
|     describe('String Default Values', () => { | ||||
|         it('should parse simple string defaults with single quotes', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE heroes ( | ||||
|                     hero_id INTEGER NOT NULL, | ||||
|                     hero_status CHARACTER VARYING DEFAULT 'questing', | ||||
|                     PRIMARY KEY (hero_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const statusColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'hero_status' | ||||
|             ); | ||||
|             expect(statusColumn?.default).toBe("'questing'"); | ||||
|         }); | ||||
|  | ||||
|         it('should parse string defaults with special characters that need escaping', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE spell_scrolls ( | ||||
|                     scroll_id INTEGER NOT NULL, | ||||
|                     incantation CHARACTER VARYING DEFAULT 'Dragon''s breath', | ||||
|                     rune_inscription TEXT DEFAULT 'Ancient rune | ||||
| Sacred symbol', | ||||
|                     PRIMARY KEY (scroll_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const incantationColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'incantation' | ||||
|             ); | ||||
|             expect(incantationColumn?.default).toBe("'Dragon''s breath'"); | ||||
|         }); | ||||
|  | ||||
|         it('should parse elvish text default values', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE elven_greetings ( | ||||
|                     greeting_id INTEGER NOT NULL, | ||||
|                     elvish_welcome CHARACTER VARYING DEFAULT 'Mae govannen', | ||||
|                     PRIMARY KEY (greeting_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const greetingColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'elvish_welcome' | ||||
|             ); | ||||
|             expect(greetingColumn?.default).toBe("'Mae govannen'"); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Numeric Default Values', () => { | ||||
|         it('should parse integer defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE dragon_hoards ( | ||||
|                     hoard_id INTEGER NOT NULL, | ||||
|                     gold_pieces INTEGER DEFAULT 0, | ||||
|                     max_treasure_value INTEGER DEFAULT 10000, | ||||
|                     PRIMARY KEY (hoard_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const goldColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'gold_pieces' | ||||
|             ); | ||||
|             expect(goldColumn?.default).toBe('0'); | ||||
|             const treasureColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'max_treasure_value' | ||||
|             ); | ||||
|             expect(treasureColumn?.default).toBe('10000'); | ||||
|         }); | ||||
|  | ||||
|         it('should parse decimal defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE enchanted_items ( | ||||
|                     item_id INTEGER NOT NULL, | ||||
|                     market_price DECIMAL(10, 2) DEFAULT 99.99, | ||||
|                     magic_power_rating NUMERIC DEFAULT 0.85, | ||||
|                     PRIMARY KEY (item_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const priceColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'market_price' | ||||
|             ); | ||||
|             expect(priceColumn?.default).toBe('99.99'); | ||||
|             const powerColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'magic_power_rating' | ||||
|             ); | ||||
|             expect(powerColumn?.default).toBe('0.85'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Boolean Default Values', () => { | ||||
|         it('should parse boolean defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE magical_artifacts ( | ||||
|                     artifact_id INTEGER NOT NULL, | ||||
|                     is_cursed BOOLEAN DEFAULT TRUE, | ||||
|                     is_destroyed BOOLEAN DEFAULT FALSE, | ||||
|                     is_legendary BOOLEAN DEFAULT '1', | ||||
|                     is_identified BOOLEAN DEFAULT '0', | ||||
|                     PRIMARY KEY (artifact_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const cursedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_cursed' | ||||
|             ); | ||||
|             expect(cursedColumn?.default).toBe('TRUE'); | ||||
|             const destroyedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_destroyed' | ||||
|             ); | ||||
|             expect(destroyedColumn?.default).toBe('FALSE'); | ||||
|             const legendaryColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_legendary' | ||||
|             ); | ||||
|             expect(legendaryColumn?.default).toBe("'1'"); | ||||
|             const identifiedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_identified' | ||||
|             ); | ||||
|             expect(identifiedColumn?.default).toBe("'0'"); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('NULL Default Values', () => { | ||||
|         it('should parse NULL defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE wizard_familiars ( | ||||
|                     familiar_id INTEGER NOT NULL, | ||||
|                     special_ability CHARACTER VARYING DEFAULT NULL, | ||||
|                     PRIMARY KEY (familiar_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const abilityColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'special_ability' | ||||
|             ); | ||||
|             expect(abilityColumn?.default).toBe('NULL'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Function Default Values', () => { | ||||
|         it('should parse function defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE quest_logs ( | ||||
|                     quest_id UUID DEFAULT gen_random_uuid(), | ||||
|                     quest_started TIMESTAMP DEFAULT NOW(), | ||||
|                     last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP, | ||||
|                     difficulty_roll INTEGER DEFAULT random() | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const questIdColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'quest_id' | ||||
|             ); | ||||
|             expect(questIdColumn?.default).toBe('GEN_RANDOM_UUID()'); | ||||
|             const startedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'quest_started' | ||||
|             ); | ||||
|             expect(startedColumn?.default).toBe('NOW()'); | ||||
|             const updatedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'last_updated' | ||||
|             ); | ||||
|             expect(updatedColumn?.default).toBe('CURRENT_TIMESTAMP'); | ||||
|             const difficultyColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'difficulty_roll' | ||||
|             ); | ||||
|             expect(difficultyColumn?.default).toBe('RANDOM()'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Complex Real-World Example', () => { | ||||
|         it('should handle a complex guild management table correctly', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "realm"( | ||||
|                     "realm_id" integer NOT NULL | ||||
|                 ); | ||||
|  | ||||
|                 CREATE TABLE "guild"( | ||||
|                     "guild_id" CHARACTER VARYING NOT NULL UNIQUE, | ||||
|                     PRIMARY KEY ("guild_id") | ||||
|                 ); | ||||
|  | ||||
|                 CREATE TABLE "guild_schedule"( | ||||
|                     "schedule_id" CHARACTER VARYING NOT NULL UNIQUE, | ||||
|                     PRIMARY KEY ("schedule_id") | ||||
|                 ); | ||||
|  | ||||
|                 CREATE TABLE "guild_quests"( | ||||
|                     "is_active" CHARACTER VARYING NOT NULL DEFAULT 'active', | ||||
|                     "quest_description" CHARACTER VARYING, | ||||
|                     "quest_type" CHARACTER VARYING, | ||||
|                     "quest_status" CHARACTER VARYING DEFAULT 'pending', | ||||
|                     "quest_id" CHARACTER VARYING NOT NULL UNIQUE, | ||||
|                     "reward_gold" CHARACTER VARYING, | ||||
|                     "quest_giver" CHARACTER VARYING, | ||||
|                     "party_size" CHARACTER VARYING, | ||||
|                     "difficulty_level" CHARACTER VARYING, | ||||
|                     "monster_type" CHARACTER VARYING, | ||||
|                     "dungeon_location" CHARACTER VARYING, | ||||
|                     "main_guild_ref" CHARACTER VARYING NOT NULL, | ||||
|                     "schedule_ref" CHARACTER VARYING, | ||||
|                     "last_attempt" CHARACTER VARYING, | ||||
|                     "max_attempts" INTEGER, | ||||
|                     "failed_attempts" INTEGER, | ||||
|                     "party_members" INTEGER, | ||||
|                     "loot_distributor" CHARACTER VARYING, | ||||
|                     "quest_validator" CHARACTER VARYING, | ||||
|                     "scout_report" CHARACTER VARYING, | ||||
|                     "completion_xp" INTEGER, | ||||
|                     "bonus_xp" INTEGER, | ||||
|                     "map_coordinates" CHARACTER VARYING, | ||||
|                     "quest_correlation" CHARACTER VARYING, | ||||
|                     "is_completed" BOOLEAN NOT NULL DEFAULT '0', | ||||
|                     "reward_items" CHARACTER VARYING, | ||||
|                     "quest_priority" INTEGER, | ||||
|                     "started_at" CHARACTER VARYING, | ||||
|                     "status" CHARACTER VARYING, | ||||
|                     "completed_at" CHARACTER VARYING, | ||||
|                     "party_level" INTEGER, | ||||
|                     "quest_master" CHARACTER VARYING, | ||||
|                     PRIMARY KEY ("quest_id"), | ||||
|                     FOREIGN KEY ("main_guild_ref") REFERENCES "guild"("guild_id"), | ||||
|                     FOREIGN KEY ("schedule_ref") REFERENCES "guild_schedule"("schedule_id") | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             // Find the guild_quests table | ||||
|             const questTable = result.tables.find( | ||||
|                 (t) => t.name === 'guild_quests' | ||||
|             ); | ||||
|             expect(questTable).toBeDefined(); | ||||
|  | ||||
|             // Check specific default values | ||||
|             const activeColumn = questTable?.columns.find( | ||||
|                 (c) => c.name === 'is_active' | ||||
|             ); | ||||
|             expect(activeColumn?.default).toBe("'active'"); | ||||
|  | ||||
|             const statusColumn = questTable?.columns.find( | ||||
|                 (c) => c.name === 'quest_status' | ||||
|             ); | ||||
|             expect(statusColumn?.default).toBe("'pending'"); | ||||
|  | ||||
|             const completedColumn = questTable?.columns.find( | ||||
|                 (c) => c.name === 'is_completed' | ||||
|             ); | ||||
|             expect(completedColumn?.default).toBe("'0'"); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('ALTER TABLE ADD COLUMN with defaults', () => { | ||||
|         it('should handle ALTER TABLE ADD COLUMN with default values', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE adventurers ( | ||||
|                     adventurer_id INTEGER NOT NULL, | ||||
|                     PRIMARY KEY (adventurer_id) | ||||
|                 ); | ||||
|                  | ||||
|                 ALTER TABLE adventurers ADD COLUMN class_type VARCHAR(50) DEFAULT 'warrior'; | ||||
|                 ALTER TABLE adventurers ADD COLUMN experience_points INTEGER DEFAULT 0; | ||||
|                 ALTER TABLE adventurers ADD COLUMN is_guild_member BOOLEAN DEFAULT TRUE; | ||||
|                 ALTER TABLE adventurers ADD COLUMN joined_at TIMESTAMP DEFAULT NOW(); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const classColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'class_type' | ||||
|             ); | ||||
|             expect(classColumn?.default).toBe("'warrior'"); | ||||
|  | ||||
|             const xpColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'experience_points' | ||||
|             ); | ||||
|             expect(xpColumn?.default).toBe('0'); | ||||
|  | ||||
|             const guildColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_guild_member' | ||||
|             ); | ||||
|             expect(guildColumn?.default).toBe('TRUE'); | ||||
|  | ||||
|             const joinedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'joined_at' | ||||
|             ); | ||||
|             expect(joinedColumn?.default).toBe('NOW()'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Edge Cases and Special Characters', () => { | ||||
|         it('should handle defaults with parentheses in strings', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE spell_formulas ( | ||||
|                     formula_id INTEGER NOT NULL, | ||||
|                     damage_calculation VARCHAR DEFAULT '(strength + magic) * 2', | ||||
|                     mana_cost TEXT DEFAULT 'cast(level * 10 - wisdom)', | ||||
|                     PRIMARY KEY (formula_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const damageColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'damage_calculation' | ||||
|             ); | ||||
|             expect(damageColumn?.default).toBe("'(strength + magic) * 2'"); | ||||
|             const manaColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'mana_cost' | ||||
|             ); | ||||
|             expect(manaColumn?.default).toBe("'cast(level * 10 - wisdom)'"); | ||||
|         }); | ||||
|  | ||||
|         it('should handle defaults with JSON strings', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE item_enchantments ( | ||||
|                     enchantment_id INTEGER NOT NULL, | ||||
|                     properties JSON DEFAULT '{"element": "fire"}', | ||||
|                     modifiers JSONB DEFAULT '[]', | ||||
|                     PRIMARY KEY (enchantment_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const propertiesColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'properties' | ||||
|             ); | ||||
|             expect(propertiesColumn?.default).toBe(`'{"element": "fire"}'`); | ||||
|             const modifiersColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'modifiers' | ||||
|             ); | ||||
|             expect(modifiersColumn?.default).toBe("'[]'"); | ||||
|         }); | ||||
|  | ||||
|         it('should handle casting in defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE ancient_runes ( | ||||
|                     rune_id INTEGER NOT NULL, | ||||
|                     rune_type VARCHAR DEFAULT 'healing'::text, | ||||
|                     PRIMARY KEY (rune_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const runeColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'rune_type' | ||||
|             ); | ||||
|             expect(runeColumn?.default).toBe("'healing'::text"); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Serial Types', () => { | ||||
|         it('should not set default for SERIAL types as they auto-increment', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE monster_spawns ( | ||||
|                     spawn_id SERIAL PRIMARY KEY, | ||||
|                     minion_id SMALLSERIAL, | ||||
|                     boss_id BIGSERIAL | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromPostgres(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const spawnColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'spawn_id' | ||||
|             ); | ||||
|             expect(spawnColumn?.default).toBeUndefined(); | ||||
|             expect(spawnColumn?.increment).toBe(true); | ||||
|  | ||||
|             const minionColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'minion_id' | ||||
|             ); | ||||
|             expect(minionColumn?.default).toBeUndefined(); | ||||
|             expect(minionColumn?.increment).toBe(true); | ||||
|  | ||||
|             const bossColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'boss_id' | ||||
|             ); | ||||
|             expect(bossColumn?.default).toBeUndefined(); | ||||
|             expect(bossColumn?.increment).toBe(true); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,350 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromPostgres } from '../postgresql'; | ||||
|  | ||||
| describe('PostgreSQL Import - Quoted Identifiers with Special Characters', () => { | ||||
|     describe('CREATE TABLE with quoted identifiers', () => { | ||||
|         it('should handle tables with quoted schema and table names', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "my-schema"."user-profiles" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     name text NOT NULL | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('my-schema'); | ||||
|             expect(table.name).toBe('user-profiles'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle tables with spaces in schema and table names', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "user schema"."profile table" ( | ||||
|                     "user id" integer PRIMARY KEY, | ||||
|                     "full name" varchar(255) | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('user schema'); | ||||
|             expect(table.name).toBe('profile table'); | ||||
|             expect(table.columns).toBeDefined(); | ||||
|             expect(table.columns.length).toBeGreaterThan(0); | ||||
|             // Note: Column names with spaces might be parsed differently | ||||
|         }); | ||||
|  | ||||
|         it('should handle mixed quoted and unquoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "special-schema".users ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|                 CREATE TABLE public."special-table" ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(2); | ||||
|  | ||||
|             expect(result.tables[0].schema).toBe('special-schema'); | ||||
|             expect(result.tables[0].name).toBe('users'); | ||||
|             expect(result.tables[1].schema).toBe('public'); | ||||
|             expect(result.tables[1].name).toBe('special-table'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle tables with dots in names', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "schema.with.dots"."table.with.dots" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     data text | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('schema.with.dots'); | ||||
|             expect(table.name).toBe('table.with.dots'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('FOREIGN KEY with quoted identifiers', () => { | ||||
|         it('should handle inline REFERENCES with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "auth-schema"."users" ( | ||||
|                     "user-id" serial PRIMARY KEY, | ||||
|                     email text UNIQUE | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "app-schema"."user-profiles" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     "user-id" integer REFERENCES "auth-schema"."users"("user-id"), | ||||
|                     bio text | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(2); | ||||
|             expect(result.relationships).toHaveLength(1); | ||||
|  | ||||
|             const relationship = result.relationships[0]; | ||||
|             expect(relationship.sourceTable).toBe('user-profiles'); | ||||
|             expect(relationship.targetTable).toBe('users'); | ||||
|             expect(relationship.sourceColumn).toBe('user-id'); | ||||
|             expect(relationship.targetColumn).toBe('user-id'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle FOREIGN KEY constraints with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "schema one"."table one" ( | ||||
|                     "id field" serial PRIMARY KEY, | ||||
|                     "data field" text | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "schema two"."table two" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     "ref id" integer, | ||||
|                     FOREIGN KEY ("ref id") REFERENCES "schema one"."table one"("id field") | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(2); | ||||
|             expect(result.relationships).toHaveLength(1); | ||||
|  | ||||
|             const relationship = result.relationships[0]; | ||||
|             expect(relationship.sourceTable).toBe('table two'); | ||||
|             expect(relationship.targetTable).toBe('table one'); | ||||
|             expect(relationship.sourceColumn).toBe('ref id'); | ||||
|             expect(relationship.targetColumn).toBe('id field'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle named constraints with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "auth"."users" ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "app"."profiles" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     user_id integer, | ||||
|                     CONSTRAINT "fk-user-profile" FOREIGN KEY (user_id) REFERENCES "auth"."users"(id) | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.relationships).toHaveLength(1); | ||||
|  | ||||
|             const relationship = result.relationships[0]; | ||||
|             // Note: Constraint names with special characters might be normalized | ||||
|             expect(relationship.name).toBeDefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle ALTER TABLE ADD CONSTRAINT with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "user-schema"."user-accounts" ( | ||||
|                     "account-id" serial PRIMARY KEY, | ||||
|                     username text | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "order-schema"."user-orders" ( | ||||
|                     "order-id" serial PRIMARY KEY, | ||||
|                     "account-id" integer | ||||
|                 ); | ||||
|                  | ||||
|                 ALTER TABLE "order-schema"."user-orders"  | ||||
|                 ADD CONSTRAINT "fk_orders_accounts"  | ||||
|                 FOREIGN KEY ("account-id")  | ||||
|                 REFERENCES "user-schema"."user-accounts"("account-id"); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(2); | ||||
|             expect(result.relationships).toHaveLength(1); | ||||
|  | ||||
|             const relationship = result.relationships[0]; | ||||
|             expect(relationship.name).toBe('fk_orders_accounts'); | ||||
|             expect(relationship.sourceTable).toBe('user-orders'); | ||||
|             expect(relationship.targetTable).toBe('user-accounts'); | ||||
|             expect(relationship.sourceColumn).toBe('account-id'); | ||||
|             expect(relationship.targetColumn).toBe('account-id'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle complex mixed quoting scenarios', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE auth.users ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "app-data"."user_profiles" ( | ||||
|                     profile_id serial PRIMARY KEY, | ||||
|                     "user-id" integer REFERENCES auth.users(id) | ||||
|                 ); | ||||
|                  | ||||
|                 CREATE TABLE "app-data".posts ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     profile_id integer | ||||
|                 ); | ||||
|                  | ||||
|                 ALTER TABLE "app-data".posts  | ||||
|                 ADD CONSTRAINT fk_posts_profiles  | ||||
|                 FOREIGN KEY (profile_id)  | ||||
|                 REFERENCES "app-data"."user_profiles"(profile_id); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(3); | ||||
|             expect(result.relationships).toHaveLength(2); | ||||
|  | ||||
|             // Verify the relationships were correctly identified | ||||
|             const profilesTable = result.tables.find( | ||||
|                 (t) => t.name === 'user_profiles' | ||||
|             ); | ||||
|             expect(profilesTable?.schema).toBe('app-data'); | ||||
|  | ||||
|             const postsTable = result.tables.find((t) => t.name === 'posts'); | ||||
|             expect(postsTable?.schema).toBe('app-data'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Edge cases and special scenarios', () => { | ||||
|         it('should handle Unicode characters in quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "схема"."таблица" ( | ||||
|                     "идентификатор" serial PRIMARY KEY, | ||||
|                     "данные" text | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('схема'); | ||||
|             expect(table.name).toBe('таблица'); | ||||
|             expect(table.columns).toBeDefined(); | ||||
|             expect(table.columns.length).toBeGreaterThan(0); | ||||
|         }); | ||||
|  | ||||
|         it('should handle parentheses in quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "schema(prod)"."users(archived)" ( | ||||
|                     id serial PRIMARY KEY, | ||||
|                     data text | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('schema(prod)'); | ||||
|             expect(table.name).toBe('users(archived)'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle forward slashes in quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "api/v1"."users/profiles" ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('api/v1'); | ||||
|             expect(table.name).toBe('users/profiles'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle IF NOT EXISTS with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE IF NOT EXISTS "test-schema"."test-table" ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('test-schema'); | ||||
|             expect(table.name).toBe('test-table'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle ONLY keyword with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE ONLY "parent-schema"."parent-table" ( | ||||
|                     id serial PRIMARY KEY | ||||
|                 ); | ||||
|                  | ||||
|                 ALTER TABLE ONLY "parent-schema"."parent-table" | ||||
|                 ADD CONSTRAINT "unique-constraint" UNIQUE (id); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             // ONLY keyword might trigger warnings | ||||
|             expect(result.warnings).toBeDefined(); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|  | ||||
|             const table = result.tables[0]; | ||||
|             expect(table.schema).toBe('parent-schema'); | ||||
|             expect(table.name).toBe('parent-table'); | ||||
|         }); | ||||
|  | ||||
|         it('should handle self-referencing foreign keys with quoted identifiers', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE "org-schema"."departments" ( | ||||
|                     "dept-id" serial PRIMARY KEY, | ||||
|                     "parent-dept-id" integer REFERENCES "org-schema"."departments"("dept-id"), | ||||
|                     name text | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromPostgres(sql); | ||||
|  | ||||
|             expect(result.warnings || []).toHaveLength(0); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             expect(result.relationships).toHaveLength(1); | ||||
|  | ||||
|             const relationship = result.relationships[0]; | ||||
|             expect(relationship.sourceTable).toBe('departments'); | ||||
|             expect(relationship.targetTable).toBe('departments'); // Self-reference | ||||
|             expect(relationship.sourceColumn).toBe('parent-dept-id'); | ||||
|             expect(relationship.targetColumn).toBe('dept-id'); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -91,7 +91,38 @@ export interface AlterTableExprItem { | ||||
|     action: string; | ||||
|     resource?: string; | ||||
|     type?: string; | ||||
|     keyword?: string; | ||||
|     constraint?: { constraint_type?: string }; | ||||
|     // Properties for ADD COLUMN | ||||
|     column?: | ||||
|         | { | ||||
|               column?: | ||||
|                   | { | ||||
|                         expr?: { | ||||
|                             value?: string; | ||||
|                         }; | ||||
|                     } | ||||
|                   | string; | ||||
|           } | ||||
|         | string | ||||
|         | ColumnReference; | ||||
|     definition?: { | ||||
|         dataType?: string; | ||||
|         length?: number; | ||||
|         precision?: number; | ||||
|         scale?: number; | ||||
|         suffix?: unknown[]; | ||||
|         nullable?: { type: string }; | ||||
|         unique?: string; | ||||
|         primary_key?: string; | ||||
|         constraint?: string; | ||||
|         default_val?: unknown; | ||||
|         auto_increment?: string; | ||||
|     }; | ||||
|     nullable?: { type: string; value?: string }; | ||||
|     unique?: string; | ||||
|     default_val?: unknown; | ||||
|     // Properties for constraints | ||||
|     create_definitions?: | ||||
|         | AlterTableConstraintDefinition | ||||
|         | { | ||||
|   | ||||
| @@ -7,6 +7,8 @@ import type { | ||||
|     SQLForeignKey, | ||||
|     SQLEnumType, | ||||
| } from '../../common'; | ||||
| import { buildSQLFromAST } from '../../common'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { | ||||
|     TableReference, | ||||
|     ColumnReference, | ||||
| @@ -347,13 +349,20 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] { | ||||
|  | ||||
|         // Try to extract column definition | ||||
|         // Match: column_name TYPE[(params)][array] | ||||
|         // Updated regex to handle complex types like GEOGRAPHY(POINT, 4326) and custom types like subscription_status | ||||
|         const columnMatch = trimmedLine.match( | ||||
|             /^\s*["']?(\w+)["']?\s+([\w_]+(?:\([^)]+\))?(?:\[\])?)/i | ||||
|         ); | ||||
|         // First extract column name and everything after it | ||||
|         const columnMatch = trimmedLine.match(/^\s*["']?(\w+)["']?\s+(.+)/i); | ||||
|         if (columnMatch) { | ||||
|             const columnName = columnMatch[1]; | ||||
|             let columnType = columnMatch[2]; | ||||
|             const restOfLine = columnMatch[2]; | ||||
|  | ||||
|             // Now extract the type from the rest of the line | ||||
|             // Match type which could be multi-word (like CHARACTER VARYING) with optional params | ||||
|             const typeMatch = restOfLine.match( | ||||
|                 /^((?:CHARACTER\s+VARYING|DOUBLE\s+PRECISION|[\w]+)(?:\([^)]+\))?(?:\[\])?)/i | ||||
|             ); | ||||
|  | ||||
|             if (!typeMatch) continue; | ||||
|             let columnType = typeMatch[1].trim(); | ||||
|  | ||||
|             // Normalize PostGIS types | ||||
|             if (columnType.toUpperCase().startsWith('GEOGRAPHY')) { | ||||
| @@ -380,7 +389,65 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] { | ||||
|             const isPrimary = trimmedLine.match(/PRIMARY\s+KEY/i) !== null; | ||||
|             const isNotNull = trimmedLine.match(/NOT\s+NULL/i) !== null; | ||||
|             const isUnique = trimmedLine.match(/\bUNIQUE\b/i) !== null; | ||||
|             const hasDefault = trimmedLine.match(/DEFAULT\s+/i) !== null; | ||||
|  | ||||
|             // Extract default value | ||||
|             let defaultValue: string | undefined; | ||||
|             // Updated regex to handle casting with :: operator | ||||
|             const defaultMatch = trimmedLine.match( | ||||
|                 /DEFAULT\s+((?:'[^']*'|"[^"]*"|\S+)(?:::\w+)?)/i | ||||
|             ); | ||||
|             if (defaultMatch) { | ||||
|                 let defVal = defaultMatch[1].trim(); | ||||
|                 // Remove trailing comma if present | ||||
|                 defVal = defVal.replace(/,$/, '').trim(); | ||||
|                 // Handle string literals | ||||
|                 if (defVal.startsWith("'") && defVal.endsWith("'")) { | ||||
|                     // Keep the quotes for string literals | ||||
|                     defaultValue = defVal; | ||||
|                 } else if (defVal.match(/^\d+(\.\d+)?$/)) { | ||||
|                     // Numeric value | ||||
|                     defaultValue = defVal; | ||||
|                 } else if ( | ||||
|                     defVal.toUpperCase() === 'TRUE' || | ||||
|                     defVal.toUpperCase() === 'FALSE' | ||||
|                 ) { | ||||
|                     // Boolean value | ||||
|                     defaultValue = defVal.toUpperCase(); | ||||
|                 } else if (defVal.toUpperCase() === 'NULL') { | ||||
|                     // NULL value | ||||
|                     defaultValue = 'NULL'; | ||||
|                 } else if (defVal.includes('(') && defVal.includes(')')) { | ||||
|                     // Function call (like gen_random_uuid()) | ||||
|                     // Normalize PostgreSQL function names to uppercase | ||||
|                     const funcMatch = defVal.match(/^(\w+)\(/); | ||||
|                     if (funcMatch) { | ||||
|                         const funcName = funcMatch[1]; | ||||
|                         const pgFunctions = [ | ||||
|                             'now', | ||||
|                             'current_timestamp', | ||||
|                             'current_date', | ||||
|                             'current_time', | ||||
|                             'gen_random_uuid', | ||||
|                             'random', | ||||
|                             'nextval', | ||||
|                             'currval', | ||||
|                         ]; | ||||
|                         if (pgFunctions.includes(funcName.toLowerCase())) { | ||||
|                             defaultValue = defVal.replace( | ||||
|                                 funcName, | ||||
|                                 funcName.toUpperCase() | ||||
|                             ); | ||||
|                         } else { | ||||
|                             defaultValue = defVal; | ||||
|                         } | ||||
|                     } else { | ||||
|                         defaultValue = defVal; | ||||
|                     } | ||||
|                 } else { | ||||
|                     // Other expressions | ||||
|                     defaultValue = defVal; | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             columns.push({ | ||||
|                 name: columnName, | ||||
| @@ -388,7 +455,7 @@ function extractColumnsFromSQL(sql: string): SQLColumn[] { | ||||
|                 nullable: !isNotNull && !isPrimary, | ||||
|                 primaryKey: isPrimary, | ||||
|                 unique: isUnique || isPrimary, | ||||
|                 default: hasDefault ? 'has default' : undefined, | ||||
|                 default: defaultValue, | ||||
|                 increment: | ||||
|                     isSerialType || | ||||
|                     trimmedLine.includes('gen_random_uuid()') || | ||||
| @@ -490,16 +557,21 @@ function extractForeignKeysFromCreateTable( | ||||
|  | ||||
|     const tableBody = tableBodyMatch[1]; | ||||
|  | ||||
|     // Pattern for inline REFERENCES - more flexible to handle various formats | ||||
|     // Pattern for inline REFERENCES - handles quoted and unquoted identifiers | ||||
|     const inlineRefPattern = | ||||
|         /["']?(\w+)["']?\s+(?:\w+(?:\([^)]*\))?(?:\[[^\]]*\])?(?:\s+\w+)*\s+)?REFERENCES\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?\s*\(\s*["']?(\w+)["']?\s*\)/gi; | ||||
|         /(?:"([^"]+)"|([^"\s,()]+))\s+(?:\w+(?:\([^)]*\))?(?:\[[^\]]*\])?(?:\s+\w+)*\s+)?REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)/gi; | ||||
|  | ||||
|     let match; | ||||
|     while ((match = inlineRefPattern.exec(tableBody)) !== null) { | ||||
|         const sourceColumn = match[1]; | ||||
|         const targetSchema = match[2] || 'public'; | ||||
|         const targetTable = match[3]; | ||||
|         const targetColumn = match[4]; | ||||
|         // Extract values from appropriate match groups | ||||
|         // Groups: 1=quoted source col, 2=unquoted source col, | ||||
|         //         3=quoted schema, 4=unquoted schema, | ||||
|         //         5=quoted target table, 6=unquoted target table, | ||||
|         //         7=quoted target col, 8=unquoted target col | ||||
|         const sourceColumn = match[1] || match[2]; | ||||
|         const targetSchema = match[3] || match[4] || 'public'; | ||||
|         const targetTable = match[5] || match[6]; | ||||
|         const targetColumn = match[7] || match[8]; | ||||
|  | ||||
|         const targetTableKey = `${targetSchema}.${targetTable}`; | ||||
|         const targetTableId = tableMap[targetTableKey]; | ||||
| @@ -521,15 +593,16 @@ function extractForeignKeysFromCreateTable( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Pattern for FOREIGN KEY constraints | ||||
|     // Pattern for FOREIGN KEY constraints - handles quoted and unquoted identifiers | ||||
|     const fkConstraintPattern = | ||||
|         /FOREIGN\s+KEY\s*\(\s*["']?(\w+)["']?\s*\)\s*REFERENCES\s+(?:["']?(\w+)["']?\.)?["']?(\w+)["']?\s*\(\s*["']?(\w+)["']?\s*\)/gi; | ||||
|         /FOREIGN\s+KEY\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)\s*REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\(\s*(?:"([^"]+)"|([^"\s,)]+))\s*\)/gi; | ||||
|  | ||||
|     while ((match = fkConstraintPattern.exec(tableBody)) !== null) { | ||||
|         const sourceColumn = match[1]; | ||||
|         const targetSchema = match[2] || 'public'; | ||||
|         const targetTable = match[3]; | ||||
|         const targetColumn = match[4]; | ||||
|         // Extract values from appropriate match groups | ||||
|         const sourceColumn = match[1] || match[2]; | ||||
|         const targetSchema = match[3] || match[4] || 'public'; | ||||
|         const targetTable = match[5] || match[6]; | ||||
|         const targetColumn = match[7] || match[8]; | ||||
|  | ||||
|         const targetTableKey = `${targetSchema}.${targetTable}`; | ||||
|         const targetTableId = tableMap[targetTableKey]; | ||||
| @@ -585,12 +658,16 @@ export async function fromPostgres( | ||||
|                     ? stmt.sql.substring(createTableIndex) | ||||
|                     : stmt.sql; | ||||
|  | ||||
|             // Updated regex to properly handle quoted identifiers with special characters | ||||
|             // Matches: schema.table, "schema"."table", "schema".table, schema."table" | ||||
|             const tableMatch = sqlFromCreate.match( | ||||
|                 /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?([^"\s.]+)"?\.)?["'`]?([^"'`\s.(]+)["'`]?/i | ||||
|                 /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))/i | ||||
|             ); | ||||
|             if (tableMatch) { | ||||
|                 const schemaName = tableMatch[1] || 'public'; | ||||
|                 const tableName = tableMatch[2]; | ||||
|                 // Extract schema and table names from the appropriate match groups | ||||
|                 // Groups: 1=quoted schema, 2=unquoted schema, 3=quoted table, 4=unquoted table | ||||
|                 const schemaName = tableMatch[1] || tableMatch[2] || 'public'; | ||||
|                 const tableName = tableMatch[3] || tableMatch[4]; | ||||
|                 const tableKey = `${schemaName}.${tableName}`; | ||||
|                 tableMap[tableKey] = generateId(); | ||||
|             } | ||||
| @@ -938,12 +1015,16 @@ export async function fromPostgres( | ||||
|                     ? stmt.sql.substring(createTableIndex) | ||||
|                     : stmt.sql; | ||||
|  | ||||
|             // Updated regex to properly handle quoted identifiers with special characters | ||||
|             // Matches: schema.table, "schema"."table", "schema".table, schema."table" | ||||
|             const tableMatch = sqlFromCreate.match( | ||||
|                 /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:"?([^"\s.]+)"?\.)?["'`]?([^"'`\s.(]+)["'`]?/i | ||||
|                 /CREATE\s+TABLE(?:\s+IF\s+NOT\s+EXISTS)?(?:\s+ONLY)?\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))/i | ||||
|             ); | ||||
|             if (tableMatch) { | ||||
|                 const schemaName = tableMatch[1] || 'public'; | ||||
|                 const tableName = tableMatch[2]; | ||||
|                 // Extract schema and table names from the appropriate match groups | ||||
|                 // Groups: 1=quoted schema, 2=unquoted schema, 3=quoted table, 4=unquoted table | ||||
|                 const schemaName = tableMatch[1] || tableMatch[2] || 'public'; | ||||
|                 const tableName = tableMatch[3] || tableMatch[4]; | ||||
|                 const tableKey = `${schemaName}.${tableName}`; | ||||
|                 const tableId = tableMap[tableKey]; | ||||
|  | ||||
| @@ -982,7 +1063,7 @@ export async function fromPostgres( | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     // Fourth pass: process ALTER TABLE statements for foreign keys | ||||
|     // Fourth pass: process ALTER TABLE statements for foreign keys and ADD COLUMN | ||||
|     for (const stmt of statements) { | ||||
|         if (stmt.type === 'alter' && stmt.parsed) { | ||||
|             const alterTableStmt = stmt.parsed as AlterTableStatement; | ||||
| @@ -1012,13 +1093,440 @@ export async function fromPostgres( | ||||
|             ); | ||||
|             if (!table) continue; | ||||
|  | ||||
|             // Process foreign key constraints in ALTER TABLE | ||||
|             // Process ALTER TABLE expressions | ||||
|             if (alterTableStmt.expr && Array.isArray(alterTableStmt.expr)) { | ||||
|                 alterTableStmt.expr.forEach((expr: AlterTableExprItem) => { | ||||
|                     if (expr.action === 'add' && expr.create_definitions) { | ||||
|                     // Handle ALTER COLUMN TYPE | ||||
|                     if (expr.action === 'alter' && expr.resource === 'column') { | ||||
|                         // Extract column name | ||||
|                         let columnName: string | undefined; | ||||
|                         if ( | ||||
|                             typeof expr.column === 'object' && | ||||
|                             'column' in expr.column | ||||
|                         ) { | ||||
|                             const innerColumn = expr.column.column; | ||||
|                             if ( | ||||
|                                 typeof innerColumn === 'object' && | ||||
|                                 'expr' in innerColumn && | ||||
|                                 innerColumn.expr?.value | ||||
|                             ) { | ||||
|                                 columnName = innerColumn.expr.value; | ||||
|                             } else if (typeof innerColumn === 'string') { | ||||
|                                 columnName = innerColumn; | ||||
|                             } | ||||
|                         } else if (typeof expr.column === 'string') { | ||||
|                             columnName = expr.column; | ||||
|                         } | ||||
|  | ||||
|                         // Check if it's a TYPE change | ||||
|                         if ( | ||||
|                             columnName && | ||||
|                             expr.type === 'alter' && | ||||
|                             expr.definition?.dataType | ||||
|                         ) { | ||||
|                             // Find the column in the table and update its type | ||||
|                             const column = table.columns.find( | ||||
|                                 (col) => (col as SQLColumn).name === columnName | ||||
|                             ); | ||||
|                             if (column) { | ||||
|                                 const definition = expr.definition; | ||||
|                                 const rawDataType = String(definition.dataType); | ||||
|  | ||||
|                                 // console.log('ALTER TYPE expr:', JSON.stringify(expr, null, 2)); | ||||
|  | ||||
|                                 // Normalize the type | ||||
|                                 let normalizedType = | ||||
|                                     normalizePostgreSQLType(rawDataType); | ||||
|  | ||||
|                                 // Handle type parameters | ||||
|                                 if ( | ||||
|                                     definition.scale !== undefined && | ||||
|                                     definition.scale !== null | ||||
|                                 ) { | ||||
|                                     // For NUMERIC/DECIMAL with scale, length is actually precision | ||||
|                                     const precision = | ||||
|                                         definition.length || | ||||
|                                         definition.precision; | ||||
|                                     normalizedType = `${normalizedType}(${precision},${definition.scale})`; | ||||
|                                 } else if ( | ||||
|                                     definition.length !== undefined && | ||||
|                                     definition.length !== null | ||||
|                                 ) { | ||||
|                                     normalizedType = `${normalizedType}(${definition.length})`; | ||||
|                                 } else if (definition.precision !== undefined) { | ||||
|                                     normalizedType = `${normalizedType}(${definition.precision})`; | ||||
|                                 } else if ( | ||||
|                                     definition.suffix && | ||||
|                                     Array.isArray(definition.suffix) && | ||||
|                                     definition.suffix.length > 0 | ||||
|                                 ) { | ||||
|                                     const params = definition.suffix | ||||
|                                         .map((s: unknown) => { | ||||
|                                             if ( | ||||
|                                                 typeof s === 'object' && | ||||
|                                                 s !== null && | ||||
|                                                 'value' in s | ||||
|                                             ) { | ||||
|                                                 return String(s.value); | ||||
|                                             } | ||||
|                                             return String(s); | ||||
|                                         }) | ||||
|                                         .join(','); | ||||
|                                     normalizedType = `${normalizedType}(${params})`; | ||||
|                                 } | ||||
|  | ||||
|                                 // Update the column type | ||||
|                                 (column as SQLColumn).type = normalizedType; | ||||
|  | ||||
|                                 // Update typeArgs if applicable | ||||
|                                 if ( | ||||
|                                     definition.scale !== undefined && | ||||
|                                     definition.scale !== null | ||||
|                                 ) { | ||||
|                                     // For NUMERIC/DECIMAL with scale | ||||
|                                     const precision = | ||||
|                                         definition.length || | ||||
|                                         definition.precision; | ||||
|                                     (column as SQLColumn).typeArgs = { | ||||
|                                         precision: precision, | ||||
|                                         scale: definition.scale, | ||||
|                                     }; | ||||
|                                 } else if (definition.length) { | ||||
|                                     (column as SQLColumn).typeArgs = { | ||||
|                                         length: definition.length, | ||||
|                                     }; | ||||
|                                 } else if (definition.precision) { | ||||
|                                     (column as SQLColumn).typeArgs = { | ||||
|                                         precision: definition.precision, | ||||
|                                     }; | ||||
|                                 } | ||||
|                             } | ||||
|                         } | ||||
|                         // Handle ADD COLUMN | ||||
|                     } else if ( | ||||
|                         expr.action === 'add' && | ||||
|                         expr.resource === 'column' | ||||
|                     ) { | ||||
|                         // Handle ADD COLUMN directly from expr structure | ||||
|                         // Extract column name from the nested structure | ||||
|                         let columnName: string | undefined; | ||||
|                         if ( | ||||
|                             typeof expr.column === 'object' && | ||||
|                             'column' in expr.column | ||||
|                         ) { | ||||
|                             const innerColumn = expr.column.column; | ||||
|                             if ( | ||||
|                                 typeof innerColumn === 'object' && | ||||
|                                 'expr' in innerColumn && | ||||
|                                 innerColumn.expr?.value | ||||
|                             ) { | ||||
|                                 columnName = innerColumn.expr.value; | ||||
|                             } else if (typeof innerColumn === 'string') { | ||||
|                                 columnName = innerColumn; | ||||
|                             } | ||||
|                         } else if (typeof expr.column === 'string') { | ||||
|                             columnName = expr.column; | ||||
|                         } | ||||
|  | ||||
|                         if (columnName && typeof columnName === 'string') { | ||||
|                             const definition = expr.definition || {}; | ||||
|                             const rawDataType = String( | ||||
|                                 definition?.dataType || 'TEXT' | ||||
|                             ); | ||||
|                             // console.log('expr:', JSON.stringify(expr, null, 2)); | ||||
|  | ||||
|                             // Normalize the type | ||||
|                             let normalizedBaseType = | ||||
|                                 normalizePostgreSQLType(rawDataType); | ||||
|  | ||||
|                             // Check if it's a serial type | ||||
|                             const upperType = rawDataType.toUpperCase(); | ||||
|                             const isSerialType = [ | ||||
|                                 'SERIAL', | ||||
|                                 'SERIAL2', | ||||
|                                 'SERIAL4', | ||||
|                                 'SERIAL8', | ||||
|                                 'BIGSERIAL', | ||||
|                                 'SMALLSERIAL', | ||||
|                             ].includes(upperType.split('(')[0]); | ||||
|  | ||||
|                             if (isSerialType) { | ||||
|                                 const typeLength = definition?.length as | ||||
|                                     | number | ||||
|                                     | undefined; | ||||
|                                 if (upperType === 'SERIAL') { | ||||
|                                     if (typeLength === 2) { | ||||
|                                         normalizedBaseType = 'SMALLINT'; | ||||
|                                     } else if (typeLength === 8) { | ||||
|                                         normalizedBaseType = 'BIGINT'; | ||||
|                                     } else { | ||||
|                                         normalizedBaseType = 'INTEGER'; | ||||
|                                     } | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             // Handle type parameters | ||||
|                             let finalDataType = normalizedBaseType; | ||||
|                             const isNormalizedIntegerType = | ||||
|                                 ['INTEGER', 'BIGINT', 'SMALLINT'].includes( | ||||
|                                     normalizedBaseType | ||||
|                                 ) && | ||||
|                                 (upperType === 'INT' || upperType === 'SERIAL'); | ||||
|  | ||||
|                             if (!isSerialType && !isNormalizedIntegerType) { | ||||
|                                 const precision = definition?.precision; | ||||
|                                 const scale = definition?.scale; | ||||
|                                 const length = definition?.length; | ||||
|                                 const suffix = | ||||
|                                     (definition?.suffix as unknown[]) || []; | ||||
|  | ||||
|                                 if (suffix.length > 0) { | ||||
|                                     const params = suffix | ||||
|                                         .map((s: unknown) => { | ||||
|                                             if ( | ||||
|                                                 typeof s === 'object' && | ||||
|                                                 s !== null && | ||||
|                                                 'value' in s | ||||
|                                             ) { | ||||
|                                                 return String( | ||||
|                                                     (s as { value: unknown }) | ||||
|                                                         .value | ||||
|                                                 ); | ||||
|                                             } | ||||
|                                             return String(s); | ||||
|                                         }) | ||||
|                                         .join(','); | ||||
|                                     finalDataType = `${normalizedBaseType}(${params})`; | ||||
|                                 } else if (precision !== undefined) { | ||||
|                                     if (scale !== undefined) { | ||||
|                                         finalDataType = `${normalizedBaseType}(${precision},${scale})`; | ||||
|                                     } else { | ||||
|                                         finalDataType = `${normalizedBaseType}(${precision})`; | ||||
|                                     } | ||||
|                                 } else if ( | ||||
|                                     length !== undefined && | ||||
|                                     length !== null | ||||
|                                 ) { | ||||
|                                     finalDataType = `${normalizedBaseType}(${length})`; | ||||
|                                 } | ||||
|                             } | ||||
|  | ||||
|                             // Check for nullable constraint | ||||
|                             let nullable = true; | ||||
|                             if (isSerialType) { | ||||
|                                 nullable = false; | ||||
|                             } else if ( | ||||
|                                 expr.nullable && | ||||
|                                 expr.nullable.type === 'not null' | ||||
|                             ) { | ||||
|                                 nullable = false; | ||||
|                             } else if ( | ||||
|                                 definition?.nullable && | ||||
|                                 definition.nullable.type === 'not null' | ||||
|                             ) { | ||||
|                                 nullable = false; | ||||
|                             } | ||||
|  | ||||
|                             // Check for unique constraint | ||||
|                             const isUnique = | ||||
|                                 expr.unique === 'unique' || | ||||
|                                 definition?.unique === 'unique'; | ||||
|  | ||||
|                             // Check for default value | ||||
|                             let defaultValue: string | undefined; | ||||
|                             const defaultVal = | ||||
|                                 expr.default_val || definition?.default_val; | ||||
|                             if (defaultVal && !isSerialType) { | ||||
|                                 // Create a temporary columnDef to use the getDefaultValueString function | ||||
|                                 const tempColumnDef = { | ||||
|                                     default_val: defaultVal, | ||||
|                                 } as ColumnDefinition; | ||||
|                                 defaultValue = | ||||
|                                     getDefaultValueString(tempColumnDef); | ||||
|                             } | ||||
|  | ||||
|                             // Create the new column object | ||||
|                             const newColumn: SQLColumn = { | ||||
|                                 name: columnName, | ||||
|                                 type: finalDataType, | ||||
|                                 nullable: nullable, | ||||
|                                 primaryKey: | ||||
|                                     definition?.primary_key === 'primary key' || | ||||
|                                     definition?.constraint === 'primary key' || | ||||
|                                     isSerialType, | ||||
|                                 unique: isUnique, | ||||
|                                 default: defaultValue, | ||||
|                                 increment: | ||||
|                                     isSerialType || | ||||
|                                     definition?.auto_increment === | ||||
|                                         'auto_increment' || | ||||
|                                     (stmt.sql | ||||
|                                         .toUpperCase() | ||||
|                                         .includes('GENERATED') && | ||||
|                                         stmt.sql | ||||
|                                             .toUpperCase() | ||||
|                                             .includes('IDENTITY')), | ||||
|                             }; | ||||
|  | ||||
|                             // Add the column to the table if it doesn't already exist | ||||
|                             const tableColumns = table.columns as SQLColumn[]; | ||||
|                             if ( | ||||
|                                 !tableColumns.some( | ||||
|                                     (col) => col.name === columnName | ||||
|                                 ) | ||||
|                             ) { | ||||
|                                 tableColumns.push(newColumn); | ||||
|                             } | ||||
|                         } | ||||
|                     } else if ( | ||||
|                         expr.action === 'add' && | ||||
|                         expr.create_definitions | ||||
|                     ) { | ||||
|                         const createDefs = expr.create_definitions; | ||||
|  | ||||
|                         if ( | ||||
|                         // Check if it's adding a column (legacy structure) | ||||
|                         if (createDefs.resource === 'column') { | ||||
|                             const columnDef = | ||||
|                                 createDefs as unknown as ColumnDefinition; | ||||
|                             const columnName = extractColumnName( | ||||
|                                 columnDef.column | ||||
|                             ); | ||||
|  | ||||
|                             if (columnName) { | ||||
|                                 // Extract the column type and properties | ||||
|                                 const definition = | ||||
|                                     columnDef.definition as Record< | ||||
|                                         string, | ||||
|                                         unknown | ||||
|                                     >; | ||||
|                                 const rawDataType = String( | ||||
|                                     definition?.dataType || 'TEXT' | ||||
|                                 ); | ||||
|  | ||||
|                                 // Normalize the type | ||||
|                                 let normalizedBaseType = | ||||
|                                     normalizePostgreSQLType(rawDataType); | ||||
|  | ||||
|                                 // Check if it's a serial type | ||||
|                                 const upperType = rawDataType.toUpperCase(); | ||||
|                                 const isSerialType = [ | ||||
|                                     'SERIAL', | ||||
|                                     'SERIAL2', | ||||
|                                     'SERIAL4', | ||||
|                                     'SERIAL8', | ||||
|                                     'BIGSERIAL', | ||||
|                                     'SMALLSERIAL', | ||||
|                                 ].includes(upperType.split('(')[0]); | ||||
|  | ||||
|                                 if (isSerialType) { | ||||
|                                     const typeLength = definition?.length as | ||||
|                                         | number | ||||
|                                         | undefined; | ||||
|                                     if (upperType === 'SERIAL') { | ||||
|                                         if (typeLength === 2) { | ||||
|                                             normalizedBaseType = 'SMALLINT'; | ||||
|                                         } else if (typeLength === 8) { | ||||
|                                             normalizedBaseType = 'BIGINT'; | ||||
|                                         } else { | ||||
|                                             normalizedBaseType = 'INTEGER'; | ||||
|                                         } | ||||
|                                     } | ||||
|                                 } | ||||
|  | ||||
|                                 // Handle type parameters | ||||
|                                 let finalDataType = normalizedBaseType; | ||||
|                                 const isNormalizedIntegerType = | ||||
|                                     ['INTEGER', 'BIGINT', 'SMALLINT'].includes( | ||||
|                                         normalizedBaseType | ||||
|                                     ) && | ||||
|                                     (upperType === 'INT' || | ||||
|                                         upperType === 'SERIAL'); | ||||
|  | ||||
|                                 if (!isSerialType && !isNormalizedIntegerType) { | ||||
|                                     const precision = | ||||
|                                         columnDef.definition?.precision; | ||||
|                                     const scale = columnDef.definition?.scale; | ||||
|                                     const length = columnDef.definition?.length; | ||||
|                                     const suffix = | ||||
|                                         (definition?.suffix as unknown[]) || []; | ||||
|  | ||||
|                                     if (suffix.length > 0) { | ||||
|                                         const params = suffix | ||||
|                                             .map((s: unknown) => { | ||||
|                                                 if ( | ||||
|                                                     typeof s === 'object' && | ||||
|                                                     s !== null && | ||||
|                                                     'value' in s | ||||
|                                                 ) { | ||||
|                                                     return String( | ||||
|                                                         ( | ||||
|                                                             s as { | ||||
|                                                                 value: unknown; | ||||
|                                                             } | ||||
|                                                         ).value | ||||
|                                                     ); | ||||
|                                                 } | ||||
|                                                 return String(s); | ||||
|                                             }) | ||||
|                                             .join(','); | ||||
|                                         finalDataType = `${normalizedBaseType}(${params})`; | ||||
|                                     } else if (precision !== undefined) { | ||||
|                                         if (scale !== undefined) { | ||||
|                                             finalDataType = `${normalizedBaseType}(${precision},${scale})`; | ||||
|                                         } else { | ||||
|                                             finalDataType = `${normalizedBaseType}(${precision})`; | ||||
|                                         } | ||||
|                                     } else if ( | ||||
|                                         length !== undefined && | ||||
|                                         length !== null | ||||
|                                     ) { | ||||
|                                         finalDataType = `${normalizedBaseType}(${length})`; | ||||
|                                     } | ||||
|                                 } | ||||
|  | ||||
|                                 // Create the new column object | ||||
|                                 const newColumn: SQLColumn = { | ||||
|                                     name: columnName, | ||||
|                                     type: finalDataType, | ||||
|                                     nullable: isSerialType | ||||
|                                         ? false | ||||
|                                         : columnDef.nullable?.type !== | ||||
|                                           'not null', | ||||
|                                     primaryKey: | ||||
|                                         columnDef.primary_key === | ||||
|                                             'primary key' || | ||||
|                                         columnDef.definition?.constraint === | ||||
|                                             'primary key' || | ||||
|                                         isSerialType, | ||||
|                                     unique: columnDef.unique === 'unique', | ||||
|                                     typeArgs: getTypeArgs(columnDef.definition), | ||||
|                                     default: isSerialType | ||||
|                                         ? undefined | ||||
|                                         : getDefaultValueString(columnDef), | ||||
|                                     increment: | ||||
|                                         isSerialType || | ||||
|                                         columnDef.auto_increment === | ||||
|                                             'auto_increment' || | ||||
|                                         (stmt.sql | ||||
|                                             .toUpperCase() | ||||
|                                             .includes('GENERATED') && | ||||
|                                             stmt.sql | ||||
|                                                 .toUpperCase() | ||||
|                                                 .includes('IDENTITY')), | ||||
|                                 }; | ||||
|  | ||||
|                                 // Add the column to the table if it doesn't already exist | ||||
|                                 const tableColumns2 = | ||||
|                                     table.columns as SQLColumn[]; | ||||
|                                 if ( | ||||
|                                     !tableColumns2.some( | ||||
|                                         (col) => col.name === columnName | ||||
|                                     ) | ||||
|                                 ) { | ||||
|                                     tableColumns2.push(newColumn); | ||||
|                                 } | ||||
|                             } | ||||
|                         } else if ( | ||||
|                             createDefs.constraint_type === 'FOREIGN KEY' || | ||||
|                             createDefs.constraint_type === 'foreign key' | ||||
|                         ) { | ||||
| @@ -1129,19 +1637,188 @@ export async function fromPostgres( | ||||
|             } | ||||
|         } else if (stmt.type === 'alter' && !stmt.parsed) { | ||||
|             // Handle ALTER TABLE statements that failed to parse | ||||
|  | ||||
|             // First try to extract ALTER COLUMN TYPE statements | ||||
|             const alterTypeMatch = stmt.sql.match( | ||||
|                 /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ALTER\s+COLUMN\s+(?:"([^"]+)"|([^"\s]+))\s+TYPE\s+([\w_]+(?:\([^)]*\))?(?:\[\])?)/i | ||||
|             ); | ||||
|  | ||||
|             if (alterTypeMatch) { | ||||
|                 const schemaName = | ||||
|                     alterTypeMatch[1] || alterTypeMatch[2] || 'public'; | ||||
|                 const tableName = alterTypeMatch[3] || alterTypeMatch[4]; | ||||
|                 const columnName = alterTypeMatch[5] || alterTypeMatch[6]; | ||||
|                 let columnType = alterTypeMatch[7]; | ||||
|  | ||||
|                 const table = findTableWithSchemaSupport( | ||||
|                     tables, | ||||
|                     tableName, | ||||
|                     schemaName | ||||
|                 ); | ||||
|                 if (table && columnName) { | ||||
|                     const column = (table.columns as SQLColumn[]).find( | ||||
|                         (col) => col.name === columnName | ||||
|                     ); | ||||
|                     if (column) { | ||||
|                         // Normalize and update the type | ||||
|                         columnType = normalizePostgreSQLType(columnType); | ||||
|                         column.type = columnType; | ||||
|  | ||||
|                         // Extract and update typeArgs if present | ||||
|                         const typeMatch = columnType.match( | ||||
|                             /^(\w+)(?:\(([^)]+)\))?$/ | ||||
|                         ); | ||||
|                         if (typeMatch && typeMatch[2]) { | ||||
|                             const params = typeMatch[2] | ||||
|                                 .split(',') | ||||
|                                 .map((p) => p.trim()); | ||||
|                             if (params.length === 1) { | ||||
|                                 column.typeArgs = { | ||||
|                                     length: parseInt(params[0]), | ||||
|                                 }; | ||||
|                             } else if (params.length === 2) { | ||||
|                                 column.typeArgs = { | ||||
|                                     precision: parseInt(params[0]), | ||||
|                                     scale: parseInt(params[1]), | ||||
|                                 }; | ||||
|                             } | ||||
|                         } | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Then try to extract ADD COLUMN statements | ||||
|             const alterColumnMatch = stmt.sql.match( | ||||
|                 /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ADD\s+COLUMN\s+(?:"([^"]+)"|([^"\s]+))\s+([\w_]+(?:\([^)]*\))?(?:\[\])?)/i | ||||
|             ); | ||||
|  | ||||
|             if (alterColumnMatch) { | ||||
|                 const schemaName = | ||||
|                     alterColumnMatch[1] || alterColumnMatch[2] || 'public'; | ||||
|                 const tableName = alterColumnMatch[3] || alterColumnMatch[4]; | ||||
|                 const columnName = alterColumnMatch[5] || alterColumnMatch[6]; | ||||
|                 let columnType = alterColumnMatch[7]; | ||||
|  | ||||
|                 const table = findTableWithSchemaSupport( | ||||
|                     tables, | ||||
|                     tableName, | ||||
|                     schemaName | ||||
|                 ); | ||||
|                 if (table && columnName) { | ||||
|                     const tableColumns = table.columns as SQLColumn[]; | ||||
|                     if (!tableColumns.some((col) => col.name === columnName)) { | ||||
|                         // Normalize the type | ||||
|                         columnType = normalizePostgreSQLType(columnType); | ||||
|  | ||||
|                         // Check for constraints in the statement | ||||
|                         const columnDefPart = stmt.sql.substring( | ||||
|                             stmt.sql.indexOf(columnName) | ||||
|                         ); | ||||
|                         const isPrimary = | ||||
|                             columnDefPart.match(/PRIMARY\s+KEY/i) !== null; | ||||
|                         const isNotNull = | ||||
|                             columnDefPart.match(/NOT\s+NULL/i) !== null; | ||||
|                         const isUnique = | ||||
|                             columnDefPart.match(/\bUNIQUE\b/i) !== null; | ||||
|                         // Extract default value | ||||
|                         let defaultValue: string | undefined; | ||||
|                         // Updated regex to handle casting with :: operator | ||||
|                         const defaultMatch = columnDefPart.match( | ||||
|                             /DEFAULT\s+((?:'[^']*'|"[^"]*"|\S+)(?:::\w+)?)/i | ||||
|                         ); | ||||
|                         if (defaultMatch) { | ||||
|                             let defVal = defaultMatch[1].trim(); | ||||
|                             // Remove trailing comma or semicolon if present | ||||
|                             defVal = defVal.replace(/[,;]$/, '').trim(); | ||||
|                             // Handle string literals | ||||
|                             if ( | ||||
|                                 defVal.startsWith("'") && | ||||
|                                 defVal.endsWith("'") | ||||
|                             ) { | ||||
|                                 // Keep the quotes for string literals | ||||
|                                 defaultValue = defVal; | ||||
|                             } else if (defVal.match(/^\d+(\.\d+)?$/)) { | ||||
|                                 // Numeric value | ||||
|                                 defaultValue = defVal; | ||||
|                             } else if ( | ||||
|                                 defVal.toUpperCase() === 'TRUE' || | ||||
|                                 defVal.toUpperCase() === 'FALSE' | ||||
|                             ) { | ||||
|                                 // Boolean value | ||||
|                                 defaultValue = defVal.toUpperCase(); | ||||
|                             } else if (defVal.toUpperCase() === 'NULL') { | ||||
|                                 // NULL value | ||||
|                                 defaultValue = 'NULL'; | ||||
|                             } else if ( | ||||
|                                 defVal.includes('(') && | ||||
|                                 defVal.includes(')') | ||||
|                             ) { | ||||
|                                 // Function call | ||||
|                                 // Normalize PostgreSQL function names to uppercase | ||||
|                                 const funcMatch = defVal.match(/^(\w+)\(/); | ||||
|                                 if (funcMatch) { | ||||
|                                     const funcName = funcMatch[1]; | ||||
|                                     const pgFunctions = [ | ||||
|                                         'now', | ||||
|                                         'current_timestamp', | ||||
|                                         'current_date', | ||||
|                                         'current_time', | ||||
|                                         'gen_random_uuid', | ||||
|                                         'random', | ||||
|                                         'nextval', | ||||
|                                         'currval', | ||||
|                                     ]; | ||||
|                                     if ( | ||||
|                                         pgFunctions.includes( | ||||
|                                             funcName.toLowerCase() | ||||
|                                         ) | ||||
|                                     ) { | ||||
|                                         defaultValue = defVal.replace( | ||||
|                                             funcName, | ||||
|                                             funcName.toUpperCase() | ||||
|                                         ); | ||||
|                                     } else { | ||||
|                                         defaultValue = defVal; | ||||
|                                     } | ||||
|                                 } else { | ||||
|                                     defaultValue = defVal; | ||||
|                                 } | ||||
|                             } else { | ||||
|                                 // Other expressions | ||||
|                                 defaultValue = defVal; | ||||
|                             } | ||||
|                         } | ||||
|  | ||||
|                         tableColumns.push({ | ||||
|                             name: columnName, | ||||
|                             type: columnType, | ||||
|                             nullable: !isNotNull && !isPrimary, | ||||
|                             primaryKey: isPrimary, | ||||
|                             unique: isUnique || isPrimary, | ||||
|                             default: defaultValue, | ||||
|                             increment: false, | ||||
|                         }); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Extract foreign keys using regex as fallback | ||||
|             // Updated regex to handle quoted identifiers properly | ||||
|             const alterFKMatch = stmt.sql.match( | ||||
|                 /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:"?([^"\s.]+)"?\.)?["']?([^"'\s.(]+)["']?\s+ADD\s+CONSTRAINT\s+["']?([^"'\s]+)["']?\s+FOREIGN\s+KEY\s*\(["']?([^"'\s)]+)["']?\)\s+REFERENCES\s+(?:"?([^"\s.]+)"?\.)?["']?([^"'\s.(]+)["']?\s*\(["']?([^"'\s)]+)["']?\)/i | ||||
|                 /ALTER\s+TABLE\s+(?:ONLY\s+)?(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s+ADD\s+CONSTRAINT\s+(?:"([^"]+)"|([^"\s]+))\s+FOREIGN\s+KEY\s*\((?:"([^"]+)"|([^"\s)]+))\)\s+REFERENCES\s+(?:(?:"([^"]+)"|([^"\s.]+))\.)?(?:"([^"]+)"|([^"\s.(]+))\s*\((?:"([^"]+)"|([^"\s)]+))\)/i | ||||
|             ); | ||||
|  | ||||
|             if (alterFKMatch) { | ||||
|                 const sourceSchema = alterFKMatch[1] || 'public'; | ||||
|                 const sourceTable = alterFKMatch[2]; | ||||
|                 const constraintName = alterFKMatch[3]; | ||||
|                 const sourceColumn = alterFKMatch[4]; | ||||
|                 const targetSchema = alterFKMatch[5] || 'public'; | ||||
|                 const targetTable = alterFKMatch[6]; | ||||
|                 const targetColumn = alterFKMatch[7]; | ||||
|                 // Extract values from appropriate match groups | ||||
|                 const sourceSchema = | ||||
|                     alterFKMatch[1] || alterFKMatch[2] || 'public'; | ||||
|                 const sourceTable = alterFKMatch[3] || alterFKMatch[4]; | ||||
|                 const constraintName = alterFKMatch[5] || alterFKMatch[6]; | ||||
|                 const sourceColumn = alterFKMatch[7] || alterFKMatch[8]; | ||||
|                 const targetSchema = | ||||
|                     alterFKMatch[9] || alterFKMatch[10] || 'public'; | ||||
|                 const targetTable = alterFKMatch[11] || alterFKMatch[12]; | ||||
|                 const targetColumn = alterFKMatch[13] || alterFKMatch[14]; | ||||
|  | ||||
|                 const sourceTableId = getTableIdWithSchemaSupport( | ||||
|                     tableMap, | ||||
| @@ -1275,58 +1952,10 @@ export async function fromPostgres( | ||||
| function getDefaultValueString( | ||||
|     columnDef: ColumnDefinition | ||||
| ): string | undefined { | ||||
|     let defVal = columnDef.default_val; | ||||
|  | ||||
|     if ( | ||||
|         defVal && | ||||
|         typeof defVal === 'object' && | ||||
|         defVal.type === 'default' && | ||||
|         'value' in defVal | ||||
|     ) { | ||||
|         defVal = defVal.value; | ||||
|     } | ||||
|     const defVal = columnDef.default_val; | ||||
|  | ||||
|     if (defVal === undefined || defVal === null) return undefined; | ||||
|  | ||||
|     let value: string | undefined; | ||||
|  | ||||
|     switch (typeof defVal) { | ||||
|         case 'string': | ||||
|             value = defVal; | ||||
|             break; | ||||
|         case 'number': | ||||
|             value = String(defVal); | ||||
|             break; | ||||
|         case 'boolean': | ||||
|             value = defVal ? 'TRUE' : 'FALSE'; | ||||
|             break; | ||||
|         case 'object': | ||||
|             if ('value' in defVal && typeof defVal.value === 'string') { | ||||
|                 value = defVal.value; | ||||
|             } else if ('raw' in defVal && typeof defVal.raw === 'string') { | ||||
|                 value = defVal.raw; | ||||
|             } else if (defVal.type === 'bool') { | ||||
|                 value = defVal.value ? 'TRUE' : 'FALSE'; | ||||
|             } else if (defVal.type === 'function' && defVal.name) { | ||||
|                 const fnName = defVal.name; | ||||
|                 if ( | ||||
|                     fnName && | ||||
|                     typeof fnName === 'object' && | ||||
|                     Array.isArray(fnName.name) && | ||||
|                     fnName.name.length > 0 && | ||||
|                     fnName.name[0].value | ||||
|                 ) { | ||||
|                     value = fnName.name[0].value.toUpperCase(); | ||||
|                 } else if (typeof fnName === 'string') { | ||||
|                     value = fnName.toUpperCase(); | ||||
|                 } else { | ||||
|                     value = 'UNKNOWN_FUNCTION'; | ||||
|                 } | ||||
|             } | ||||
|             break; | ||||
|         default: | ||||
|             value = undefined; | ||||
|     } | ||||
|  | ||||
|     return value; | ||||
|     // Use buildSQLFromAST to reconstruct the default value | ||||
|     return buildSQLFromAST(defVal, DatabaseType.POSTGRESQL); | ||||
| } | ||||
|   | ||||
| @@ -0,0 +1,252 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromSQLServer } from '../sqlserver'; | ||||
|  | ||||
| describe('SQL Server Default Value Import', () => { | ||||
|     describe('String Default Values', () => { | ||||
|         it('should parse simple string defaults with single quotes', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE kingdom_citizens ( | ||||
|                     citizen_id INT NOT NULL, | ||||
|                     allegiance NVARCHAR(50) DEFAULT 'neutral', | ||||
|                     PRIMARY KEY (citizen_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const allegianceColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'allegiance' | ||||
|             ); | ||||
|             expect(allegianceColumn?.default).toBe("'neutral'"); | ||||
|         }); | ||||
|  | ||||
|         it('should parse string defaults with Unicode prefix', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE ancient_scrolls ( | ||||
|                     scroll_id INT NOT NULL, | ||||
|                     runic_inscription NVARCHAR(255) DEFAULT N'Ancient wisdom', | ||||
|                     prophecy NVARCHAR(MAX) DEFAULT N'The chosen one shall rise', | ||||
|                     PRIMARY KEY (scroll_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const runicColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'runic_inscription' | ||||
|             ); | ||||
|             expect(runicColumn?.default).toBe("N'Ancient wisdom'"); | ||||
|             const prophecyColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'prophecy' | ||||
|             ); | ||||
|             expect(prophecyColumn?.default).toBe( | ||||
|                 "N'The chosen one shall rise'" | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Numeric Default Values', () => { | ||||
|         it('should parse integer defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE castle_treasury ( | ||||
|                     treasury_id INT NOT NULL, | ||||
|                     gold_count INT DEFAULT 0, | ||||
|                     max_capacity BIGINT DEFAULT 100000, | ||||
|                     guard_posts SMALLINT DEFAULT 5, | ||||
|                     PRIMARY KEY (treasury_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const goldColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'gold_count' | ||||
|             ); | ||||
|             expect(goldColumn?.default).toBe('0'); | ||||
|             const capacityColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'max_capacity' | ||||
|             ); | ||||
|             expect(capacityColumn?.default).toBe('100000'); | ||||
|             const guardColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'guard_posts' | ||||
|             ); | ||||
|             expect(guardColumn?.default).toBe('5'); | ||||
|         }); | ||||
|  | ||||
|         it('should parse decimal defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE blacksmith_shop ( | ||||
|                     item_id INT NOT NULL, | ||||
|                     weapon_price DECIMAL(10, 2) DEFAULT 99.99, | ||||
|                     guild_discount FLOAT DEFAULT 0.15, | ||||
|                     enchantment_tax NUMERIC(5, 4) DEFAULT 0.0825, | ||||
|                     PRIMARY KEY (item_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const priceColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'weapon_price' | ||||
|             ); | ||||
|             expect(priceColumn?.default).toBe('99.99'); | ||||
|             const discountColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'guild_discount' | ||||
|             ); | ||||
|             expect(discountColumn?.default).toBe('0.15'); | ||||
|             const taxColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'enchantment_tax' | ||||
|             ); | ||||
|             expect(taxColumn?.default).toBe('0.0825'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Boolean Default Values', () => { | ||||
|         it('should parse BIT defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE magic_barriers ( | ||||
|                     barrier_id INT NOT NULL, | ||||
|                     is_active BIT DEFAULT 1, | ||||
|                     is_breached BIT DEFAULT 0, | ||||
|                     PRIMARY KEY (barrier_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const activeColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_active' | ||||
|             ); | ||||
|             expect(activeColumn?.default).toBe('1'); | ||||
|             const breachedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'is_breached' | ||||
|             ); | ||||
|             expect(breachedColumn?.default).toBe('0'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Date and Time Default Values', () => { | ||||
|         it('should parse date/time function defaults', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE battle_logs ( | ||||
|                     battle_id INT NOT NULL, | ||||
|                     battle_started DATETIME DEFAULT GETDATE(), | ||||
|                     last_action DATETIME2 DEFAULT SYSDATETIME(), | ||||
|                     battle_date DATE DEFAULT GETDATE(), | ||||
|                     PRIMARY KEY (battle_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const startedColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'battle_started' | ||||
|             ); | ||||
|             expect(startedColumn?.default).toBe('GETDATE()'); | ||||
|             const actionColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'last_action' | ||||
|             ); | ||||
|             expect(actionColumn?.default).toBe('SYSDATETIME()'); | ||||
|             const dateColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'battle_date' | ||||
|             ); | ||||
|             expect(dateColumn?.default).toBe('GETDATE()'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('IDENTITY columns', () => { | ||||
|         it('should handle IDENTITY columns correctly', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE legendary_weapons ( | ||||
|                     weapon_id INT IDENTITY(1,1) NOT NULL, | ||||
|                     legacy_id BIGINT IDENTITY(100,10) NOT NULL, | ||||
|                     weapon_name NVARCHAR(100), | ||||
|                     PRIMARY KEY (weapon_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const weaponColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'weapon_id' | ||||
|             ); | ||||
|             expect(weaponColumn?.increment).toBe(true); | ||||
|             const legacyColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'legacy_id' | ||||
|             ); | ||||
|             expect(legacyColumn?.increment).toBe(true); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Complex Real-World Example with Schema', () => { | ||||
|         it('should handle complex table with schema and multiple default types', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE [dbo].[QuestContracts] ( | ||||
|                     [ContractID] INT IDENTITY(1,1) NOT NULL, | ||||
|                     [AdventurerID] INT NOT NULL, | ||||
|                     [QuestDate] DATETIME DEFAULT GETDATE(), | ||||
|                     [QuestStatus] NVARCHAR(20) DEFAULT N'Available', | ||||
|                     [RewardAmount] DECIMAL(10, 2) DEFAULT 0.00, | ||||
|                     [IsCompleted] BIT DEFAULT 0, | ||||
|                     [CompletedDate] DATETIME NULL, | ||||
|                     [QuestNotes] NVARCHAR(MAX) DEFAULT NULL, | ||||
|                     [DifficultyLevel] INT DEFAULT 5, | ||||
|                     [QuestGuid] UNIQUEIDENTIFIER DEFAULT NEWID(), | ||||
|                     PRIMARY KEY ([ContractID]) | ||||
|                 ); | ||||
|             `; | ||||
|  | ||||
|             const result = await fromSQLServer(sql); | ||||
|             const table = result.tables[0]; | ||||
|             expect(table).toBeDefined(); | ||||
|             expect(table.schema).toBe('dbo'); | ||||
|  | ||||
|             // Check various default values | ||||
|             const questDateColumn = table.columns.find( | ||||
|                 (c) => c.name === 'QuestDate' | ||||
|             ); | ||||
|             expect(questDateColumn?.default).toBe('GETDATE()'); | ||||
|  | ||||
|             const statusColumn = table.columns.find( | ||||
|                 (c) => c.name === 'QuestStatus' | ||||
|             ); | ||||
|             expect(statusColumn?.default).toBe("N'Available'"); | ||||
|  | ||||
|             const rewardColumn = table.columns.find( | ||||
|                 (c) => c.name === 'RewardAmount' | ||||
|             ); | ||||
|             expect(rewardColumn?.default).toBe('0.00'); | ||||
|  | ||||
|             const completedColumn = table.columns.find( | ||||
|                 (c) => c.name === 'IsCompleted' | ||||
|             ); | ||||
|             expect(completedColumn?.default).toBe('0'); | ||||
|  | ||||
|             const difficultyColumn = table.columns.find( | ||||
|                 (c) => c.name === 'DifficultyLevel' | ||||
|             ); | ||||
|             expect(difficultyColumn?.default).toBe('5'); | ||||
|  | ||||
|             const guidColumn = table.columns.find( | ||||
|                 (c) => c.name === 'QuestGuid' | ||||
|             ); | ||||
|             expect(guidColumn?.default).toBe('NEWID()'); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Expressions in defaults', () => { | ||||
|         it('should handle parentheses in default expressions', async () => { | ||||
|             const sql = ` | ||||
|                 CREATE TABLE spell_calculations ( | ||||
|                     calculation_id INT NOT NULL, | ||||
|                     base_damage INT DEFAULT (10 + 5), | ||||
|                     total_power DECIMAL(10,2) DEFAULT ((100.0 * 0.15) + 10), | ||||
|                     PRIMARY KEY (calculation_id) | ||||
|                 ); | ||||
|             `; | ||||
|             const result = await fromSQLServer(sql); | ||||
|             expect(result.tables).toHaveLength(1); | ||||
|             const damageColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'base_damage' | ||||
|             ); | ||||
|             expect(damageColumn?.default).toBe('(10 + 5)'); | ||||
|             const powerColumn = result.tables[0].columns.find( | ||||
|                 (c) => c.name === 'total_power' | ||||
|             ); | ||||
|             expect(powerColumn?.default).toBe('((100.0 * 0.15) + 10)'); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,91 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromSQLServer } from '../sqlserver'; | ||||
|  | ||||
| describe('SQL Server Complex Fantasy Case', () => { | ||||
|     it('should parse complex SQL with SpellDefinition and SpellComponent tables', async () => { | ||||
|         // Complex SQL with same structure as user's case but fantasy-themed | ||||
|         const sql = `CREATE TABLE [DBO].[SpellDefinition]( | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [HASVERBALCOMP] BOOLEAN,   | ||||
|   [INCANTATION] [VARCHAR](128),   | ||||
|   [INCANTATIONFIX] BOOLEAN,   | ||||
|   [ITSCOMPONENTREL]  [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),  | ||||
|   [SHOWVISUALS] BOOLEAN,    ) ON [PRIMARY] | ||||
|  | ||||
| CREATE TABLE [DBO].[SpellComponent]( | ||||
|   [ALIAS] CHAR (50),     | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [ISOPTIONAL] BOOLEAN,   | ||||
|   [ITSPARENTCOMP]  [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),  | ||||
|   [ITSSCHOOLMETA]  [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),  | ||||
|   [KEYATTR] CHAR (100),  ) ON [PRIMARY]`; | ||||
|  | ||||
|         console.log('Testing complex fantasy SQL...'); | ||||
|         console.log( | ||||
|             'Number of CREATE TABLE statements:', | ||||
|             (sql.match(/CREATE\s+TABLE/gi) || []).length | ||||
|         ); | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         console.log( | ||||
|             'Result tables:', | ||||
|             result.tables.map((t) => t.name) | ||||
|         ); | ||||
|         console.log('Result relationships:', result.relationships.length); | ||||
|  | ||||
|         // Debug: Show actual relationships | ||||
|         if (result.relationships.length === 0) { | ||||
|             console.log('WARNING: No relationships found!'); | ||||
|         } else { | ||||
|             console.log('Relationships found:'); | ||||
|             result.relationships.forEach((r) => { | ||||
|                 console.log( | ||||
|                     `  ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}` | ||||
|                 ); | ||||
|             }); | ||||
|         } | ||||
|  | ||||
|         // Should create TWO tables | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|  | ||||
|         // Check first table | ||||
|         const spellDef = result.tables.find( | ||||
|             (t) => t.name === 'SpellDefinition' | ||||
|         ); | ||||
|         expect(spellDef).toBeDefined(); | ||||
|         expect(spellDef?.schema).toBe('DBO'); | ||||
|         expect(spellDef?.columns).toHaveLength(6); | ||||
|  | ||||
|         // Check second table | ||||
|         const spellComp = result.tables.find( | ||||
|             (t) => t.name === 'SpellComponent' | ||||
|         ); | ||||
|         expect(spellComp).toBeDefined(); | ||||
|         expect(spellComp?.schema).toBe('DBO'); | ||||
|         expect(spellComp?.columns).toHaveLength(6); | ||||
|  | ||||
|         // Check foreign key relationships (should have at least 2) | ||||
|         expect(result.relationships.length).toBeGreaterThanOrEqual(2); | ||||
|  | ||||
|         // Check FK from SpellDefinition to SpellComponent | ||||
|         const fkDefToComp = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'SpellDefinition' && | ||||
|                 r.targetTable === 'SpellComponent' && | ||||
|                 r.sourceColumn === 'itscomponentrel' | ||||
|         ); | ||||
|         expect(fkDefToComp).toBeDefined(); | ||||
|         expect(fkDefToComp?.targetColumn).toBe('SPELLID'); | ||||
|  | ||||
|         // Check self-referential FK in SpellComponent | ||||
|         const selfRefFK = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'SpellComponent' && | ||||
|                 r.targetTable === 'SpellComponent' && | ||||
|                 r.sourceColumn === 'itsparentcomp' | ||||
|         ); | ||||
|         expect(selfRefFK).toBeDefined(); | ||||
|         expect(selfRefFK?.targetColumn).toBe('SPELLID'); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,102 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { sqlImportToDiagram } from '../../../index'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
|  | ||||
| describe('SQL Server Full Import Flow', () => { | ||||
|     it('should create relationships when importing through the full flow', async () => { | ||||
|         const sql = `CREATE TABLE [DBO].[SpellDefinition]( | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [HASVERBALCOMP] BOOLEAN,   | ||||
|   [INCANTATION] [VARCHAR](128),   | ||||
|   [INCANTATIONFIX] BOOLEAN,   | ||||
|   [ITSCOMPONENTREL]  [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),  | ||||
|   [SHOWVISUALS] BOOLEAN,    ) ON [PRIMARY] | ||||
|  | ||||
| CREATE TABLE [DBO].[SpellComponent]( | ||||
|   [ALIAS] CHAR (50),     | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [ISOPTIONAL] BOOLEAN,   | ||||
|   [ITSPARENTCOMP]  [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),  | ||||
|   [ITSSCHOOLMETA]  [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),  | ||||
|   [KEYATTR] CHAR (100),  ) ON [PRIMARY]`; | ||||
|  | ||||
|         // Test the full import flow as the application uses it | ||||
|         const diagram = await sqlImportToDiagram({ | ||||
|             sqlContent: sql, | ||||
|             sourceDatabaseType: DatabaseType.SQL_SERVER, | ||||
|             targetDatabaseType: DatabaseType.SQL_SERVER, | ||||
|         }); | ||||
|  | ||||
|         // Verify tables | ||||
|         expect(diagram.tables).toHaveLength(2); | ||||
|         const tableNames = diagram.tables?.map((t) => t.name).sort(); | ||||
|         expect(tableNames).toEqual(['SpellComponent', 'SpellDefinition']); | ||||
|  | ||||
|         // Verify relationships are created in the diagram | ||||
|         expect(diagram.relationships).toBeDefined(); | ||||
|         expect(diagram.relationships?.length).toBeGreaterThanOrEqual(2); | ||||
|  | ||||
|         // Check specific relationships | ||||
|         const fk1 = diagram.relationships?.find( | ||||
|             (r) => | ||||
|                 r.sourceFieldId && | ||||
|                 r.targetFieldId && // Must have field IDs | ||||
|                 diagram.tables?.some( | ||||
|                     (t) => | ||||
|                         t.id === r.sourceTableId && t.name === 'SpellDefinition' | ||||
|                 ) | ||||
|         ); | ||||
|         expect(fk1).toBeDefined(); | ||||
|  | ||||
|         const fk2 = diagram.relationships?.find( | ||||
|             (r) => | ||||
|                 r.sourceFieldId && | ||||
|                 r.targetFieldId && // Must have field IDs | ||||
|                 diagram.tables?.some( | ||||
|                     (t) => | ||||
|                         t.id === r.sourceTableId && | ||||
|                         t.name === 'SpellComponent' && | ||||
|                         t.id === r.targetTableId // self-reference | ||||
|                 ) | ||||
|         ); | ||||
|         expect(fk2).toBeDefined(); | ||||
|  | ||||
|         console.log( | ||||
|             'Full flow test - Relationships created:', | ||||
|             diagram.relationships?.length | ||||
|         ); | ||||
|         diagram.relationships?.forEach((r) => { | ||||
|             const sourceTable = diagram.tables?.find( | ||||
|                 (t) => t.id === r.sourceTableId | ||||
|             ); | ||||
|             const targetTable = diagram.tables?.find( | ||||
|                 (t) => t.id === r.targetTableId | ||||
|             ); | ||||
|             const sourceField = sourceTable?.fields.find( | ||||
|                 (f) => f.id === r.sourceFieldId | ||||
|             ); | ||||
|             const targetField = targetTable?.fields.find( | ||||
|                 (f) => f.id === r.targetFieldId | ||||
|             ); | ||||
|             console.log( | ||||
|                 `  ${sourceTable?.name}.${sourceField?.name} -> ${targetTable?.name}.${targetField?.name}` | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('should handle case-insensitive field matching', async () => { | ||||
|         const sql = `CREATE TABLE DragonLair ( | ||||
|   [LAIRID] INT PRIMARY KEY, | ||||
|   [parentLairId] INT, FOREIGN KEY (PARENTLAIRID) REFERENCES DragonLair(lairid) | ||||
| )`; | ||||
|  | ||||
|         const diagram = await sqlImportToDiagram({ | ||||
|             sqlContent: sql, | ||||
|             sourceDatabaseType: DatabaseType.SQL_SERVER, | ||||
|             targetDatabaseType: DatabaseType.SQL_SERVER, | ||||
|         }); | ||||
|  | ||||
|         // Should create the self-referential relationship despite case differences | ||||
|         expect(diagram.relationships?.length).toBe(1); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,132 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromSQLServer } from '../sqlserver'; | ||||
|  | ||||
| describe('SQL Server Multiple Tables with Foreign Keys', () => { | ||||
|     it('should parse multiple tables with foreign keys in user format', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE [DBO].[QuestReward]( | ||||
|                 [BOID] (VARCHAR)(32), | ||||
|                 [HASEXTRACOL] BOOLEAN, | ||||
|                 [REWARDCODE] [VARCHAR](128), | ||||
|                 [REWARDFIX] BOOLEAN, | ||||
|                 [ITSQUESTREL] [VARCHAR](32), FOREIGN KEY (itsquestrel) REFERENCES QuestRelation(BOID), | ||||
|                 [SHOWDETAILS] BOOLEAN, | ||||
|             ) ON [PRIMARY] | ||||
|  | ||||
|             CREATE TABLE [DBO].[QuestRelation]( | ||||
|                 [ALIAS] CHAR (50), | ||||
|                 [BOID] (VARCHAR)(32), | ||||
|                 [ISOPTIONAL] BOOLEAN, | ||||
|                 [ITSPARENTREL] [VARCHAR](32), FOREIGN KEY (itsparentrel) REFERENCES QuestRelation(BOID), | ||||
|                 [ITSGUILDMETA] [VARCHAR](32), FOREIGN KEY (itsguildmeta) REFERENCES GuildMeta(BOID), | ||||
|                 [KEYATTR] CHAR (100), | ||||
|             ) ON [PRIMARY] | ||||
|         `; | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         // Should create both tables | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|  | ||||
|         // Check first table | ||||
|         const questReward = result.tables.find((t) => t.name === 'QuestReward'); | ||||
|         expect(questReward).toBeDefined(); | ||||
|         expect(questReward?.schema).toBe('DBO'); | ||||
|         expect(questReward?.columns).toHaveLength(6); | ||||
|  | ||||
|         // Check second table | ||||
|         const questRelation = result.tables.find( | ||||
|             (t) => t.name === 'QuestRelation' | ||||
|         ); | ||||
|         expect(questRelation).toBeDefined(); | ||||
|         expect(questRelation?.schema).toBe('DBO'); | ||||
|         expect(questRelation?.columns).toHaveLength(6); | ||||
|  | ||||
|         // Check foreign key relationships | ||||
|         expect(result.relationships).toHaveLength(2); // Should have 2 FKs (one self-referential in QuestRelation, one from QuestReward to QuestRelation) | ||||
|  | ||||
|         // Check FK from QuestReward to QuestRelation | ||||
|         const fkToRelation = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'QuestReward' && | ||||
|                 r.targetTable === 'QuestRelation' | ||||
|         ); | ||||
|         expect(fkToRelation).toBeDefined(); | ||||
|         expect(fkToRelation?.sourceColumn).toBe('itsquestrel'); | ||||
|         expect(fkToRelation?.targetColumn).toBe('BOID'); | ||||
|  | ||||
|         // Check self-referential FK in QuestRelation | ||||
|         const selfRefFK = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'QuestRelation' && | ||||
|                 r.targetTable === 'QuestRelation' && | ||||
|                 r.sourceColumn === 'itsparentrel' | ||||
|         ); | ||||
|         expect(selfRefFK).toBeDefined(); | ||||
|         expect(selfRefFK?.targetColumn).toBe('BOID'); | ||||
|     }); | ||||
|  | ||||
|     it('should parse multiple tables with circular dependencies', async () => { | ||||
|         const sql = ` | ||||
|             CREATE TABLE [DBO].[Dragon]( | ||||
|                 [DRAGONID] (VARCHAR)(32), | ||||
|                 [NAME] [VARCHAR](100), | ||||
|                 [ITSLAIRREL] [VARCHAR](32), FOREIGN KEY (itslairrel) REFERENCES DragonLair(LAIRID), | ||||
|                 [POWER] INT, | ||||
|             ) ON [PRIMARY] | ||||
|  | ||||
|             CREATE TABLE [DBO].[DragonLair]( | ||||
|                 [LAIRID] (VARCHAR)(32), | ||||
|                 [LOCATION] [VARCHAR](200), | ||||
|                 [ITSGUARDIAN] [VARCHAR](32), FOREIGN KEY (itsguardian) REFERENCES Dragon(DRAGONID), | ||||
|                 [TREASURES] INT, | ||||
|             ) ON [PRIMARY] | ||||
|         `; | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         // Should create both tables despite circular dependency | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|  | ||||
|         const dragon = result.tables.find((t) => t.name === 'Dragon'); | ||||
|         expect(dragon).toBeDefined(); | ||||
|  | ||||
|         const dragonLair = result.tables.find((t) => t.name === 'DragonLair'); | ||||
|         expect(dragonLair).toBeDefined(); | ||||
|  | ||||
|         // Check foreign key relationships (may have one or both depending on parser behavior with circular deps) | ||||
|         expect(result.relationships.length).toBeGreaterThanOrEqual(1); | ||||
|     }); | ||||
|  | ||||
|     it('should handle exact user input format', async () => { | ||||
|         // Exact copy of the user's input with fantasy theme | ||||
|         const sql = `CREATE TABLE [DBO].[WizardDef]( | ||||
|   [BOID]  (VARCHAR)(32),     | ||||
|   [HASEXTRACNTCOL] BOOLEAN,   | ||||
|   [HISTORYCD] [VARCHAR](128),   | ||||
|   [HISTORYCDFIX] BOOLEAN,   | ||||
|   [ITSADWIZARDREL]  [VARCHAR](32), FOREIGN KEY (itsadwizardrel) REFERENCES WizardRel(BOID),  | ||||
|   [SHOWDETAILS] BOOLEAN,    ) ON [PRIMARY] | ||||
|  | ||||
| CREATE TABLE [DBO].[WizardRel]( | ||||
|   [ALIAS] CHAR (50),     | ||||
|   [BOID]  (VARCHAR)(32),     | ||||
|   [ISOPTIONAL] BOOLEAN,   | ||||
|   [ITSARWIZARDREL]  [VARCHAR](32), FOREIGN KEY (itsarwizardrel) REFERENCES WizardRel(BOID),  | ||||
|   [ITSARMETABO]  [VARCHAR](32), FOREIGN KEY (itsarmetabo) REFERENCES MetaBO(BOID),  | ||||
|   [KEYATTR] CHAR (100),  ) ON [PRIMARY]`; | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         // This should create TWO tables, not just one | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|  | ||||
|         const wizardDef = result.tables.find((t) => t.name === 'WizardDef'); | ||||
|         expect(wizardDef).toBeDefined(); | ||||
|         expect(wizardDef?.columns).toHaveLength(6); | ||||
|  | ||||
|         const wizardRel = result.tables.find((t) => t.name === 'WizardRel'); | ||||
|         expect(wizardRel).toBeDefined(); | ||||
|         expect(wizardRel?.columns).toHaveLength(6); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,93 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { fromSQLServer } from '../sqlserver'; | ||||
|  | ||||
| describe('SQL Server FK Verification', () => { | ||||
|     it('should correctly parse FKs from complex fantasy SQL', async () => { | ||||
|         const sql = `CREATE TABLE [DBO].[SpellDefinition]( | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [HASVERBALCOMP] BOOLEAN,   | ||||
|   [INCANTATION] [VARCHAR](128),   | ||||
|   [INCANTATIONFIX] BOOLEAN,   | ||||
|   [ITSCOMPONENTREL]  [VARCHAR](32), FOREIGN KEY (itscomponentrel) REFERENCES SpellComponent(SPELLID),  | ||||
|   [SHOWVISUALS] BOOLEAN,    ) ON [PRIMARY] | ||||
|  | ||||
| CREATE TABLE [DBO].[SpellComponent]( | ||||
|   [ALIAS] CHAR (50),     | ||||
|   [SPELLID]  (VARCHAR)(32),     | ||||
|   [ISOPTIONAL] BOOLEAN,   | ||||
|   [ITSPARENTCOMP]  [VARCHAR](32), FOREIGN KEY (itsparentcomp) REFERENCES SpellComponent(SPELLID),  | ||||
|   [ITSSCHOOLMETA]  [VARCHAR](32), FOREIGN KEY (itsschoolmeta) REFERENCES MagicSchool(SCHOOLID),  | ||||
|   [KEYATTR] CHAR (100),  ) ON [PRIMARY]`; | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         // Verify tables | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|         expect(result.tables.map((t) => t.name).sort()).toEqual([ | ||||
|             'SpellComponent', | ||||
|             'SpellDefinition', | ||||
|         ]); | ||||
|  | ||||
|         // Verify that FKs were found (even if MagicSchool doesn't exist) | ||||
|         // The parsing should find 3 FKs initially, but linkRelationships will filter out the one to MagicSchool | ||||
|         expect(result.relationships.length).toBeGreaterThanOrEqual(2); | ||||
|  | ||||
|         // Verify specific FKs that should exist | ||||
|         const fk1 = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'SpellDefinition' && | ||||
|                 r.sourceColumn.toLowerCase() === 'itscomponentrel' && | ||||
|                 r.targetTable === 'SpellComponent' | ||||
|         ); | ||||
|         expect(fk1).toBeDefined(); | ||||
|         expect(fk1?.targetColumn).toBe('SPELLID'); | ||||
|         expect(fk1?.sourceTableId).toBeTruthy(); | ||||
|         expect(fk1?.targetTableId).toBeTruthy(); | ||||
|  | ||||
|         const fk2 = result.relationships.find( | ||||
|             (r) => | ||||
|                 r.sourceTable === 'SpellComponent' && | ||||
|                 r.sourceColumn.toLowerCase() === 'itsparentcomp' && | ||||
|                 r.targetTable === 'SpellComponent' | ||||
|         ); | ||||
|         expect(fk2).toBeDefined(); | ||||
|         expect(fk2?.targetColumn).toBe('SPELLID'); | ||||
|         expect(fk2?.sourceTableId).toBeTruthy(); | ||||
|         expect(fk2?.targetTableId).toBeTruthy(); | ||||
|  | ||||
|         // Log for debugging | ||||
|         console.log('\n=== FK Verification Results ==='); | ||||
|         console.log( | ||||
|             'Tables:', | ||||
|             result.tables.map((t) => `${t.schema}.${t.name}`) | ||||
|         ); | ||||
|         console.log('Total FKs found:', result.relationships.length); | ||||
|         result.relationships.forEach((r, i) => { | ||||
|             console.log( | ||||
|                 `FK ${i + 1}: ${r.sourceTable}.${r.sourceColumn} -> ${r.targetTable}.${r.targetColumn}` | ||||
|             ); | ||||
|             console.log(`  IDs: ${r.sourceTableId} -> ${r.targetTableId}`); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     it('should parse inline FOREIGN KEY syntax correctly', async () => { | ||||
|         // Simplified test with just one FK to ensure parsing works | ||||
|         const sql = `CREATE TABLE [DBO].[WizardTower]( | ||||
|   [TOWERID] INT, | ||||
|   [MASTERKEY] [VARCHAR](32), FOREIGN KEY (MASTERKEY) REFERENCES ArcaneGuild(GUILDID), | ||||
|   [NAME] VARCHAR(100) | ||||
| ) ON [PRIMARY] | ||||
|  | ||||
| CREATE TABLE [DBO].[ArcaneGuild]( | ||||
|   [GUILDID] [VARCHAR](32), | ||||
|   [GUILDNAME] VARCHAR(100) | ||||
| ) ON [PRIMARY]`; | ||||
|  | ||||
|         const result = await fromSQLServer(sql); | ||||
|  | ||||
|         expect(result.tables).toHaveLength(2); | ||||
|         expect(result.relationships).toHaveLength(1); | ||||
|         expect(result.relationships[0].sourceColumn).toBe('MASTERKEY'); | ||||
|         expect(result.relationships[0].targetColumn).toBe('GUILDID'); | ||||
|     }); | ||||
| }); | ||||
| @@ -342,6 +342,35 @@ function parseCreateTableManually( | ||||
|  | ||||
|     // Process each part (column or constraint) | ||||
|     for (const part of parts) { | ||||
|         // Handle standalone FOREIGN KEY definitions (without CONSTRAINT keyword) | ||||
|         // Format: FOREIGN KEY (column) REFERENCES Table(column) | ||||
|         if (part.match(/^\s*FOREIGN\s+KEY/i)) { | ||||
|             const fkMatch = part.match( | ||||
|                 /FOREIGN\s+KEY\s*\(([^)]+)\)\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i | ||||
|             ); | ||||
|             if (fkMatch) { | ||||
|                 const [ | ||||
|                     , | ||||
|                     sourceCol, | ||||
|                     targetSchema = 'dbo', | ||||
|                     targetTable, | ||||
|                     targetCol, | ||||
|                 ] = fkMatch; | ||||
|                 relationships.push({ | ||||
|                     name: `FK_${tableName}_${sourceCol.trim().replace(/\[|\]/g, '')}`, | ||||
|                     sourceTable: tableName, | ||||
|                     sourceSchema: schema, | ||||
|                     sourceColumn: sourceCol.trim().replace(/\[|\]/g, ''), | ||||
|                     targetTable: targetTable || targetSchema, | ||||
|                     targetSchema: targetTable ? targetSchema : 'dbo', | ||||
|                     targetColumn: targetCol.trim().replace(/\[|\]/g, ''), | ||||
|                     sourceTableId: tableId, | ||||
|                     targetTableId: '', // Will be filled later | ||||
|                 }); | ||||
|             } | ||||
|             continue; | ||||
|         } | ||||
|  | ||||
|         // Handle constraint definitions | ||||
|         if (part.match(/^\s*CONSTRAINT/i)) { | ||||
|             // Parse constraints | ||||
| @@ -435,6 +464,13 @@ function parseCreateTableManually( | ||||
|             columnMatch = part.match(/^\s*(\w+)\s+(\w+)\s+([\d,\s]+)\s+(.*)$/i); | ||||
|         } | ||||
|  | ||||
|         // Handle unusual format: [COLUMN_NAME] (VARCHAR)(32) | ||||
|         if (!columnMatch) { | ||||
|             columnMatch = part.match( | ||||
|                 /^\s*\[?(\w+)\]?\s+\((\w+)\)\s*\(([\d,\s]+|max)\)(.*)$/i | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         if (columnMatch) { | ||||
|             const [, colName, baseType, typeArgs, rest] = columnMatch; | ||||
|  | ||||
| @@ -446,7 +482,37 @@ function parseCreateTableManually( | ||||
|                 const inlineFkMatch = rest.match( | ||||
|                     /FOREIGN\s+KEY\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i | ||||
|                 ); | ||||
|                 if (inlineFkMatch) { | ||||
|  | ||||
|                 // Also check if there's a FOREIGN KEY after a comma with column name | ||||
|                 // Format: , FOREIGN KEY (columnname) REFERENCES Table(column) | ||||
|                 if (!inlineFkMatch && rest.includes('FOREIGN KEY')) { | ||||
|                     const fkWithColumnMatch = rest.match( | ||||
|                         /,\s*FOREIGN\s+KEY\s*\((\w+)\)\s+REFERENCES\s+(?:\[?(\w+)\]?\.)??\[?(\w+)\]?\s*\(([^)]+)\)/i | ||||
|                     ); | ||||
|                     if (fkWithColumnMatch) { | ||||
|                         const [, srcCol, targetSchema, targetTable, targetCol] = | ||||
|                             fkWithColumnMatch; | ||||
|                         // Only process if srcCol matches current colName (case-insensitive) | ||||
|                         if (srcCol.toLowerCase() === colName.toLowerCase()) { | ||||
|                             // Create FK relationship | ||||
|                             relationships.push({ | ||||
|                                 name: `FK_${tableName}_${colName}`, | ||||
|                                 sourceTable: tableName, | ||||
|                                 sourceSchema: schema, | ||||
|                                 sourceColumn: colName, | ||||
|                                 targetTable: targetTable || targetSchema, | ||||
|                                 targetSchema: targetTable | ||||
|                                     ? targetSchema || 'dbo' | ||||
|                                     : 'dbo', | ||||
|                                 targetColumn: targetCol | ||||
|                                     .trim() | ||||
|                                     .replace(/\[|\]/g, ''), | ||||
|                                 sourceTableId: tableId, | ||||
|                                 targetTableId: '', // Will be filled later | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|                 } else if (inlineFkMatch) { | ||||
|                     const [, targetSchema = 'dbo', targetTable, targetCol] = | ||||
|                         inlineFkMatch; | ||||
|                     relationships.push({ | ||||
| @@ -536,10 +602,36 @@ export async function fromSQLServer( | ||||
|     try { | ||||
|         // First, handle ALTER TABLE statements for foreign keys | ||||
|         // Split by GO or semicolon for SQL Server | ||||
|         const statements = sqlContent | ||||
|         let statements = sqlContent | ||||
|             .split(/(?:GO\s*$|;\s*$)/im) | ||||
|             .filter((stmt) => stmt.trim().length > 0); | ||||
|  | ||||
|         // Additional splitting for CREATE TABLE statements that might not be separated by semicolons | ||||
|         // If we have a statement with multiple CREATE TABLE, split them | ||||
|         const expandedStatements: string[] = []; | ||||
|         for (const stmt of statements) { | ||||
|             // Check if this statement contains multiple CREATE TABLE statements | ||||
|             if ((stmt.match(/CREATE\s+TABLE/gi) || []).length > 1) { | ||||
|                 // Split by ") ON [PRIMARY]" followed by CREATE TABLE | ||||
|                 const parts = stmt.split( | ||||
|                     /\)\s*ON\s*\[PRIMARY\]\s*(?=CREATE\s+TABLE)/gi | ||||
|                 ); | ||||
|                 for (let i = 0; i < parts.length; i++) { | ||||
|                     let part = parts[i].trim(); | ||||
|                     // Re-add ") ON [PRIMARY]" to all parts except the last (which should already have it) | ||||
|                     if (i < parts.length - 1 && part.length > 0) { | ||||
|                         part += ') ON [PRIMARY]'; | ||||
|                     } | ||||
|                     if (part.trim().length > 0) { | ||||
|                         expandedStatements.push(part); | ||||
|                     } | ||||
|                 } | ||||
|             } else { | ||||
|                 expandedStatements.push(stmt); | ||||
|             } | ||||
|         } | ||||
|         statements = expandedStatements; | ||||
|  | ||||
|         const alterTableStatements = statements.filter( | ||||
|             (stmt) => | ||||
|                 stmt.trim().toUpperCase().includes('ALTER TABLE') && | ||||
|   | ||||
							
								
								
									
										7273
									
								
								src/lib/dbml/dbml-export/__tests__/cases/1.dbml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										7273
									
								
								src/lib/dbml/dbml-export/__tests__/cases/1.dbml
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										73546
									
								
								src/lib/dbml/dbml-export/__tests__/cases/1.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										73546
									
								
								src/lib/dbml/dbml-export/__tests__/cases/1.json
									
									
									
									
									
										Normal file
									
								
							
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							
							
								
								
									
										17
									
								
								src/lib/dbml/dbml-export/__tests__/cases/2.dbml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										17
									
								
								src/lib/dbml/dbml-export/__tests__/cases/2.dbml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,17 @@ | ||||
| Table "bruit"."100-AAB-CABAS-Mdap" { | ||||
|   "qgs_fid" int [pk, not null] | ||||
|   "geom" geometry | ||||
|   "from" decimal(8,2) | ||||
|   "to" decimal(8,2) | ||||
|   "period" nvarchar(500) | ||||
|   "objectid" float | ||||
|   "insee" float | ||||
|   "nom" nvarchar(500) | ||||
|   "code_posta" int | ||||
|   "ut" nvarchar(500) | ||||
|   "territoire" nvarchar(500) | ||||
|   "surface" float | ||||
|   "perimetre" float | ||||
|   "ccodter" float | ||||
|   "numcom" nvarchar(500) | ||||
| } | ||||
							
								
								
									
										1
									
								
								src/lib/dbml/dbml-export/__tests__/cases/2.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/lib/dbml/dbml-export/__tests__/cases/2.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {"id":"mqqwkkodtkfm","name":"NTP_CANPT-db","createdAt":"2025-08-27T17:03:48.994Z","updatedAt":"2025-08-27T17:12:54.617Z","databaseType":"sql_server","tables":[{"id":"e4qecug35j4b7q75u1j3sdca5","name":"100-AAB-CABAS-Mdap","schema":"bruit","x":100,"y":100,"fields":[{"id":"04liixxb8yenudc6gqjjbgm1r","name":"qgs_fid","type":{"id":"int","name":"int"},"primaryKey":true,"unique":true,"nullable":false,"createdAt":1739267036546},{"id":"hr29n1e1jgybuac3gcerk7jyi","name":"geom","type":{"id":"geometry","name":"geometry"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"jcqh683op52ovfwqwe0w0i2or","name":"from","type":{"id":"decimal","name":"decimal"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"precision":8,"scale":2},{"id":"xev33ok0oqqom2n1tabpp5eds","name":"to","type":{"id":"decimal","name":"decimal"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"precision":8,"scale":2},{"id":"pj36qhdpl0vice9tsyiaaef4l","name":"period","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"l4ce4a68j9h7l46p8dg5qi09u","name":"objectid","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"fi4s2aahfjdeelfkgnrk4q5mk","name":"insee","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"ujsajf0t5xg0td614lpxk32py","name":"nom","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"9j0c54ez2t5dgr0ybzd0ksbuz","name":"code_posta","type":{"id":"int","name":"int"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"gybxvu42odvvjyfoe9zdn7tul","name":"ut","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"qon7xs001v9q8frad6jr9lrho","name":"territoire","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"},{"id":"aeqrfvw5dvig7t8zyjfiri707","name":"surface","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"eqbcy7gfd49a3a6ds6ne6fmzd","name":"perimetre","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"cbxmodo9l3keqxapqnlfjnqy2","name":"ccodter","type":{"id":"float","name":"float"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546},{"id":"c3j131aycof5kgyiypva428l3","name":"numcom","type":{"id":"nvarchar","name":"nvarchar"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1739267036546,"collation":"French_CI_AS"}],"indexes":[],"color":"#8a61f5","isView":false,"isMaterializedView":false,"createdAt":1739267036546,"diagramId":"mqqwkkodtkfm","expanded":true}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]} | ||||
							
								
								
									
										8
									
								
								src/lib/dbml/dbml-export/__tests__/cases/3.dbml
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										8
									
								
								src/lib/dbml/dbml-export/__tests__/cases/3.dbml
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,8 @@ | ||||
| Table "public"."guy_table" { | ||||
|   "id" integer [pk, not null] | ||||
|   "created_at" timestamp [not null] | ||||
|   "column3" text | ||||
|   "arrayfield" text[] | ||||
|   "field_5" "character varying" | ||||
|   "field_6" "character varying(100)" | ||||
| } | ||||
							
								
								
									
										1
									
								
								src/lib/dbml/dbml-export/__tests__/cases/3.json
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										1
									
								
								src/lib/dbml/dbml-export/__tests__/cases/3.json
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1 @@ | ||||
| {"id":"mqqwkkod7trl","name":"guy-db","databaseType":"postgresql","createdAt":"2025-09-10T18:45:32.817Z","updatedAt":"2025-09-10T19:15:21.682Z","tables":[{"id":"g2hv9mlo3qbyjnxdc44j1zxl2","name":"guy_table","schema":"public","x":100,"y":300,"fields":[{"id":"qdqgzmtxsi84ujfuktsvjuop8","name":"id","type":{"id":"integer","name":"integer"},"primaryKey":true,"unique":true,"nullable":false,"createdAt":1757529932816},{"id":"wsys99f86679ch6fbjryw0egr","name":"created_at","type":{"id":"timestamp_without_time_zone","name":"timestamp without time zone"},"primaryKey":false,"unique":false,"nullable":false,"createdAt":1757529932816},{"id":"ro39cba7sd290k90qjgzib8pi","name":"column3","type":{"id":"text","name":"text"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"6cntbu2orwk7kxlg0rcduqgbo","name":"arrayfield","type":{"id":"array","name":"array"},"primaryKey":false,"unique":false,"nullable":true,"createdAt":1757529932816},{"id":"7cz0ybdoov2m3wbgm9tlzatz0","name":"field_5","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531685981},{"id":"zzwlyvqzz93oh0vv8f8qob103","name":"field_6","type":{"id":"character_varying","name":"character varying"},"unique":false,"nullable":true,"primaryKey":false,"createdAt":1757531713961,"characterMaximumLength":"100"}],"indexes":[{"id":"r0w71lnbnje2j9cz1t9j64rya","name":"guy_table_pkey","unique":true,"fieldIds":["qdqgzmtxsi84ujfuktsvjuop8"],"createdAt":1757529932816,"isPrimaryKey":true}],"color":"#8eb7ff","isView":false,"isMaterializedView":false,"createdAt":1757529932816,"diagramId":"mqqwkkod7trl"}],"relationships":[],"dependencies":[],"areas":[],"customTypes":[]} | ||||
| @@ -0,0 +1,438 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { generateDBMLFromDiagram } from '../dbml-export'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import type { DBTable } from '@/lib/domain/db-table'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
|  | ||||
| describe('DBML Export - Fix Multiline Table Names', () => { | ||||
|     // Helper to generate test IDs and timestamps | ||||
|     let idCounter = 0; | ||||
|     const testId = () => `test-id-${++idCounter}`; | ||||
|     const testTime = Date.now(); | ||||
|  | ||||
|     // Helper to create a field | ||||
|     const createField = (overrides: Partial<DBField>): DBField => | ||||
|         ({ | ||||
|             id: testId(), | ||||
|             name: 'field', | ||||
|             type: { id: 'text', name: 'text' }, | ||||
|             primaryKey: false, | ||||
|             nullable: true, | ||||
|             unique: false, | ||||
|             createdAt: testTime, | ||||
|             ...overrides, | ||||
|         }) as DBField; | ||||
|  | ||||
|     // Helper to create a table | ||||
|     const createTable = (overrides: Partial<DBTable>): DBTable => | ||||
|         ({ | ||||
|             id: testId(), | ||||
|             name: 'table', | ||||
|             fields: [], | ||||
|             indexes: [], | ||||
|             createdAt: testTime, | ||||
|             x: 0, | ||||
|             y: 0, | ||||
|             width: 200, | ||||
|             ...overrides, | ||||
|         }) as DBTable; | ||||
|  | ||||
|     // Helper to create a diagram | ||||
|     const createDiagram = (overrides: Partial<Diagram>): Diagram => | ||||
|         ({ | ||||
|             id: testId(), | ||||
|             name: 'diagram', | ||||
|             databaseType: DatabaseType.POSTGRESQL, | ||||
|             tables: [], | ||||
|             relationships: [], | ||||
|             createdAt: testTime, | ||||
|             updatedAt: testTime, | ||||
|             ...overrides, | ||||
|         }) as Diagram; | ||||
|  | ||||
|     describe('DBML Generation with Special Characters', () => { | ||||
|         it('should handle table names with special characters', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         name: 'user-profiles', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                             createField({ | ||||
|                                 name: 'user-name', | ||||
|                                 type: { id: 'varchar', name: 'varchar' }, | ||||
|                                 nullable: true, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should properly quote table names with special characters | ||||
|             expect(result.standardDbml).toContain('Table "user-profiles"'); | ||||
|  | ||||
|             // Field names with special characters should also be quoted | ||||
|             expect(result.standardDbml).toContain('"user-name"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle schema-qualified table names', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         schema: 'my-schema', | ||||
|                         name: 'my-table', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should properly quote schema and table names | ||||
|             expect(result.standardDbml).toContain( | ||||
|                 'Table "my-schema"."my-table"' | ||||
|             ); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle table names with spaces', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         name: 'user profiles', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should properly quote table names with spaces | ||||
|             expect(result.standardDbml).toContain('Table "user profiles"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle schema names with spaces', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         schema: 'my schema', | ||||
|                         name: 'my_table', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should properly quote schema with spaces | ||||
|             expect(result.standardDbml).toContain( | ||||
|                 'Table "my schema"."my_table"' | ||||
|             ); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle table names with dots', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         name: 'app.config', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should properly quote table names with dots | ||||
|             expect(result.standardDbml).toContain('Table "app.config"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should not have line breaks in table declarations', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         schema: 'very-long-schema-name-with-dashes', | ||||
|                         name: 'very-long-table-name-with-special-characters', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Table declaration should be on a single line | ||||
|             const tableDeclarations = | ||||
|                 result.standardDbml.match(/Table\s+[^{]+\{/g) || []; | ||||
|             tableDeclarations.forEach((decl) => { | ||||
|                 // Should not contain newlines before the opening brace | ||||
|                 expect(decl).not.toContain('\n'); | ||||
|             }); | ||||
|  | ||||
|             // The full qualified name should be on one line | ||||
|             expect(result.standardDbml).toMatch( | ||||
|                 /Table\s+"very-long-schema-name-with-dashes"\."very-long-table-name-with-special-characters"\s*\{/ | ||||
|             ); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Multiple tables and relationships', () => { | ||||
|         it('should handle multiple tables with special characters', () => { | ||||
|             const parentTableId = testId(); | ||||
|             const childTableId = testId(); | ||||
|             const parentIdField = testId(); | ||||
|             const childParentIdField = testId(); | ||||
|  | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         id: parentTableId, | ||||
|                         schema: 'auth-schema', | ||||
|                         name: 'user-accounts', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 id: parentIdField, | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'uuid', name: 'uuid' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                     createTable({ | ||||
|                         id: childTableId, | ||||
|                         schema: 'app-schema', | ||||
|                         name: 'user-profiles', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'uuid', name: 'uuid' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                             createField({ | ||||
|                                 id: childParentIdField, | ||||
|                                 name: 'account-id', | ||||
|                                 type: { id: 'uuid', name: 'uuid' }, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|                 relationships: [ | ||||
|                     { | ||||
|                         id: testId(), | ||||
|                         name: 'fk_profiles_accounts', | ||||
|                         sourceTableId: childTableId, | ||||
|                         targetTableId: parentTableId, | ||||
|                         sourceFieldId: childParentIdField, | ||||
|                         targetFieldId: parentIdField, | ||||
|                         sourceCardinality: 'many', | ||||
|                         targetCardinality: 'one', | ||||
|                         createdAt: testTime, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should contain both tables properly quoted | ||||
|             expect(result.standardDbml).toContain( | ||||
|                 'Table "auth-schema"."user-accounts"' | ||||
|             ); | ||||
|             expect(result.standardDbml).toContain( | ||||
|                 'Table "app-schema"."user-profiles"' | ||||
|             ); | ||||
|  | ||||
|             // Should contain the relationship reference | ||||
|             expect(result.standardDbml).toContain('Ref'); | ||||
|  | ||||
|             // Should contain field names with dashes properly quoted | ||||
|             expect(result.standardDbml).toContain('"account-id"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should work correctly with inline DBML format', () => { | ||||
|             const parentTableId = testId(); | ||||
|             const childTableId = testId(); | ||||
|             const parentIdField = testId(); | ||||
|             const childParentIdField = testId(); | ||||
|  | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         id: parentTableId, | ||||
|                         name: 'parent-table', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 id: parentIdField, | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                     createTable({ | ||||
|                         id: childTableId, | ||||
|                         name: 'child-table', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                             createField({ | ||||
|                                 id: childParentIdField, | ||||
|                                 name: 'parent-id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|                 relationships: [ | ||||
|                     { | ||||
|                         id: testId(), | ||||
|                         name: 'fk_child_parent', | ||||
|                         sourceTableId: childTableId, | ||||
|                         targetTableId: parentTableId, | ||||
|                         sourceFieldId: childParentIdField, | ||||
|                         targetFieldId: parentIdField, | ||||
|                         sourceCardinality: 'many', | ||||
|                         targetCardinality: 'one', | ||||
|                         createdAt: testTime, | ||||
|                     }, | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Both standard and inline should be generated | ||||
|             expect(result.standardDbml).toBeDefined(); | ||||
|             expect(result.inlineDbml).toBeDefined(); | ||||
|  | ||||
|             // Inline version should contain inline references | ||||
|             expect(result.inlineDbml).toContain('ref:'); | ||||
|  | ||||
|             // Both should properly quote table names | ||||
|             expect(result.standardDbml).toContain('Table "parent-table"'); | ||||
|             expect(result.inlineDbml).toContain('Table "parent-table"'); | ||||
|             expect(result.standardDbml).toContain('Table "child-table"'); | ||||
|             expect(result.inlineDbml).toContain('Table "child-table"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|     describe('Edge cases', () => { | ||||
|         it('should handle empty table names gracefully', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         name: '', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'id', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should not throw error | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|  | ||||
|         it('should handle Unicode characters in names', () => { | ||||
|             const diagram = createDiagram({ | ||||
|                 tables: [ | ||||
|                     createTable({ | ||||
|                         name: 'użytkownik', | ||||
|                         fields: [ | ||||
|                             createField({ | ||||
|                                 name: 'identyfikator', | ||||
|                                 type: { id: 'integer', name: 'integer' }, | ||||
|                                 primaryKey: true, | ||||
|                                 nullable: false, | ||||
|                             }), | ||||
|                         ], | ||||
|                     }), | ||||
|                 ], | ||||
|             }); | ||||
|  | ||||
|             const result = generateDBMLFromDiagram(diagram); | ||||
|  | ||||
|             // Should handle Unicode characters | ||||
|             expect(result.standardDbml).toContain('Table "użytkownik"'); | ||||
|             expect(result.standardDbml).toContain('"identyfikator"'); | ||||
|  | ||||
|             // Should not have any errors | ||||
|             expect(result.error).toBeUndefined(); | ||||
|         }); | ||||
|     }); | ||||
| }); | ||||
| @@ -0,0 +1,67 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { diagramFromJSONInput } from '@/lib/export-import-utils'; | ||||
| import { generateDBMLFromDiagram } from '../dbml-export'; | ||||
| import * as fs from 'fs'; | ||||
| import * as path from 'path'; | ||||
|  | ||||
| describe('DBML Export - Diagram Case 1 Tests', () => { | ||||
|     it('should handle case 1 diagram', { timeout: 30000 }, async () => { | ||||
|         // Read the JSON file | ||||
|         const jsonPath = path.join(__dirname, 'cases', '1.json'); | ||||
|         const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); | ||||
|  | ||||
|         // Parse the JSON and convert to diagram | ||||
|         const diagram = diagramFromJSONInput(jsonContent); | ||||
|  | ||||
|         // Generate DBML from the diagram | ||||
|         const result = generateDBMLFromDiagram(diagram); | ||||
|         const generatedDBML = result.standardDbml; | ||||
|  | ||||
|         // Read the expected DBML file | ||||
|         const dbmlPath = path.join(__dirname, 'cases', '1.dbml'); | ||||
|         const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); | ||||
|  | ||||
|         // Compare the generated DBML with the expected DBML | ||||
|         expect(generatedDBML).toBe(expectedDBML); | ||||
|     }); | ||||
|  | ||||
|     it('should handle case 2 diagram', { timeout: 30000 }, async () => { | ||||
|         // Read the JSON file | ||||
|         const jsonPath = path.join(__dirname, 'cases', '2.json'); | ||||
|         const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); | ||||
|  | ||||
|         // Parse the JSON and convert to diagram | ||||
|         const diagram = diagramFromJSONInput(jsonContent); | ||||
|  | ||||
|         // Generate DBML from the diagram | ||||
|         const result = generateDBMLFromDiagram(diagram); | ||||
|         const generatedDBML = result.standardDbml; | ||||
|  | ||||
|         // Read the expected DBML file | ||||
|         const dbmlPath = path.join(__dirname, 'cases', '2.dbml'); | ||||
|         const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); | ||||
|  | ||||
|         // Compare the generated DBML with the expected DBML | ||||
|         expect(generatedDBML).toBe(expectedDBML); | ||||
|     }); | ||||
|  | ||||
|     it('should handle case 3 diagram', { timeout: 30000 }, async () => { | ||||
|         // Read the JSON file | ||||
|         const jsonPath = path.join(__dirname, 'cases', '3.json'); | ||||
|         const jsonContent = fs.readFileSync(jsonPath, 'utf-8'); | ||||
|  | ||||
|         // Parse the JSON and convert to diagram | ||||
|         const diagram = diagramFromJSONInput(jsonContent); | ||||
|  | ||||
|         // Generate DBML from the diagram | ||||
|         const result = generateDBMLFromDiagram(diagram); | ||||
|         const generatedDBML = result.standardDbml; | ||||
|  | ||||
|         // Read the expected DBML file | ||||
|         const dbmlPath = path.join(__dirname, 'cases', '3.dbml'); | ||||
|         const expectedDBML = fs.readFileSync(dbmlPath, 'utf-8'); | ||||
|  | ||||
|         // Compare the generated DBML with the expected DBML | ||||
|         expect(generatedDBML).toBe(expectedDBML); | ||||
|     }); | ||||
| }); | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { importer } from '@dbml/core'; | ||||
| import { exportBaseSQL } from '@/lib/data/export-metadata/export-sql-script'; | ||||
| import { exportBaseSQL } from '@/lib/data/sql-export/export-sql-script'; | ||||
| import type { Diagram } from '@/lib/domain/diagram'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import type { DBTable } from '@/lib/domain/db-table'; | ||||
| @@ -596,6 +596,13 @@ const normalizeCharTypeFormat = (dbml: string): string => { | ||||
|         .replace(/character \(([0-9]+)\)/g, 'character($1)'); | ||||
| }; | ||||
|  | ||||
| // Fix array types that are incorrectly quoted by DBML importer | ||||
| const fixArrayTypes = (dbml: string): string => { | ||||
|     // Remove quotes around array types like "text[]" -> text[] | ||||
|     // Matches patterns like: "fieldname" "type[]" and replaces with "fieldname" type[] | ||||
|     return dbml.replace(/(\s+"[^"]+"\s+)"([^"\s]+\[\])"/g, '$1$2'); | ||||
| }; | ||||
|  | ||||
| // Fix table definitions with incorrect bracket syntax | ||||
| const fixTableBracketSyntax = (dbml: string): string => { | ||||
|     // Fix patterns like Table [schema].[table] to Table "schema"."table" | ||||
| @@ -605,6 +612,23 @@ const fixTableBracketSyntax = (dbml: string): string => { | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| // Fix table names that have been broken across multiple lines | ||||
| const fixMultilineTableNames = (dbml: string): string => { | ||||
|     // Match Table declarations that might have line breaks in the table name | ||||
|     // This regex captures: | ||||
|     // - Table keyword | ||||
|     // - Optional quoted schema with dot | ||||
|     // - Table name that might be broken across lines (until the opening brace) | ||||
|     return dbml.replace( | ||||
|         /Table\s+((?:"[^"]*"\.)?"[^"]*(?:\n[^"]*)*")\s*\{/g, | ||||
|         (_, tableName) => { | ||||
|             // Remove line breaks within the table name | ||||
|             const fixedTableName = tableName.replace(/\n\s*/g, ''); | ||||
|             return `Table ${fixedTableName} {`; | ||||
|         } | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| // Restore composite primary key names in the DBML | ||||
| const restoreCompositePKNames = (dbml: string, tables: DBTable[]): string => { | ||||
|     if (!tables || tables.length === 0) return dbml; | ||||
| @@ -968,11 +992,15 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { | ||||
|             ); | ||||
|         } | ||||
|  | ||||
|         standard = normalizeCharTypeFormat( | ||||
|             fixTableBracketSyntax( | ||||
|                 importer.import( | ||||
|                     baseScript, | ||||
|                     databaseTypeToImportFormat(diagram.databaseType) | ||||
|         standard = fixArrayTypes( | ||||
|             normalizeCharTypeFormat( | ||||
|                 fixMultilineTableNames( | ||||
|                     fixTableBracketSyntax( | ||||
|                         importer.import( | ||||
|                             baseScript, | ||||
|                             databaseTypeToImportFormat(diagram.databaseType) | ||||
|                         ) | ||||
|                     ) | ||||
|                 ) | ||||
|             ) | ||||
|         ); | ||||
| @@ -988,7 +1016,9 @@ export function generateDBMLFromDiagram(diagram: Diagram): DBMLExportResult { | ||||
|             standard = enumsDBML + '\n\n' + standard; | ||||
|         } | ||||
|  | ||||
|         inline = normalizeCharTypeFormat(convertToInlineRefs(standard)); | ||||
|         inline = fixArrayTypes( | ||||
|             normalizeCharTypeFormat(convertToInlineRefs(standard)) | ||||
|         ); | ||||
|  | ||||
|         // Clean up excessive empty lines in both outputs | ||||
|         standard = standard.replace(/\n\s*\n\s*\n/g, '\n\n'); | ||||
|   | ||||
| @@ -1,8 +1,8 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { importDBMLToDiagram } from '../dbml-import'; | ||||
| import { exportPostgreSQL } from '@/lib/data/export-metadata/export-per-type/postgresql'; | ||||
| import { exportMySQL } from '@/lib/data/export-metadata/export-per-type/mysql'; | ||||
| import { exportMSSQL } from '@/lib/data/export-metadata/export-per-type/mssql'; | ||||
| import { exportPostgreSQL } from '@/lib/data/sql-export/export-per-type/postgresql'; | ||||
| import { exportMySQL } from '@/lib/data/sql-export/export-per-type/mysql'; | ||||
| import { exportMSSQL } from '@/lib/data/sql-export/export-per-type/mssql'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
|  | ||||
| describe('Composite Primary Key with Name', () => { | ||||
|   | ||||
| @@ -154,7 +154,7 @@ Note note_1750185617764 { | ||||
|  | ||||
|             // Should not throw | ||||
|             const parser = new Parser(); | ||||
|             expect(() => parser.parse(sanitized, 'dbml')).not.toThrow(); | ||||
|             expect(() => parser.parse(sanitized, 'dbmlv2')).not.toThrow(); | ||||
|         }); | ||||
|     }); | ||||
|  | ||||
|   | ||||
| @@ -1,3 +1,5 @@ | ||||
| import type { CompilerError } from '@dbml/core/types/parse/error'; | ||||
|  | ||||
| export interface DBMLError { | ||||
|     message: string; | ||||
|     line: number; | ||||
| @@ -9,28 +11,12 @@ export function parseDBMLError(error: unknown): DBMLError | null { | ||||
|         if (typeof error === 'string') { | ||||
|             const parsed = JSON.parse(error); | ||||
|             if (parsed.diags?.[0]) { | ||||
|                 const diag = parsed.diags[0]; | ||||
|  | ||||
|                 return { | ||||
|                     message: diag.message, | ||||
|                     line: diag.location.start.line, | ||||
|                     column: diag.location.start.column, | ||||
|                 }; | ||||
|                 const parsedError = parsed as CompilerError; | ||||
|                 return getFirstErrorFromCompileError(parsedError); | ||||
|             } | ||||
|         } else if (error && typeof error === 'object' && 'diags' in error) { | ||||
|             const parsed = error as { | ||||
|                 diags: Array<{ | ||||
|                     message: string; | ||||
|                     location: { start: { line: number; column: number } }; | ||||
|                 }>; | ||||
|             }; | ||||
|             if (parsed.diags?.[0]) { | ||||
|                 return { | ||||
|                     message: parsed.diags[0].message, | ||||
|                     line: parsed.diags[0].location.start.line, | ||||
|                     column: parsed.diags[0].location.start.column, | ||||
|                 }; | ||||
|             } | ||||
|             const parsed = error as CompilerError; | ||||
|             return getFirstErrorFromCompileError(parsed); | ||||
|         } | ||||
|     } catch (e) { | ||||
|         console.error('Error parsing DBML error:', e); | ||||
| @@ -38,3 +24,25 @@ export function parseDBMLError(error: unknown): DBMLError | null { | ||||
|  | ||||
|     return null; | ||||
| } | ||||
|  | ||||
| const getFirstErrorFromCompileError = ( | ||||
|     error: CompilerError | ||||
| ): DBMLError | null => { | ||||
|     const diags = (error.diags ?? []).sort((a, b) => { | ||||
|         if (a.location.start.line === b.location.start.line) { | ||||
|             return a.location.start.column - b.location.start.column; | ||||
|         } | ||||
|         return a.location.start.line - b.location.start.line; | ||||
|     }); | ||||
|  | ||||
|     if (diags.length > 0) { | ||||
|         const firstDiag = diags[0]; | ||||
|         return { | ||||
|             message: firstDiag.message, | ||||
|             line: firstDiag.location.start.line, | ||||
|             column: firstDiag.location.start.column, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     return null; | ||||
| }; | ||||
|   | ||||
| @@ -223,7 +223,7 @@ export const importDBMLToDiagram = async ( | ||||
|             }; | ||||
|         } | ||||
|  | ||||
|         const parsedData = parser.parse(sanitizedContent, 'dbml'); | ||||
|         const parsedData = parser.parse(sanitizedContent, 'dbmlv2'); | ||||
|  | ||||
|         // Handle case where no schemas are found | ||||
|         if (!parsedData.schemas || parsedData.schemas.length === 0) { | ||||
|   | ||||
| @@ -1,5 +1,5 @@ | ||||
| import { describe, it, expect } from 'vitest'; | ||||
| import { createTablesFromMetadata } from '../db-table'; | ||||
| import { createTablesFromMetadata } from '@/lib/data/import-metadata/import/tables'; | ||||
| import { DatabaseType } from '../database-type'; | ||||
| import type { DatabaseMetadata } from '@/lib/data/import-metadata/metadata-types/database-metadata'; | ||||
|  | ||||
|   | ||||
| @@ -1,7 +1,4 @@ | ||||
| import { z } from 'zod'; | ||||
| import type { DBCustomTypeInfo } from '@/lib/data/import-metadata/metadata-types/custom-type-info'; | ||||
| import { generateId } from '../utils'; | ||||
| import { schemaNameToDomainSchemaName } from './db-schema'; | ||||
|  | ||||
| export enum DBCustomTypeKind { | ||||
|     enum = 'enum', | ||||
| @@ -15,12 +12,12 @@ export interface DBCustomTypeField { | ||||
|  | ||||
| export interface DBCustomType { | ||||
|     id: string; | ||||
|     schema?: string; | ||||
|     schema?: string | null; | ||||
|     name: string; | ||||
|     kind: DBCustomTypeKind; | ||||
|     values?: string[]; // For enum types | ||||
|     fields?: DBCustomTypeField[]; // For composite types | ||||
|     order?: number; | ||||
|     values?: string[] | null; // For enum types | ||||
|     fields?: DBCustomTypeField[] | null; // For composite types | ||||
|     order?: number | null; | ||||
| } | ||||
|  | ||||
| export const dbCustomTypeFieldSchema = z.object({ | ||||
| @@ -30,30 +27,14 @@ export const dbCustomTypeFieldSchema = z.object({ | ||||
|  | ||||
| export const dbCustomTypeSchema: z.ZodType<DBCustomType> = z.object({ | ||||
|     id: z.string(), | ||||
|     schema: z.string(), | ||||
|     schema: z.string().or(z.null()).optional(), | ||||
|     name: z.string(), | ||||
|     kind: z.nativeEnum(DBCustomTypeKind), | ||||
|     values: z.array(z.string()).optional(), | ||||
|     fields: z.array(dbCustomTypeFieldSchema).optional(), | ||||
|     values: z.array(z.string()).or(z.null()).optional(), | ||||
|     fields: z.array(dbCustomTypeFieldSchema).or(z.null()).optional(), | ||||
|     order: z.number().or(z.null()).optional(), | ||||
| }); | ||||
|  | ||||
| export const createCustomTypesFromMetadata = ({ | ||||
|     customTypes, | ||||
| }: { | ||||
|     customTypes: DBCustomTypeInfo[]; | ||||
| }): DBCustomType[] => { | ||||
|     return customTypes.map((customType) => { | ||||
|         return { | ||||
|             id: generateId(), | ||||
|             schema: schemaNameToDomainSchemaName(customType.schema), | ||||
|             name: customType.type, | ||||
|             kind: customType.kind as DBCustomTypeKind, | ||||
|             values: customType.values, | ||||
|             fields: customType.fields, | ||||
|         }; | ||||
|     }); | ||||
| }; | ||||
|  | ||||
| export const customTypeKindToLabel: Record<DBCustomTypeKind, string> = { | ||||
|     enum: 'Enum', | ||||
|     composite: 'Composite', | ||||
|   | ||||
| @@ -1,10 +1,4 @@ | ||||
| import { z } from 'zod'; | ||||
| import type { ViewInfo } from '../data/import-metadata/metadata-types/view-info'; | ||||
| import { DatabaseType } from './database-type'; | ||||
| import { schemaNameToDomainSchemaName } from './db-schema'; | ||||
| import { decodeViewDefinition, type DBTable } from './db-table'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
| import type { AST } from 'node-sql-parser'; | ||||
|  | ||||
| export interface DBDependency { | ||||
|     id: string; | ||||
| @@ -23,348 +17,3 @@ export const dbDependencySchema: z.ZodType<DBDependency> = z.object({ | ||||
|     dependentTableId: z.string(), | ||||
|     createdAt: z.number(), | ||||
| }); | ||||
|  | ||||
| const astDatabaseTypes: Record<DatabaseType, string> = { | ||||
|     [DatabaseType.POSTGRESQL]: 'postgresql', | ||||
|     [DatabaseType.MYSQL]: 'postgresql', | ||||
|     [DatabaseType.MARIADB]: 'postgresql', | ||||
|     [DatabaseType.GENERIC]: 'postgresql', | ||||
|     [DatabaseType.SQLITE]: 'postgresql', | ||||
|     [DatabaseType.SQL_SERVER]: 'postgresql', | ||||
|     [DatabaseType.CLICKHOUSE]: 'postgresql', | ||||
|     [DatabaseType.COCKROACHDB]: 'postgresql', | ||||
|     [DatabaseType.ORACLE]: 'postgresql', | ||||
| }; | ||||
|  | ||||
| export const createDependenciesFromMetadata = async ({ | ||||
|     views, | ||||
|     tables, | ||||
|     databaseType, | ||||
| }: { | ||||
|     views: ViewInfo[]; | ||||
|     tables: DBTable[]; | ||||
|     databaseType: DatabaseType; | ||||
| }): Promise<DBDependency[]> => { | ||||
|     if (!views || views.length === 0) { | ||||
|         return []; | ||||
|     } | ||||
|  | ||||
|     const { Parser } = await import('node-sql-parser'); | ||||
|     const parser = new Parser(); | ||||
|  | ||||
|     const dependencies = views | ||||
|         .flatMap((view) => { | ||||
|             const viewSchema = schemaNameToDomainSchemaName(view.schema); | ||||
|             const viewTable = tables.find( | ||||
|                 (table) => | ||||
|                     table.name === view.view_name && viewSchema === table.schema | ||||
|             ); | ||||
|  | ||||
|             if (!viewTable) { | ||||
|                 console.warn( | ||||
|                     `Source table for view ${view.view_name} not found (schema: ${viewSchema})` | ||||
|                 ); | ||||
|                 return []; // Skip this view and proceed to the next | ||||
|             } | ||||
|  | ||||
|             if (view.view_definition) { | ||||
|                 try { | ||||
|                     const decodedViewDefinition = decodeViewDefinition( | ||||
|                         databaseType, | ||||
|                         view.view_definition | ||||
|                     ); | ||||
|  | ||||
|                     let modifiedViewDefinition = ''; | ||||
|                     if ( | ||||
|                         databaseType === DatabaseType.MYSQL || | ||||
|                         databaseType === DatabaseType.MARIADB | ||||
|                     ) { | ||||
|                         modifiedViewDefinition = preprocessViewDefinitionMySQL( | ||||
|                             decodedViewDefinition | ||||
|                         ); | ||||
|                     } else if (databaseType === DatabaseType.SQL_SERVER) { | ||||
|                         modifiedViewDefinition = | ||||
|                             preprocessViewDefinitionSQLServer( | ||||
|                                 decodedViewDefinition | ||||
|                             ); | ||||
|                     } else { | ||||
|                         modifiedViewDefinition = preprocessViewDefinition( | ||||
|                             decodedViewDefinition | ||||
|                         ); | ||||
|                     } | ||||
|  | ||||
|                     // Parse using the appropriate dialect | ||||
|                     const ast = parser.astify(modifiedViewDefinition, { | ||||
|                         database: astDatabaseTypes[databaseType], | ||||
|                         type: 'select', // Parsing a SELECT statement | ||||
|                     }); | ||||
|  | ||||
|                     let relatedTables = extractTablesFromAST(ast); | ||||
|  | ||||
|                     // Filter out duplicate tables without schema | ||||
|                     relatedTables = filterDuplicateTables(relatedTables); | ||||
|  | ||||
|                     return relatedTables.map((relTable) => { | ||||
|                         const relSchema = relTable.schema || view.schema; // Use view's schema if relSchema is undefined | ||||
|                         const relTableName = relTable.tableName; | ||||
|  | ||||
|                         const table = tables.find( | ||||
|                             (table) => | ||||
|                                 table.name === relTableName && | ||||
|                                 (table.schema || '') === relSchema | ||||
|                         ); | ||||
|  | ||||
|                         if (table) { | ||||
|                             const dependency: DBDependency = { | ||||
|                                 id: generateId(), | ||||
|                                 schema: view.schema, | ||||
|                                 tableId: table.id, // related table | ||||
|                                 dependentSchema: table.schema, | ||||
|                                 dependentTableId: viewTable.id, // dependent view | ||||
|                                 createdAt: Date.now(), | ||||
|                             }; | ||||
|  | ||||
|                             return dependency; | ||||
|                         } else { | ||||
|                             console.warn( | ||||
|                                 `Dependent table ${relSchema}.${relTableName} not found for view ${view.schema}.${view.view_name}` | ||||
|                             ); | ||||
|                             return null; | ||||
|                         } | ||||
|                     }); | ||||
|                 } catch (error) { | ||||
|                     console.error( | ||||
|                         `Error parsing view ${view.schema}.${view.view_name}:`, | ||||
|                         error | ||||
|                     ); | ||||
|                     return []; | ||||
|                 } | ||||
|             } else { | ||||
|                 console.warn( | ||||
|                     `View definition missing for ${view.schema}.${view.view_name}` | ||||
|                 ); | ||||
|                 return []; | ||||
|             } | ||||
|         }) | ||||
|         .filter((dependency) => dependency !== null); | ||||
|  | ||||
|     return dependencies; | ||||
| }; | ||||
|  | ||||
| // Add this new function to filter out duplicate tables | ||||
| function filterDuplicateTables( | ||||
|     tables: { schema?: string; tableName: string }[] | ||||
| ): { schema?: string; tableName: string }[] { | ||||
|     const tableMap = new Map<string, { schema?: string; tableName: string }>(); | ||||
|  | ||||
|     for (const table of tables) { | ||||
|         const key = table.tableName; | ||||
|         const existingTable = tableMap.get(key); | ||||
|  | ||||
|         if (!existingTable || (table.schema && !existingTable.schema)) { | ||||
|             tableMap.set(key, table); | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     return Array.from(tableMap.values()); | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition to remove schema from CREATE VIEW | ||||
| function preprocessViewDefinition(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove leading and trailing whitespace | ||||
|     viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim(); | ||||
|  | ||||
|     // Replace escaped double quotes with regular ones | ||||
|     viewDefinition = viewDefinition.replace(/\\"/g, '"'); | ||||
|  | ||||
|     // Replace 'CREATE MATERIALIZED VIEW' with 'CREATE VIEW' | ||||
|     viewDefinition = viewDefinition.replace( | ||||
|         /CREATE\s+MATERIALIZED\s+VIEW/i, | ||||
|         'CREATE VIEW' | ||||
|     ); | ||||
|  | ||||
|     // Regular expression to match 'CREATE VIEW [schema.]view_name [ (column definitions) ] AS' | ||||
|     // This regex captures the view name and skips any content between the view name and 'AS' | ||||
|     const regex = | ||||
|         /CREATE\s+VIEW\s+(?:(?:`[^`]+`|"[^"]+"|\w+)\.)?(?:`([^`]+)`|"([^"]+)"|(\w+))[\s\S]*?\bAS\b\s+/i; | ||||
|  | ||||
|     const match = viewDefinition.match(regex); | ||||
|     let modifiedDefinition: string; | ||||
|  | ||||
|     if (match) { | ||||
|         const viewName = match[1] || match[2] || match[3]; | ||||
|         // Extract the SQL after the 'AS' keyword | ||||
|         const restOfDefinition = viewDefinition.substring( | ||||
|             match.index! + match[0].length | ||||
|         ); | ||||
|  | ||||
|         // Replace double-quoted identifiers with unquoted ones | ||||
|         let modifiedSQL = restOfDefinition.replace(/"(\w+)"/g, '$1'); | ||||
|  | ||||
|         // Replace '::' type casts with 'CAST' expressions | ||||
|         modifiedSQL = modifiedSQL.replace( | ||||
|             /\(([^()]+)\)::(\w+)/g, | ||||
|             'CAST($1 AS $2)' | ||||
|         ); | ||||
|  | ||||
|         // Remove ClickHouse-specific syntax that may still be present | ||||
|         // For example, remove SETTINGS clauses inside the SELECT statement | ||||
|         modifiedSQL = modifiedSQL.replace(/\bSETTINGS\b[\s\S]*$/i, ''); | ||||
|  | ||||
|         modifiedDefinition = `CREATE VIEW ${viewName} AS ${modifiedSQL}`; | ||||
|     } else { | ||||
|         console.warn('Could not preprocess view definition:', viewDefinition); | ||||
|         modifiedDefinition = viewDefinition; | ||||
|     } | ||||
|  | ||||
|     return modifiedDefinition; | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition for SQL Server | ||||
| function preprocessViewDefinitionSQLServer(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove BOM if present | ||||
|     viewDefinition = viewDefinition.replace(/^\uFEFF/, ''); | ||||
|  | ||||
|     // Normalize whitespace | ||||
|     viewDefinition = viewDefinition.replace(/\s+/g, ' ').trim(); | ||||
|  | ||||
|     // Remove square brackets and replace with double quotes | ||||
|     viewDefinition = viewDefinition.replace(/\[([^\]]+)\]/g, '"$1"'); | ||||
|  | ||||
|     // Remove database names from fully qualified identifiers | ||||
|     viewDefinition = viewDefinition.replace( | ||||
|         /"([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"\."([a-zA-Z0-9_]+)"/g, | ||||
|         '"$2"."$3"' | ||||
|     ); | ||||
|  | ||||
|     // Replace SQL Server functions with PostgreSQL equivalents | ||||
|     viewDefinition = viewDefinition.replace(/\bGETDATE\(\)/gi, 'NOW()'); | ||||
|     viewDefinition = viewDefinition.replace(/\bISNULL\(/gi, 'COALESCE('); | ||||
|  | ||||
|     // Replace 'TOP N' with 'LIMIT N' at the end of the query | ||||
|     const topMatch = viewDefinition.match(/SELECT\s+TOP\s+(\d+)/i); | ||||
|     if (topMatch) { | ||||
|         const topN = topMatch[1]; | ||||
|         viewDefinition = viewDefinition.replace( | ||||
|             /SELECT\s+TOP\s+\d+/i, | ||||
|             'SELECT' | ||||
|         ); | ||||
|         viewDefinition = viewDefinition.replace(/;+\s*$/, ''); // Remove semicolons at the end | ||||
|         viewDefinition += ` LIMIT ${topN}`; | ||||
|     } | ||||
|  | ||||
|     viewDefinition = viewDefinition.replace(/\n/g, ''); // Remove newlines | ||||
|  | ||||
|     // Adjust CREATE VIEW syntax | ||||
|     const regex = | ||||
|         /CREATE\s+VIEW\s+(?:"?([^".\s]+)"?\.)?"?([^".\s]+)"?\s+AS\s+/i; | ||||
|     const match = viewDefinition.match(regex); | ||||
|     let modifiedDefinition: string; | ||||
|  | ||||
|     if (match) { | ||||
|         const viewName = match[2]; | ||||
|         const modifiedSQL = viewDefinition.substring( | ||||
|             match.index! + match[0].length | ||||
|         ); | ||||
|  | ||||
|         // Remove semicolons at the end | ||||
|         const finalSQL = modifiedSQL.replace(/;+\s*$/, ''); | ||||
|  | ||||
|         modifiedDefinition = `CREATE VIEW "${viewName}" AS ${finalSQL}`; | ||||
|     } else { | ||||
|         console.warn('Could not preprocess view definition:', viewDefinition); | ||||
|         modifiedDefinition = viewDefinition; | ||||
|     } | ||||
|  | ||||
|     return modifiedDefinition; | ||||
| } | ||||
|  | ||||
| // Preprocess the view_definition to remove schema from CREATE VIEW | ||||
| function preprocessViewDefinitionMySQL(viewDefinition: string): string { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     // Remove any trailing semicolons | ||||
|     viewDefinition = viewDefinition.replace(/;\s*$/, ''); | ||||
|  | ||||
|     // Remove backticks from identifiers | ||||
|     viewDefinition = viewDefinition.replace(/`/g, ''); | ||||
|  | ||||
|     // Remove unnecessary parentheses around joins and ON clauses | ||||
|     viewDefinition = removeRedundantParentheses(viewDefinition); | ||||
|  | ||||
|     return viewDefinition; | ||||
| } | ||||
|  | ||||
| function removeRedundantParentheses(sql: string): string { | ||||
|     // Regular expressions to match unnecessary parentheses | ||||
|     const patterns = [ | ||||
|         /\(\s*(JOIN\s+[^()]+?)\s*\)/gi, | ||||
|         /\(\s*(ON\s+[^()]+?)\s*\)/gi, | ||||
|         // Additional patterns if necessary | ||||
|     ]; | ||||
|  | ||||
|     let prevSql; | ||||
|     do { | ||||
|         prevSql = sql; | ||||
|         patterns.forEach((pattern) => { | ||||
|             sql = sql.replace(pattern, '$1'); | ||||
|         }); | ||||
|     } while (sql !== prevSql); | ||||
|  | ||||
|     return sql; | ||||
| } | ||||
|  | ||||
| function extractTablesFromAST( | ||||
|     ast: AST | AST[] | ||||
| ): { schema?: string; tableName: string }[] { | ||||
|     const tablesMap = new Map<string, { schema: string; tableName: string }>(); | ||||
|     const visitedNodes = new Set(); | ||||
|  | ||||
|     // eslint-disable-next-line @typescript-eslint/no-explicit-any | ||||
|     function traverse(node: any) { | ||||
|         if (!node || visitedNodes.has(node)) return; | ||||
|         visitedNodes.add(node); | ||||
|  | ||||
|         if (Array.isArray(node)) { | ||||
|             node.forEach(traverse); | ||||
|         } else if (typeof node === 'object') { | ||||
|             // Check if node represents a table | ||||
|             if ( | ||||
|                 Object.hasOwnProperty.call(node, 'table') && | ||||
|                 typeof node.table === 'string' | ||||
|             ) { | ||||
|                 let schema = node.db || node.schema; | ||||
|                 const tableName = node.table; | ||||
|                 if (tableName) { | ||||
|                     // Assign default schema if undefined | ||||
|                     schema = schemaNameToDomainSchemaName(schema) || ''; | ||||
|                     const key = `${schema}.${tableName}`; | ||||
|                     if (!tablesMap.has(key)) { | ||||
|                         tablesMap.set(key, { schema, tableName }); | ||||
|                     } | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Recursively traverse all properties | ||||
|             for (const key in node) { | ||||
|                 if (Object.hasOwnProperty.call(node, key)) { | ||||
|                     traverse(node[key]); | ||||
|                 } | ||||
|             } | ||||
|         } | ||||
|     } | ||||
|  | ||||
|     traverse(ast); | ||||
|  | ||||
|     return Array.from(tablesMap.values()); | ||||
| } | ||||
|   | ||||
| @@ -4,11 +4,6 @@ import { | ||||
|     findDataTypeDataById, | ||||
|     type DataType, | ||||
| } from '../data/data-types/data-types'; | ||||
| import type { ColumnInfo } from '../data/import-metadata/metadata-types/column-info'; | ||||
| import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info'; | ||||
| import type { PrimaryKeyInfo } from '../data/import-metadata/metadata-types/primary-key-info'; | ||||
| import type { TableInfo } from '../data/import-metadata/metadata-types/table-info'; | ||||
| import { generateId } from '../utils'; | ||||
| import type { DatabaseType } from './database-type'; | ||||
|  | ||||
| export interface DBField { | ||||
| @@ -45,64 +40,6 @@ export const dbFieldSchema: z.ZodType<DBField> = z.object({ | ||||
|     comments: z.string().or(z.null()).optional(), | ||||
| }); | ||||
|  | ||||
| export const createFieldsFromMetadata = ({ | ||||
|     tableColumns, | ||||
|     tablePrimaryKeys, | ||||
|     aggregatedIndexes, | ||||
| }: { | ||||
|     tableColumns: ColumnInfo[]; | ||||
|     tableSchema?: string; | ||||
|     tableInfo: TableInfo; | ||||
|     tablePrimaryKeys: PrimaryKeyInfo[]; | ||||
|     aggregatedIndexes: AggregatedIndexInfo[]; | ||||
| }) => { | ||||
|     const uniqueColumns = tableColumns.reduce((acc, col) => { | ||||
|         if (!acc.has(col.name)) { | ||||
|             acc.set(col.name, col); | ||||
|         } | ||||
|         return acc; | ||||
|     }, new Map<string, ColumnInfo>()); | ||||
|  | ||||
|     const sortedColumns = Array.from(uniqueColumns.values()).sort( | ||||
|         (a, b) => a.ordinal_position - b.ordinal_position | ||||
|     ); | ||||
|  | ||||
|     const tablePrimaryKeysColumns = tablePrimaryKeys.map((pk) => | ||||
|         pk.column.trim() | ||||
|     ); | ||||
|  | ||||
|     return sortedColumns.map( | ||||
|         (col: ColumnInfo): DBField => ({ | ||||
|             id: generateId(), | ||||
|             name: col.name, | ||||
|             type: { | ||||
|                 id: col.type.split(' ').join('_').toLowerCase(), | ||||
|                 name: col.type.toLowerCase(), | ||||
|             }, | ||||
|             primaryKey: tablePrimaryKeysColumns.includes(col.name), | ||||
|             unique: Object.values(aggregatedIndexes).some( | ||||
|                 (idx) => | ||||
|                     idx.unique && | ||||
|                     idx.columns.length === 1 && | ||||
|                     idx.columns[0].name === col.name | ||||
|             ), | ||||
|             nullable: Boolean(col.nullable), | ||||
|             ...(col.character_maximum_length && | ||||
|             col.character_maximum_length !== 'null' | ||||
|                 ? { characterMaximumLength: col.character_maximum_length } | ||||
|                 : {}), | ||||
|             ...(col.precision?.precision | ||||
|                 ? { precision: col.precision.precision } | ||||
|                 : {}), | ||||
|             ...(col.precision?.scale ? { scale: col.precision.scale } : {}), | ||||
|             ...(col.default ? { default: col.default } : {}), | ||||
|             ...(col.collation ? { collation: col.collation } : {}), | ||||
|             createdAt: Date.now(), | ||||
|             comments: col.comment ? col.comment : undefined, | ||||
|         }) | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const generateDBFieldSuffix = ( | ||||
|     field: DBField, | ||||
|     { | ||||
|   | ||||
| @@ -1,7 +1,5 @@ | ||||
| import { z } from 'zod'; | ||||
| import type { AggregatedIndexInfo } from '../data/import-metadata/metadata-types/index-info'; | ||||
| import { generateId } from '../utils'; | ||||
| import type { DBField } from './db-field'; | ||||
| import { DatabaseType } from './database-type'; | ||||
| import type { DBTable } from './db-table'; | ||||
|  | ||||
| @@ -43,27 +41,6 @@ export const dbIndexSchema: z.ZodType<DBIndex> = z.object({ | ||||
|     isPrimaryKey: z.boolean().or(z.null()).optional(), | ||||
| }); | ||||
|  | ||||
| export const createIndexesFromMetadata = ({ | ||||
|     aggregatedIndexes, | ||||
|     fields, | ||||
| }: { | ||||
|     aggregatedIndexes: AggregatedIndexInfo[]; | ||||
|     fields: DBField[]; | ||||
| }): DBIndex[] => | ||||
|     aggregatedIndexes.map( | ||||
|         (idx): DBIndex => ({ | ||||
|             id: generateId(), | ||||
|             name: idx.name, | ||||
|             unique: Boolean(idx.unique), | ||||
|             fieldIds: idx.columns | ||||
|                 .sort((a, b) => a.position - b.position) | ||||
|                 .map((c) => fields.find((f) => f.name === c.name)?.id) | ||||
|                 .filter((id): id is string => id !== undefined), | ||||
|             createdAt: Date.now(), | ||||
|             type: idx.index_type?.toLowerCase() as IndexType, | ||||
|         }) | ||||
|     ); | ||||
|  | ||||
| export const databaseIndexTypes: { [key in DatabaseType]?: IndexType[] } = { | ||||
|     [DatabaseType.POSTGRESQL]: ['btree', 'hash'], | ||||
| }; | ||||
|   | ||||
| @@ -1,9 +1,4 @@ | ||||
| import { z } from 'zod'; | ||||
| import type { ForeignKeyInfo } from '../data/import-metadata/metadata-types/foreign-key-info'; | ||||
| import type { DBField } from './db-field'; | ||||
| import { schemaNameToDomainSchemaName } from './db-schema'; | ||||
| import type { DBTable } from './db-table'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
|  | ||||
| export interface DBRelationship { | ||||
|     id: string; | ||||
| @@ -40,82 +35,6 @@ export type RelationshipType = | ||||
|     | 'many_to_many'; | ||||
| export type Cardinality = 'one' | 'many'; | ||||
|  | ||||
| const determineCardinality = ( | ||||
|     field: DBField, | ||||
|     isTablePKComplex: boolean | ||||
| ): Cardinality => { | ||||
|     return field.unique || (field.primaryKey && !isTablePKComplex) | ||||
|         ? 'one' | ||||
|         : 'many'; | ||||
| }; | ||||
|  | ||||
| export const createRelationshipsFromMetadata = ({ | ||||
|     foreignKeys, | ||||
|     tables, | ||||
| }: { | ||||
|     foreignKeys: ForeignKeyInfo[]; | ||||
|     tables: DBTable[]; | ||||
| }): DBRelationship[] => { | ||||
|     return foreignKeys | ||||
|         .map((fk: ForeignKeyInfo): DBRelationship | null => { | ||||
|             const schema = schemaNameToDomainSchemaName(fk.schema); | ||||
|             const sourceTable = tables.find( | ||||
|                 (table) => table.name === fk.table && table.schema === schema | ||||
|             ); | ||||
|  | ||||
|             const targetSchema = schemaNameToDomainSchemaName( | ||||
|                 fk.reference_schema | ||||
|             ); | ||||
|  | ||||
|             const targetTable = tables.find( | ||||
|                 (table) => | ||||
|                     table.name === fk.reference_table && | ||||
|                     table.schema === targetSchema | ||||
|             ); | ||||
|             const sourceField = sourceTable?.fields.find( | ||||
|                 (field) => field.name === fk.column | ||||
|             ); | ||||
|             const targetField = targetTable?.fields.find( | ||||
|                 (field) => field.name === fk.reference_column | ||||
|             ); | ||||
|  | ||||
|             const isSourceTablePKComplex = | ||||
|                 (sourceTable?.fields.filter((field) => field.primaryKey) ?? []) | ||||
|                     .length > 1; | ||||
|             const isTargetTablePKComplex = | ||||
|                 (targetTable?.fields.filter((field) => field.primaryKey) ?? []) | ||||
|                     .length > 1; | ||||
|  | ||||
|             if (sourceTable && targetTable && sourceField && targetField) { | ||||
|                 const sourceCardinality = determineCardinality( | ||||
|                     sourceField, | ||||
|                     isSourceTablePKComplex | ||||
|                 ); | ||||
|                 const targetCardinality = determineCardinality( | ||||
|                     targetField, | ||||
|                     isTargetTablePKComplex | ||||
|                 ); | ||||
|  | ||||
|                 return { | ||||
|                     id: generateId(), | ||||
|                     name: fk.foreign_key_name, | ||||
|                     sourceSchema: schema, | ||||
|                     targetSchema: targetSchema, | ||||
|                     sourceTableId: sourceTable.id, | ||||
|                     targetTableId: targetTable.id, | ||||
|                     sourceFieldId: sourceField.id, | ||||
|                     targetFieldId: targetField.id, | ||||
|                     sourceCardinality, | ||||
|                     targetCardinality, | ||||
|                     createdAt: Date.now(), | ||||
|                 }; | ||||
|             } | ||||
|  | ||||
|             return null; | ||||
|         }) | ||||
|         .filter((rel) => rel !== null) as DBRelationship[]; | ||||
| }; | ||||
|  | ||||
| export const determineRelationshipType = ({ | ||||
|     sourceCardinality, | ||||
|     targetCardinality, | ||||
|   | ||||
| @@ -1,30 +1,8 @@ | ||||
| import { | ||||
|     createIndexesFromMetadata, | ||||
|     dbIndexSchema, | ||||
|     type DBIndex, | ||||
| } from './db-index'; | ||||
| import { | ||||
|     createFieldsFromMetadata, | ||||
|     dbFieldSchema, | ||||
|     type DBField, | ||||
| } from './db-field'; | ||||
| import type { TableInfo } from '../data/import-metadata/metadata-types/table-info'; | ||||
| import { createAggregatedIndexes } from '../data/import-metadata/metadata-types/index-info'; | ||||
| import { | ||||
|     materializedViewColor, | ||||
|     viewColor, | ||||
|     defaultTableColor, | ||||
| } from '@/lib/colors'; | ||||
| import { dbIndexSchema, type DBIndex } from './db-index'; | ||||
| import { dbFieldSchema, type DBField } from './db-field'; | ||||
| import type { DBRelationship } from './db-relationship'; | ||||
| import { | ||||
|     decodeBase64ToUtf16LE, | ||||
|     decodeBase64ToUtf8, | ||||
|     deepCopy, | ||||
|     generateId, | ||||
| } from '../utils'; | ||||
| import { deepCopy } from '../utils'; | ||||
| import { schemaNameToDomainSchemaName } from './db-schema'; | ||||
| import { DatabaseType } from './database-type'; | ||||
| import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata'; | ||||
| import { z } from 'zod'; | ||||
| import type { Area } from './area'; | ||||
|  | ||||
| @@ -79,213 +57,6 @@ export const generateTableKey = ({ | ||||
|     tableName: string; | ||||
| }) => `${schemaNameToDomainSchemaName(schemaName) ?? ''}.${tableName}`; | ||||
|  | ||||
| export const decodeViewDefinition = ( | ||||
|     databaseType: DatabaseType, | ||||
|     viewDefinition?: string | ||||
| ): string => { | ||||
|     if (!viewDefinition) { | ||||
|         return ''; | ||||
|     } | ||||
|  | ||||
|     let decodedViewDefinition: string; | ||||
|     if (databaseType === DatabaseType.SQL_SERVER) { | ||||
|         decodedViewDefinition = decodeBase64ToUtf16LE(viewDefinition); | ||||
|     } else { | ||||
|         decodedViewDefinition = decodeBase64ToUtf8(viewDefinition); | ||||
|     } | ||||
|  | ||||
|     return decodedViewDefinition; | ||||
| }; | ||||
|  | ||||
| export const createTablesFromMetadata = ({ | ||||
|     databaseMetadata, | ||||
|     databaseType, | ||||
| }: { | ||||
|     databaseMetadata: DatabaseMetadata; | ||||
|     databaseType: DatabaseType; | ||||
| }): DBTable[] => { | ||||
|     const { | ||||
|         tables: tableInfos, | ||||
|         pk_info: primaryKeys, | ||||
|         columns, | ||||
|         indexes, | ||||
|         views: views, | ||||
|     } = databaseMetadata; | ||||
|  | ||||
|     // Pre-compute view names for faster lookup if there are views | ||||
|     const viewNamesSet = new Set<string>(); | ||||
|     const materializedViewNamesSet = new Set<string>(); | ||||
|  | ||||
|     if (views && views.length > 0) { | ||||
|         views.forEach((view) => { | ||||
|             const key = generateTableKey({ | ||||
|                 schemaName: view.schema, | ||||
|                 tableName: view.view_name, | ||||
|             }); | ||||
|             viewNamesSet.add(key); | ||||
|  | ||||
|             if ( | ||||
|                 view.view_definition && | ||||
|                 decodeViewDefinition(databaseType, view.view_definition) | ||||
|                     .toLowerCase() | ||||
|                     .includes('materialized') | ||||
|             ) { | ||||
|                 materializedViewNamesSet.add(key); | ||||
|             } | ||||
|         }); | ||||
|     } | ||||
|  | ||||
|     // Pre-compute lookup maps for better performance | ||||
|     const columnsByTable = new Map<string, (typeof columns)[0][]>(); | ||||
|     const indexesByTable = new Map<string, (typeof indexes)[0][]>(); | ||||
|     const primaryKeysByTable = new Map<string, (typeof primaryKeys)[0][]>(); | ||||
|  | ||||
|     // Group columns by table | ||||
|     columns.forEach((col) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: col.schema, | ||||
|             tableName: col.table, | ||||
|         }); | ||||
|         if (!columnsByTable.has(key)) { | ||||
|             columnsByTable.set(key, []); | ||||
|         } | ||||
|         columnsByTable.get(key)!.push(col); | ||||
|     }); | ||||
|  | ||||
|     // Group indexes by table | ||||
|     indexes.forEach((idx) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: idx.schema, | ||||
|             tableName: idx.table, | ||||
|         }); | ||||
|         if (!indexesByTable.has(key)) { | ||||
|             indexesByTable.set(key, []); | ||||
|         } | ||||
|         indexesByTable.get(key)!.push(idx); | ||||
|     }); | ||||
|  | ||||
|     // Group primary keys by table | ||||
|     primaryKeys.forEach((pk) => { | ||||
|         const key = generateTableKey({ | ||||
|             schemaName: pk.schema, | ||||
|             tableName: pk.table, | ||||
|         }); | ||||
|         if (!primaryKeysByTable.has(key)) { | ||||
|             primaryKeysByTable.set(key, []); | ||||
|         } | ||||
|         primaryKeysByTable.get(key)!.push(pk); | ||||
|     }); | ||||
|  | ||||
|     const result = tableInfos.map((tableInfo: TableInfo) => { | ||||
|         const tableSchema = schemaNameToDomainSchemaName(tableInfo.schema); | ||||
|         const tableKey = generateTableKey({ | ||||
|             schemaName: tableInfo.schema, | ||||
|             tableName: tableInfo.table, | ||||
|         }); | ||||
|  | ||||
|         // Use pre-computed lookups instead of filtering entire arrays | ||||
|         const tableIndexes = indexesByTable.get(tableKey) || []; | ||||
|         const tablePrimaryKeys = primaryKeysByTable.get(tableKey) || []; | ||||
|         const tableColumns = columnsByTable.get(tableKey) || []; | ||||
|  | ||||
|         // Aggregate indexes with multiple columns | ||||
|         const aggregatedIndexes = createAggregatedIndexes({ | ||||
|             tableInfo, | ||||
|             tableSchema, | ||||
|             tableIndexes, | ||||
|         }); | ||||
|  | ||||
|         const fields = createFieldsFromMetadata({ | ||||
|             aggregatedIndexes, | ||||
|             tableColumns, | ||||
|             tablePrimaryKeys, | ||||
|             tableInfo, | ||||
|             tableSchema, | ||||
|         }); | ||||
|  | ||||
|         // Check for composite primary key and find matching index name | ||||
|         const primaryKeyFields = fields.filter((f) => f.primaryKey); | ||||
|         let pkMatchingIndexName: string | undefined; | ||||
|         let pkIndex: DBIndex | undefined; | ||||
|  | ||||
|         if (primaryKeyFields.length >= 1) { | ||||
|             // We have a composite primary key, look for an index that matches all PK columns | ||||
|             const pkFieldNames = primaryKeyFields.map((f) => f.name).sort(); | ||||
|  | ||||
|             // Find an index that matches the primary key columns exactly | ||||
|             const matchingIndex = aggregatedIndexes.find((index) => { | ||||
|                 const indexColumnNames = index.columns | ||||
|                     .map((c) => c.name) | ||||
|                     .sort(); | ||||
|                 return ( | ||||
|                     indexColumnNames.length === pkFieldNames.length && | ||||
|                     indexColumnNames.every((col, i) => col === pkFieldNames[i]) | ||||
|                 ); | ||||
|             }); | ||||
|  | ||||
|             if (matchingIndex) { | ||||
|                 pkMatchingIndexName = matchingIndex.name; | ||||
|                 // Create a special PK index | ||||
|                 pkIndex = { | ||||
|                     id: generateId(), | ||||
|                     name: matchingIndex.name, | ||||
|                     unique: true, | ||||
|                     fieldIds: primaryKeyFields.map((f) => f.id), | ||||
|                     createdAt: Date.now(), | ||||
|                     isPrimaryKey: true, | ||||
|                 }; | ||||
|             } | ||||
|         } | ||||
|  | ||||
|         // Filter out the index that matches the composite PK (to avoid duplication) | ||||
|         const filteredAggregatedIndexes = pkMatchingIndexName | ||||
|             ? aggregatedIndexes.filter( | ||||
|                   (idx) => idx.name !== pkMatchingIndexName | ||||
|               ) | ||||
|             : aggregatedIndexes; | ||||
|  | ||||
|         const dbIndexes = createIndexesFromMetadata({ | ||||
|             aggregatedIndexes: filteredAggregatedIndexes, | ||||
|             fields, | ||||
|         }); | ||||
|  | ||||
|         // Add the PK index if it exists | ||||
|         if (pkIndex) { | ||||
|             dbIndexes.push(pkIndex); | ||||
|         } | ||||
|  | ||||
|         // Determine if the current table is a view by checking against pre-computed sets | ||||
|         const viewKey = generateTableKey({ | ||||
|             schemaName: tableSchema, | ||||
|             tableName: tableInfo.table, | ||||
|         }); | ||||
|         const isView = viewNamesSet.has(viewKey); | ||||
|         const isMaterializedView = materializedViewNamesSet.has(viewKey); | ||||
|  | ||||
|         // Initial random positions; these will be adjusted later | ||||
|         return { | ||||
|             id: generateId(), | ||||
|             name: tableInfo.table, | ||||
|             schema: tableSchema, | ||||
|             x: Math.random() * 1000, // Placeholder X | ||||
|             y: Math.random() * 800, // Placeholder Y | ||||
|             fields, | ||||
|             indexes: dbIndexes, | ||||
|             color: isMaterializedView | ||||
|                 ? materializedViewColor | ||||
|                 : isView | ||||
|                   ? viewColor | ||||
|                   : defaultTableColor, | ||||
|             isView: isView, | ||||
|             isMaterializedView: isMaterializedView, | ||||
|             createdAt: Date.now(), | ||||
|             comments: tableInfo.comment ? tableInfo.comment : undefined, | ||||
|         }; | ||||
|     }); | ||||
|  | ||||
|     return result; | ||||
| }; | ||||
|  | ||||
| export const adjustTablePositions = ({ | ||||
|     relationships: inputRelationships, | ||||
|     tables: inputTables, | ||||
|   | ||||
| @@ -1,30 +1,15 @@ | ||||
| import { z } from 'zod'; | ||||
| import type { DatabaseMetadata } from '../data/import-metadata/metadata-types/database-metadata'; | ||||
| import { DatabaseEdition } from './database-edition'; | ||||
| import { DatabaseType } from './database-type'; | ||||
| import type { DBDependency } from './db-dependency'; | ||||
| import { | ||||
|     createDependenciesFromMetadata, | ||||
|     dbDependencySchema, | ||||
| } from './db-dependency'; | ||||
| import { dbDependencySchema } from './db-dependency'; | ||||
| import type { DBRelationship } from './db-relationship'; | ||||
| import { | ||||
|     createRelationshipsFromMetadata, | ||||
|     dbRelationshipSchema, | ||||
| } from './db-relationship'; | ||||
| import { dbRelationshipSchema } from './db-relationship'; | ||||
| import type { DBTable } from './db-table'; | ||||
| import { | ||||
|     adjustTablePositions, | ||||
|     createTablesFromMetadata, | ||||
|     dbTableSchema, | ||||
| } from './db-table'; | ||||
| import { generateDiagramId } from '@/lib/utils'; | ||||
| import { dbTableSchema } from './db-table'; | ||||
| import { areaSchema, type Area } from './area'; | ||||
| import type { DBCustomType } from './db-custom-type'; | ||||
| import { | ||||
|     dbCustomTypeSchema, | ||||
|     createCustomTypesFromMetadata, | ||||
| } from './db-custom-type'; | ||||
| import { dbCustomTypeSchema } from './db-custom-type'; | ||||
|  | ||||
| export interface Diagram { | ||||
|     id: string; | ||||
| @@ -53,77 +38,3 @@ export const diagramSchema: z.ZodType<Diagram> = z.object({ | ||||
|     createdAt: z.date(), | ||||
|     updatedAt: z.date(), | ||||
| }); | ||||
|  | ||||
| export const loadFromDatabaseMetadata = async ({ | ||||
|     databaseType, | ||||
|     databaseMetadata, | ||||
|     diagramNumber, | ||||
|     databaseEdition, | ||||
| }: { | ||||
|     databaseType: DatabaseType; | ||||
|     databaseMetadata: DatabaseMetadata; | ||||
|     diagramNumber?: number; | ||||
|     databaseEdition?: DatabaseEdition; | ||||
| }): Promise<Diagram> => { | ||||
|     const { | ||||
|         fk_info: foreignKeys, | ||||
|         views: views, | ||||
|         custom_types: customTypes, | ||||
|     } = databaseMetadata; | ||||
|  | ||||
|     const tables = createTablesFromMetadata({ | ||||
|         databaseMetadata, | ||||
|         databaseType, | ||||
|     }); | ||||
|  | ||||
|     const relationships = createRelationshipsFromMetadata({ | ||||
|         foreignKeys, | ||||
|         tables, | ||||
|     }); | ||||
|  | ||||
|     const dependencies = await createDependenciesFromMetadata({ | ||||
|         views, | ||||
|         tables, | ||||
|         databaseType, | ||||
|     }); | ||||
|  | ||||
|     const dbCustomTypes = customTypes | ||||
|         ? createCustomTypesFromMetadata({ | ||||
|               customTypes, | ||||
|           }) | ||||
|         : []; | ||||
|  | ||||
|     const adjustedTables = adjustTablePositions({ | ||||
|         tables, | ||||
|         relationships, | ||||
|         mode: 'perSchema', | ||||
|     }); | ||||
|  | ||||
|     const sortedTables = adjustedTables.sort((a, b) => { | ||||
|         if (a.isView === b.isView) { | ||||
|             // Both are either tables or views, so sort alphabetically by name | ||||
|             return a.name.localeCompare(b.name); | ||||
|         } | ||||
|         // If one is a view and the other is not, put tables first | ||||
|         return a.isView ? 1 : -1; | ||||
|     }); | ||||
|  | ||||
|     const diagram: Diagram = { | ||||
|         id: generateDiagramId(), | ||||
|         name: databaseMetadata.database_name | ||||
|             ? `${databaseMetadata.database_name}-db` | ||||
|             : diagramNumber | ||||
|               ? `Diagram ${diagramNumber}` | ||||
|               : 'New Diagram', | ||||
|         databaseType: databaseType ?? DatabaseType.GENERIC, | ||||
|         databaseEdition, | ||||
|         tables: sortedTables, | ||||
|         relationships, | ||||
|         dependencies, | ||||
|         customTypes: dbCustomTypes, | ||||
|         createdAt: new Date(), | ||||
|         updatedAt: new Date(), | ||||
|     }; | ||||
|  | ||||
|     return diagram; | ||||
| }; | ||||
|   | ||||
| @@ -57,3 +57,40 @@ export type DiffObject< | ||||
|     | FieldDiff<TField>['object'] | ||||
|     | IndexDiff<TIndex>['object'] | ||||
|     | RelationshipDiff<TRelationship>['object']; | ||||
|  | ||||
| type ExtractDiffKind<T> = T extends { object: infer O; type: infer Type } | ||||
|     ? T extends { attribute: infer A } | ||||
|         ? { object: O; type: Type; attribute: A } | ||||
|         : { object: O; type: Type } | ||||
|     : never; | ||||
|  | ||||
| export type DiffKind< | ||||
|     TTable = DBTable, | ||||
|     TField = DBField, | ||||
|     TIndex = DBIndex, | ||||
|     TRelationship = DBRelationship, | ||||
| > = ExtractDiffKind<ChartDBDiff<TTable, TField, TIndex, TRelationship>>; | ||||
|  | ||||
| export const isDiffOfKind = < | ||||
|     TTable = DBTable, | ||||
|     TField = DBField, | ||||
|     TIndex = DBIndex, | ||||
|     TRelationship = DBRelationship, | ||||
| >( | ||||
|     diff: ChartDBDiff<TTable, TField, TIndex, TRelationship>, | ||||
|     kind: DiffKind<TTable, TField, TIndex, TRelationship> | ||||
| ): boolean => { | ||||
|     if ('attribute' in kind) { | ||||
|         return ( | ||||
|             diff.object === kind.object && | ||||
|             diff.type === kind.type && | ||||
|             diff.attribute === kind.attribute | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|     if ('attribute' in diff) { | ||||
|         return false; | ||||
|     } | ||||
|  | ||||
|     return diff.object === kind.object && diff.type === kind.type; | ||||
| }; | ||||
|   | ||||
| @@ -77,13 +77,15 @@ export const AreaNode: React.FC<NodeProps<AreaNodeType>> = React.memo( | ||||
|                     } | ||||
|                 }} | ||||
|             > | ||||
|                 <NodeResizer | ||||
|                     isVisible={focused} | ||||
|                     lineClassName="!border-4 !border-transparent" | ||||
|                     handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600" | ||||
|                     minHeight={100} | ||||
|                     minWidth={100} | ||||
|                 /> | ||||
|                 {!readonly ? ( | ||||
|                     <NodeResizer | ||||
|                         isVisible={focused} | ||||
|                         lineClassName="!border-4 !border-transparent" | ||||
|                         handleClassName="!h-[10px] !w-[10px] !rounded-full !bg-pink-600" | ||||
|                         minHeight={100} | ||||
|                         minWidth={100} | ||||
|                     /> | ||||
|                 ) : null} | ||||
|                 <div className="group flex h-8 items-center justify-between rounded-t-md px-2"> | ||||
|                     <div className="flex w-full items-center gap-1"> | ||||
|                         <GripVertical className="size-4 shrink-0 text-slate-700 opacity-60 dark:text-slate-300" /> | ||||
|   | ||||
| @@ -28,6 +28,7 @@ import { FilterItemActions } from './filter-item-actions'; | ||||
| import { databasesWithSchemas } from '@/lib/domain'; | ||||
| import { getOperatingSystem } from '@/lib/utils'; | ||||
| import { useLocalConfig } from '@/hooks/use-local-config'; | ||||
| import { useDiff } from '@/context/diff-context/use-diff'; | ||||
|  | ||||
| export interface CanvasFilterProps { | ||||
|     onClose: () => void; | ||||
| @@ -36,6 +37,7 @@ export interface CanvasFilterProps { | ||||
| export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { | ||||
|     const { t } = useTranslation(); | ||||
|     const { tables, databaseType, areas } = useChartDB(); | ||||
|     const { checkIfNewTable } = useDiff(); | ||||
|     const { | ||||
|         filter, | ||||
|         toggleSchemaFilter, | ||||
| @@ -45,7 +47,7 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { | ||||
|         addTablesToFilter, | ||||
|         removeTablesFromFilter, | ||||
|     } = useDiagramFilter(); | ||||
|     const { fitView, setNodes } = useReactFlow(); | ||||
|     const { setNodes } = useReactFlow(); | ||||
|     const [searchQuery, setSearchQuery] = useState(''); | ||||
|     const [expanded, setExpanded] = useState<Record<string, boolean>>({}); | ||||
|     const [isFilterVisible, setIsFilterVisible] = useState(false); | ||||
| @@ -58,13 +60,14 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { | ||||
|         () => | ||||
|             tables | ||||
|                 .filter((table) => (showDBViews ? true : !table.isView)) | ||||
|                 .filter((table) => !checkIfNewTable({ tableId: table.id })) | ||||
|                 .map((table) => ({ | ||||
|                     id: table.id, | ||||
|                     name: table.name, | ||||
|                     schema: table.schema, | ||||
|                     parentAreaId: table.parentAreaId, | ||||
|                 })), | ||||
|         [tables, showDBViews] | ||||
|         [tables, showDBViews, checkIfNewTable] | ||||
|     ); | ||||
|  | ||||
|     const databaseWithSchemas = useMemo( | ||||
| @@ -157,39 +160,53 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { | ||||
|         ] | ||||
|     ); | ||||
|  | ||||
|     const focusOnTable = useCallback( | ||||
|     const selectTable = useCallback( | ||||
|         (tableId: string) => { | ||||
|             // Make sure the table is visible | ||||
|             // Make sure the table is visible, selected and trigger animation | ||||
|             setNodes((nodes) => | ||||
|                 nodes.map((node) => | ||||
|                     node.id === tableId | ||||
|                         ? { | ||||
|                               ...node, | ||||
|                               hidden: false, | ||||
|                               selected: true, | ||||
|                           } | ||||
|                         : { | ||||
|                               ...node, | ||||
|                               selected: false, | ||||
|                           } | ||||
|                 ) | ||||
|                 nodes.map((node) => { | ||||
|                     if (node.id === tableId) { | ||||
|                         return { | ||||
|                             ...node, | ||||
|                             selected: true, | ||||
|                             data: { | ||||
|                                 ...node.data, | ||||
|                                 highlightTable: true, | ||||
|                             }, | ||||
|                         }; | ||||
|                     } | ||||
|  | ||||
|                     return { | ||||
|                         ...node, | ||||
|                         selected: false, | ||||
|                         data: { | ||||
|                             ...node.data, | ||||
|                             highlightTable: false, | ||||
|                         }, | ||||
|                     }; | ||||
|                 }) | ||||
|             ); | ||||
|  | ||||
|             // Focus on the table | ||||
|             // Remove the highlight flag after animation completes | ||||
|             setTimeout(() => { | ||||
|                 fitView({ | ||||
|                     duration: 500, | ||||
|                     maxZoom: 1, | ||||
|                     minZoom: 1, | ||||
|                     nodes: [ | ||||
|                         { | ||||
|                             id: tableId, | ||||
|                         }, | ||||
|                     ], | ||||
|                 }); | ||||
|             }, 100); | ||||
|                 setNodes((nodes) => | ||||
|                     nodes.map((node) => { | ||||
|                         if (node.id === tableId) { | ||||
|                             return { | ||||
|                                 ...node, | ||||
|                                 data: { | ||||
|                                     ...node.data, | ||||
|                                     highlightTable: false, | ||||
|                                 }, | ||||
|                             }; | ||||
|                         } | ||||
|  | ||||
|                         return node; | ||||
|                     }) | ||||
|                 ); | ||||
|             }, 600); | ||||
|         }, | ||||
|         [fitView, setNodes] | ||||
|         [setNodes] | ||||
|     ); | ||||
|  | ||||
|     // Handle node click | ||||
| @@ -199,13 +216,13 @@ export const CanvasFilter: React.FC<CanvasFilterProps> = ({ onClose }) => { | ||||
|                 const context = node.context as TableContext; | ||||
|                 const isTableVisible = context.visible; | ||||
|  | ||||
|                 // Only focus if table is visible | ||||
|                 // Only select if table is visible | ||||
|                 if (isTableVisible) { | ||||
|                     focusOnTable(node.id); | ||||
|                     selectTable(node.id); | ||||
|                 } | ||||
|             } | ||||
|         }, | ||||
|         [focusOnTable] | ||||
|         [selectTable] | ||||
|     ); | ||||
|  | ||||
|     // Animate in on mount and focus search input | ||||
|   | ||||
| @@ -1,8 +1,9 @@ | ||||
| import React from 'react'; | ||||
| import { Eye, EyeOff } from 'lucide-react'; | ||||
| import { Eye, EyeOff, CircleDotDashed } from 'lucide-react'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import type { TreeNode } from '@/components/tree-view/tree'; | ||||
| import { schemaNameToSchemaId } from '@/lib/domain/db-schema'; | ||||
| import { useFocusOn } from '@/hooks/use-focus-on'; | ||||
| import type { | ||||
|     AreaContext, | ||||
|     NodeContext, | ||||
| @@ -12,6 +13,7 @@ import type { | ||||
|     TableContext, | ||||
| } from './types'; | ||||
| import type { FilterTableInfo } from '@/lib/domain/diagram-filter/diagram-filter'; | ||||
| import { cn } from '@/lib/utils'; | ||||
|  | ||||
| interface FilterItemActionsProps { | ||||
|     node: TreeNode<NodeType, NodeContext>; | ||||
| @@ -40,6 +42,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({ | ||||
|     addTablesToFilter, | ||||
|     removeTablesFromFilter, | ||||
| }) => { | ||||
|     const { focusOnArea, focusOnTable } = useFocusOn(); | ||||
|     if (node.type === 'schema') { | ||||
|         const context = node.context as SchemaContext; | ||||
|         const schemaVisible = context.visible; | ||||
| @@ -50,7 +53,7 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({ | ||||
|             <Button | ||||
|                 variant="ghost" | ||||
|                 size="sm" | ||||
|                 className="size-7 h-fit p-0" | ||||
|                 className="h-fit w-6 p-0" | ||||
|                 onClick={(e) => { | ||||
|                     e.stopPropagation(); | ||||
|  | ||||
| @@ -67,9 +70,9 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({ | ||||
|                 }} | ||||
|             > | ||||
|                 {!schemaVisible ? ( | ||||
|                     <EyeOff className="size-3.5 text-muted-foreground" /> | ||||
|                     <EyeOff className="!size-3.5 text-muted-foreground" /> | ||||
|                 ) : ( | ||||
|                     <Eye className="size-3.5" /> | ||||
|                     <Eye className="!size-3.5" /> | ||||
|                 )} | ||||
|             </Button> | ||||
|         ); | ||||
| @@ -81,37 +84,60 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({ | ||||
|         const isUngrouped = context.isUngrouped; | ||||
|         const areaId = context.id; | ||||
|  | ||||
|         const handleZoomToArea = (e: React.MouseEvent) => { | ||||
|             e.stopPropagation(); | ||||
|             if (!isUngrouped) { | ||||
|                 focusOnArea(areaId); | ||||
|             } | ||||
|         }; | ||||
|  | ||||
|         return ( | ||||
|             <Button | ||||
|                 variant="ghost" | ||||
|                 size="sm" | ||||
|                 className="size-7 h-fit p-0" | ||||
|                 onClick={(e) => { | ||||
|                     e.stopPropagation(); | ||||
|                     // Toggle all tables in this area | ||||
|                     if (areaVisible) { | ||||
|                         // Hide all tables in this area | ||||
|                         removeTablesFromFilter({ | ||||
|                             filterCallback: (table) => | ||||
|                                 (isUngrouped && !table.areaId) || | ||||
|                                 (!isUngrouped && table.areaId === areaId), | ||||
|                         }); | ||||
|                     } else { | ||||
|                         // Show all tables in this area | ||||
|                         addTablesToFilter({ | ||||
|                             filterCallback: (table) => | ||||
|                                 (isUngrouped && !table.areaId) || | ||||
|                                 (!isUngrouped && table.areaId === areaId), | ||||
|                         }); | ||||
|                     } | ||||
|                 }} | ||||
|             > | ||||
|                 {!areaVisible ? ( | ||||
|                     <EyeOff className="size-3.5 text-muted-foreground" /> | ||||
|                 ) : ( | ||||
|                     <Eye className="size-3.5" /> | ||||
|                 )} | ||||
|             </Button> | ||||
|             <div className="flex h-full items-center gap-0.5"> | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="sm" | ||||
|                     className={cn( | ||||
|                         'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100', | ||||
|                         { | ||||
|                             '!opacity-0': !areaVisible, | ||||
|                         } | ||||
|                     )} | ||||
|                     onClick={handleZoomToArea} | ||||
|                     disabled={!areaVisible} | ||||
|                 > | ||||
|                     <CircleDotDashed className="!size-3.5" /> | ||||
|                 </Button> | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="sm" | ||||
|                     className="flex h-fit w-6 items-center justify-center p-0" | ||||
|                     onClick={(e) => { | ||||
|                         e.stopPropagation(); | ||||
|                         // Toggle all tables in this area | ||||
|                         if (areaVisible) { | ||||
|                             // Hide all tables in this area | ||||
|                             removeTablesFromFilter({ | ||||
|                                 filterCallback: (table) => | ||||
|                                     (isUngrouped && !table.areaId) || | ||||
|                                     (!isUngrouped && table.areaId === areaId), | ||||
|                             }); | ||||
|                         } else { | ||||
|                             // Show all tables in this area | ||||
|                             addTablesToFilter({ | ||||
|                                 filterCallback: (table) => | ||||
|                                     (isUngrouped && !table.areaId) || | ||||
|                                     (!isUngrouped && table.areaId === areaId), | ||||
|                             }); | ||||
|                         } | ||||
|                     }} | ||||
|                 > | ||||
|                     {!areaVisible ? ( | ||||
|                         <EyeOff className="!size-3.5 text-muted-foreground" /> | ||||
|                     ) : ( | ||||
|                         <Eye className="!size-3.5" /> | ||||
|                     )} | ||||
|                 </Button> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
|  | ||||
| @@ -120,22 +146,43 @@ export const FilterItemActions: React.FC<FilterItemActionsProps> = ({ | ||||
|         const context = node.context as TableContext; | ||||
|         const tableVisible = context.visible; | ||||
|  | ||||
|         const handleZoomToTable = (e: React.MouseEvent) => { | ||||
|             e.stopPropagation(); | ||||
|             focusOnTable(tableId); | ||||
|         }; | ||||
|  | ||||
|         return ( | ||||
|             <Button | ||||
|                 variant="ghost" | ||||
|                 size="sm" | ||||
|                 className="size-7 h-fit p-0" | ||||
|                 onClick={(e) => { | ||||
|                     e.stopPropagation(); | ||||
|                     toggleTableFilter(tableId); | ||||
|                 }} | ||||
|             > | ||||
|                 {!tableVisible ? ( | ||||
|                     <EyeOff className="size-3.5 text-muted-foreground" /> | ||||
|                 ) : ( | ||||
|                     <Eye className="size-3.5" /> | ||||
|                 )} | ||||
|             </Button> | ||||
|             <div className="flex h-full items-center gap-0.5"> | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="sm" | ||||
|                     className={cn( | ||||
|                         'flex h-fit w-6 items-center justify-center p-0 opacity-0 transition-opacity group-hover:opacity-100', | ||||
|                         { | ||||
|                             '!opacity-0': !tableVisible, | ||||
|                         } | ||||
|                     )} | ||||
|                     onClick={handleZoomToTable} | ||||
|                     disabled={!tableVisible} | ||||
|                 > | ||||
|                     <CircleDotDashed className="!size-3.5" /> | ||||
|                 </Button> | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="sm" | ||||
|                     className="flex w-6 items-center justify-center p-0" | ||||
|                     onClick={(e) => { | ||||
|                         e.stopPropagation(); | ||||
|                         toggleTableFilter(tableId); | ||||
|                     }} | ||||
|                 > | ||||
|                     {!tableVisible ? ( | ||||
|                         <EyeOff className="!size-3.5 text-muted-foreground" /> | ||||
|                     ) : ( | ||||
|                         <Eye className="!size-3.5" /> | ||||
|                     )} | ||||
|                 </Button> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
|  | ||||
|   | ||||
| @@ -40,13 +40,7 @@ import { | ||||
| } from './table-node/table-node-field'; | ||||
| import { Toolbar } from './toolbar/toolbar'; | ||||
| import { useToast } from '@/components/toast/use-toast'; | ||||
| import { | ||||
|     Pencil, | ||||
|     LayoutGrid, | ||||
|     AlertTriangle, | ||||
|     Magnet, | ||||
|     Highlighter, | ||||
| } from 'lucide-react'; | ||||
| import { Pencil, AlertTriangle, Magnet, Highlighter } from 'lucide-react'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { useLayout } from '@/hooks/use-layout'; | ||||
| import { useBreakpoint } from '@/hooks/use-breakpoint'; | ||||
| @@ -81,7 +75,6 @@ import { | ||||
|     TOP_SOURCE_HANDLE_ID_PREFIX, | ||||
| } from './table-node/table-node-dependency-indicator'; | ||||
| import type { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { useAlert } from '@/context/alert-context/alert-context'; | ||||
| import { useCanvas } from '@/hooks/use-canvas'; | ||||
| import type { AreaNodeType } from './area-node/area-node'; | ||||
| import { AreaNode } from './area-node/area-node'; | ||||
| @@ -95,6 +88,7 @@ import type { DiagramFilter } from '@/lib/domain/diagram-filter/diagram-filter'; | ||||
| import { useDiagramFilter } from '@/context/diagram-filter-context/use-diagram-filter'; | ||||
| import { filterTable } from '@/lib/domain/diagram-filter/filter'; | ||||
| import { defaultSchemas } from '@/lib/data/default-schemas'; | ||||
| import { useDiff } from '@/context/diff-context/use-diff'; | ||||
|  | ||||
| const HIGHLIGHTED_EDGE_Z_INDEX = 1; | ||||
| const DEFAULT_EDGE_Z_INDEX = 0; | ||||
| @@ -124,16 +118,33 @@ const tableToTableNode = ( | ||||
|         databaseType, | ||||
|         filterLoading, | ||||
|         showDBViews, | ||||
|         forceShow, | ||||
|     }: { | ||||
|         filter?: DiagramFilter; | ||||
|         databaseType: DatabaseType; | ||||
|         filterLoading: boolean; | ||||
|         showDBViews?: boolean; | ||||
|         forceShow?: boolean; | ||||
|     } | ||||
| ): TableNodeType => { | ||||
|     // Always use absolute position for now | ||||
|     const position = { x: table.x, y: table.y }; | ||||
|  | ||||
|     let hidden = false; | ||||
|  | ||||
|     if (forceShow) { | ||||
|         hidden = false; | ||||
|     } else { | ||||
|         hidden = | ||||
|             !filterTable({ | ||||
|                 table: { id: table.id, schema: table.schema }, | ||||
|                 filter, | ||||
|                 options: { defaultSchema: defaultSchemas[databaseType] }, | ||||
|             }) || | ||||
|             filterLoading || | ||||
|             (!showDBViews && table.isView); | ||||
|     } | ||||
|  | ||||
|     return { | ||||
|         id: table.id, | ||||
|         type: 'table', | ||||
| @@ -143,14 +154,7 @@ const tableToTableNode = ( | ||||
|             isOverlapping: false, | ||||
|         }, | ||||
|         width: table.width ?? MIN_TABLE_SIZE, | ||||
|         hidden: | ||||
|             !filterTable({ | ||||
|                 table: { id: table.id, schema: table.schema }, | ||||
|                 filter, | ||||
|                 options: { defaultSchema: defaultSchemas[databaseType] }, | ||||
|             }) || | ||||
|             filterLoading || | ||||
|             (!showDBViews && table.isView), | ||||
|         hidden, | ||||
|     }; | ||||
| }; | ||||
|  | ||||
| @@ -231,12 +235,10 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|     const { showSidePanel } = useLayout(); | ||||
|     const { effectiveTheme } = useTheme(); | ||||
|     const { scrollAction, showDBViews, showMiniMapOnCanvas } = useLocalConfig(); | ||||
|     const { showAlert } = useAlert(); | ||||
|     const { isMd: isDesktop } = useBreakpoint('md'); | ||||
|     const [highlightOverlappingTables, setHighlightOverlappingTables] = | ||||
|         useState(false); | ||||
|     const { | ||||
|         reorderTables, | ||||
|         fitView, | ||||
|         setOverlapGraph, | ||||
|         overlapGraph, | ||||
| @@ -244,6 +246,14 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|         setShowFilter, | ||||
|     } = useCanvas(); | ||||
|     const { filter, loading: filterLoading } = useDiagramFilter(); | ||||
|     const { checkIfNewTable } = useDiff(); | ||||
|  | ||||
|     const shouldForceShowTable = useCallback( | ||||
|         (tableId: string) => { | ||||
|             return checkIfNewTable({ tableId }); | ||||
|         }, | ||||
|         [checkIfNewTable] | ||||
|     ); | ||||
|  | ||||
|     const [isInitialLoadingNodes, setIsInitialLoadingNodes] = useState(true); | ||||
|  | ||||
| @@ -254,6 +264,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|                 databaseType, | ||||
|                 filterLoading, | ||||
|                 showDBViews, | ||||
|                 forceShow: shouldForceShowTable(table.id), | ||||
|             }) | ||||
|         ) | ||||
|     ); | ||||
| @@ -273,6 +284,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|                 databaseType, | ||||
|                 filterLoading, | ||||
|                 showDBViews, | ||||
|                 forceShow: shouldForceShowTable(table.id), | ||||
|             }) | ||||
|         ); | ||||
|         if (equal(initialNodes, nodes)) { | ||||
| @@ -285,6 +297,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|         databaseType, | ||||
|         filterLoading, | ||||
|         showDBViews, | ||||
|         shouldForceShowTable, | ||||
|     ]); | ||||
|  | ||||
|     useEffect(() => { | ||||
| @@ -445,6 +458,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|                         databaseType, | ||||
|                         filterLoading, | ||||
|                         showDBViews, | ||||
|                         forceShow: shouldForceShowTable(table.id), | ||||
|                     }); | ||||
|  | ||||
|                     // Check if table uses the highlighted custom type | ||||
| @@ -495,6 +509,7 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|         highlightedCustomType, | ||||
|         filterLoading, | ||||
|         showDBViews, | ||||
|         shouldForceShowTable, | ||||
|     ]); | ||||
|  | ||||
|     const prevFilter = useRef<DiagramFilter | undefined>(undefined); | ||||
| @@ -1185,16 +1200,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|     const isLoadingDOM = | ||||
|         tables.length > 0 ? !getInternalNode(tables[0].id) : false; | ||||
|  | ||||
|     const showReorderConfirmation = useCallback(() => { | ||||
|         showAlert({ | ||||
|             title: t('reorder_diagram_alert.title'), | ||||
|             description: t('reorder_diagram_alert.description'), | ||||
|             actionLabel: t('reorder_diagram_alert.reorder'), | ||||
|             closeLabel: t('reorder_diagram_alert.cancel'), | ||||
|             onAction: reorderTables, | ||||
|         }); | ||||
|     }, [t, showAlert, reorderTables]); | ||||
|  | ||||
|     const hasOverlappingTables = useMemo( | ||||
|         () => | ||||
|             Array.from(overlapGraph.graph).some( | ||||
| @@ -1261,24 +1266,6 @@ export const Canvas: React.FC<CanvasProps> = ({ initialTables }) => { | ||||
|                         <div className="flex flex-col items-center gap-2 md:flex-row"> | ||||
|                             {!readonly ? ( | ||||
|                                 <> | ||||
|                                     <Tooltip> | ||||
|                                         <TooltipTrigger asChild> | ||||
|                                             <span> | ||||
|                                                 <Button | ||||
|                                                     variant="secondary" | ||||
|                                                     className="size-8 p-1 shadow-none" | ||||
|                                                     onClick={ | ||||
|                                                         showReorderConfirmation | ||||
|                                                     } | ||||
|                                                 > | ||||
|                                                     <LayoutGrid className="size-4" /> | ||||
|                                                 </Button> | ||||
|                                             </span> | ||||
|                                         </TooltipTrigger> | ||||
|                                         <TooltipContent> | ||||
|                                             {t('toolbar.reorder_diagram')} | ||||
|                                         </TooltipContent> | ||||
|                                     </Tooltip> | ||||
|                                     <Tooltip> | ||||
|                                         <TooltipTrigger asChild> | ||||
|                                             <span> | ||||
|   | ||||
							
								
								
									
										60
									
								
								src/pages/editor-page/canvas/table-node/table-edit-mode.css
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										60
									
								
								src/pages/editor-page/canvas/table-node/table-edit-mode.css
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,60 @@ | ||||
| /* Custom scrollbar styles for table edit mode */ | ||||
| .custom-scrollbar { | ||||
|     scrollbar-width: thin; | ||||
|     scrollbar-color: #cbd5e1 transparent; | ||||
| } | ||||
|  | ||||
| /* Webkit browsers (Chrome, Safari, Edge) */ | ||||
| .custom-scrollbar::-webkit-scrollbar { | ||||
|     width: 10px; | ||||
|     height: 10px; | ||||
| } | ||||
|  | ||||
| .custom-scrollbar::-webkit-scrollbar-track { | ||||
|     background: #f1f5f9; | ||||
|     border-radius: 6px; | ||||
|     margin: 4px 0; | ||||
| } | ||||
|  | ||||
| .custom-scrollbar::-webkit-scrollbar-thumb { | ||||
|     background: #cbd5e1; | ||||
|     border-radius: 6px; | ||||
|     border: 2px solid transparent; | ||||
|     background-clip: padding-box; | ||||
|     min-height: 40px; | ||||
| } | ||||
|  | ||||
| .custom-scrollbar::-webkit-scrollbar-thumb:hover { | ||||
|     background: #94a3b8; | ||||
| } | ||||
|  | ||||
| .custom-scrollbar::-webkit-scrollbar-thumb:active { | ||||
|     background: #64748b; | ||||
| } | ||||
|  | ||||
| /* Dark mode styles */ | ||||
| .dark .custom-scrollbar { | ||||
|     scrollbar-color: #475569 transparent; | ||||
| } | ||||
|  | ||||
| .dark .custom-scrollbar::-webkit-scrollbar-track { | ||||
|     background: #1e293b; | ||||
| } | ||||
|  | ||||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb { | ||||
|     background: #475569; | ||||
| } | ||||
|  | ||||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:hover { | ||||
|     background: #64748b; | ||||
| } | ||||
|  | ||||
| .dark .custom-scrollbar::-webkit-scrollbar-thumb:active { | ||||
|     background: #94a3b8; | ||||
| } | ||||
|  | ||||
| /* Always show scrollbar when content is scrollable */ | ||||
| .custom-scrollbar:hover::-webkit-scrollbar-thumb, | ||||
| .custom-scrollbar::-webkit-scrollbar-thumb { | ||||
|     visibility: visible; | ||||
| } | ||||
							
								
								
									
										859
									
								
								src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										859
									
								
								src/pages/editor-page/canvas/table-node/table-edit-mode.tsx
									
									
									
									
									
										Normal file
									
								
							| @@ -0,0 +1,859 @@ | ||||
| import React, { | ||||
|     useState, | ||||
|     useCallback, | ||||
|     useEffect, | ||||
|     useRef, | ||||
|     useMemo, | ||||
|     memo, | ||||
| } from 'react'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { Input } from '@/components/input/input'; | ||||
| import { Plus, Trash2, X } from 'lucide-react'; | ||||
| import type { DBField } from '@/lib/domain/db-field'; | ||||
| import type { DBTable } from '@/lib/domain/db-table'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| import { generateId } from '@/lib/utils'; | ||||
| import type { SelectBoxOption } from '@/components/select-box/select-box'; | ||||
| import { SelectBox } from '@/components/select-box/select-box'; | ||||
| import { generateDBFieldSuffix } from '@/lib/domain/db-field'; | ||||
| import { dataTypeDataToDataType } from '@/lib/data/data-types/data-types'; | ||||
| import type { DataTypeData } from '@/lib/data/data-types/data-types'; | ||||
| import { sortedDataTypeMap } from '@/lib/data/data-types/data-types'; | ||||
| import { DatabaseType } from '@/lib/domain/database-type'; | ||||
| import { Checkbox } from '@/components/checkbox/checkbox'; | ||||
| import { cn } from '@/lib/utils'; | ||||
| import './table-edit-mode.css'; | ||||
|  | ||||
| interface TableEditModeProps { | ||||
|     table: DBTable; | ||||
|     color?: string; | ||||
|     focusFieldId?: string; | ||||
|     onClose: () => void; | ||||
| } | ||||
|  | ||||
| interface FieldRowProps { | ||||
|     field: DBField; | ||||
|     dataTypeOptions: SelectBoxOption[]; | ||||
|     databaseType: DatabaseType; | ||||
|     onTypeChange: ( | ||||
|         fieldId: string, | ||||
|         value: string, | ||||
|         regexMatches?: string[] | ||||
|     ) => void; | ||||
|     onNameChange: ( | ||||
|         fieldId: string, | ||||
|         e: React.ChangeEvent<HTMLInputElement> | ||||
|     ) => void; | ||||
|     onPrimaryKeyChange: (fieldId: string, checked: boolean) => void; | ||||
|     onRemove: (fieldId: string) => void; | ||||
|     inputRef?: (el: HTMLInputElement | null) => void; | ||||
| } | ||||
|  | ||||
| const FieldRow = memo<FieldRowProps>( | ||||
|     ({ | ||||
|         field, | ||||
|         dataTypeOptions, | ||||
|         databaseType, | ||||
|         onTypeChange, | ||||
|         onNameChange, | ||||
|         onPrimaryKeyChange, | ||||
|         onRemove, | ||||
|         inputRef, | ||||
|     }) => { | ||||
|         return ( | ||||
|             <div className="mb-2 grid grid-cols-[1fr,150px,60px,40px] items-center gap-3 rounded-md p-2 hover:bg-slate-50 dark:hover:bg-slate-800"> | ||||
|                 <Input | ||||
|                     ref={inputRef} | ||||
|                     value={field.name} | ||||
|                     onChange={(e) => onNameChange(field.id, e)} | ||||
|                     className="h-9 text-sm font-medium" | ||||
|                     placeholder="Field name" | ||||
|                 /> | ||||
|  | ||||
|                 <SelectBox | ||||
|                     className="h-9 min-h-9 w-[150px] text-sm" | ||||
|                     popoverClassName="min-w-[350px]" | ||||
|                     options={dataTypeOptions} | ||||
|                     value={field.type.id} | ||||
|                     valueSuffix={generateDBFieldSuffix(field)} | ||||
|                     optionSuffix={(option) => | ||||
|                         generateDBFieldSuffix(field, { | ||||
|                             databaseType, | ||||
|                             forceExtended: true, | ||||
|                             typeId: option.value, | ||||
|                         }) | ||||
|                     } | ||||
|                     onChange={(value, regexMatches) => | ||||
|                         onTypeChange( | ||||
|                             field.id, | ||||
|                             value as string, | ||||
|                             Array.isArray(regexMatches) | ||||
|                                 ? regexMatches | ||||
|                                 : undefined | ||||
|                         ) | ||||
|                     } | ||||
|                     placeholder="Select type" | ||||
|                     emptyPlaceholder="No types found" | ||||
|                 /> | ||||
|  | ||||
|                 <div className="flex justify-center"> | ||||
|                     <Checkbox | ||||
|                         checked={field.primaryKey || false} | ||||
|                         onCheckedChange={(checked) => | ||||
|                             onPrimaryKeyChange(field.id, checked as boolean) | ||||
|                         } | ||||
|                         className="size-5" | ||||
|                     /> | ||||
|                 </div> | ||||
|  | ||||
|                 <Button | ||||
|                     variant="ghost" | ||||
|                     size="sm" | ||||
|                     className="size-8 p-0 hover:bg-red-100 dark:hover:bg-red-900" | ||||
|                     onClick={() => onRemove(field.id)} | ||||
|                 > | ||||
|                     <Trash2 className="size-4 text-red-600" /> | ||||
|                 </Button> | ||||
|             </div> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
|  | ||||
| FieldRow.displayName = 'FieldRow'; | ||||
|  | ||||
| // Helper function to generate field regex patterns | ||||
| const generateFieldRegexPatterns = ( | ||||
|     dataType: DataTypeData | ||||
| ): { | ||||
|     regex?: string; | ||||
|     extractRegex?: RegExp; | ||||
| } => { | ||||
|     if (!dataType.fieldAttributes) { | ||||
|         return { regex: undefined, extractRegex: undefined }; | ||||
|     } | ||||
|  | ||||
|     const typeName = dataType.name; | ||||
|     const fieldAttributes = dataType.fieldAttributes; | ||||
|  | ||||
|     if (fieldAttributes.hasCharMaxLength) { | ||||
|         if (fieldAttributes.hasCharMaxLengthOption) { | ||||
|             return { | ||||
|                 regex: `^${typeName}\\((\\d+|[mM][aA][xX])\\)$`, | ||||
|                 extractRegex: /\((\d+|max)\)/i, | ||||
|             }; | ||||
|         } | ||||
|         return { | ||||
|             regex: `^${typeName}\\(\\d+\\)$`, | ||||
|             extractRegex: /\((\d+)\)/, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     if (fieldAttributes.precision && fieldAttributes.scale) { | ||||
|         return { | ||||
|             regex: `^${typeName}\\s*\\(\\s*\\d+\\s*(?:,\\s*\\d+\\s*)?\\)$`, | ||||
|             extractRegex: new RegExp( | ||||
|                 `${typeName}\\s*\\(\\s*(\\d+)\\s*(?:,\\s*(\\d+)\\s*)?\\)` | ||||
|             ), | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     if (fieldAttributes.precision) { | ||||
|         return { | ||||
|             regex: `^${typeName}\\s*\\(\\s*\\d+\\s*\\)$`, | ||||
|             extractRegex: /\((\d+)\)/, | ||||
|         }; | ||||
|     } | ||||
|  | ||||
|     return { regex: undefined, extractRegex: undefined }; | ||||
| }; | ||||
|  | ||||
| export const TableEditMode: React.FC<TableEditModeProps> = memo( | ||||
|     ({ table, color, focusFieldId, onClose }) => { | ||||
|         const { updateTable, databaseType, customTypes } = useChartDB(); | ||||
|         const [tableName, setTableName] = useState(() => table.name); | ||||
|         const [localFields, setLocalFields] = useState<DBField[]>(() => | ||||
|             (table.fields || []).map((field) => ({ | ||||
|                 ...field, | ||||
|                 primaryKey: field.primaryKey || false, | ||||
|             })) | ||||
|         ); | ||||
|         const [removedFieldIds, setRemovedFieldIds] = useState<string[]>( | ||||
|             () => [] | ||||
|         ); | ||||
|         const [newlyCreatedFields, setNewlyCreatedFields] = useState<DBField[]>( | ||||
|             () => [] | ||||
|         ); | ||||
|         const [newFieldId, setNewFieldId] = useState<string | null>(null); | ||||
|         const containerRef = useRef<HTMLDivElement>(null); | ||||
|         const scrollContainerRef = useRef<HTMLDivElement>(null); | ||||
|         const tableNameInputRef = useRef<HTMLInputElement>(null); | ||||
|         const fieldInputRefs = useRef<{ | ||||
|             [key: string]: HTMLInputElement | null; | ||||
|         }>({}); | ||||
|         const hasInitialFocusedRef = useRef(false); | ||||
|  | ||||
|         // Use refs to get latest state values in callbacks | ||||
|         const tableNameRef = useRef(tableName); | ||||
|         const localFieldsRef = useRef(localFields); | ||||
|         const removedFieldIdsRef = useRef(removedFieldIds); | ||||
|         const newlyCreatedFieldsRef = useRef(newlyCreatedFields); | ||||
|  | ||||
|         useEffect(() => { | ||||
|             tableNameRef.current = tableName; | ||||
|         }, [tableName]); | ||||
|  | ||||
|         useEffect(() => { | ||||
|             localFieldsRef.current = localFields; | ||||
|         }, [localFields]); | ||||
|  | ||||
|         useEffect(() => { | ||||
|             removedFieldIdsRef.current = removedFieldIds; | ||||
|         }, [removedFieldIds]); | ||||
|  | ||||
|         useEffect(() => { | ||||
|             newlyCreatedFieldsRef.current = newlyCreatedFields; | ||||
|         }, [newlyCreatedFields]); | ||||
|  | ||||
|         const dataTypes = useMemo( | ||||
|             () => | ||||
|                 sortedDataTypeMap[databaseType] || | ||||
|                 sortedDataTypeMap[DatabaseType.GENERIC], | ||||
|             [databaseType] | ||||
|         ); | ||||
|  | ||||
|         // Generate options for SelectBox similar to side panel | ||||
|         const dataTypeOptions = useMemo(() => { | ||||
|             const standardTypes: SelectBoxOption[] = dataTypes.map((type) => { | ||||
|                 const regexPatterns = generateFieldRegexPatterns(type); | ||||
|                 return { | ||||
|                     label: type.name, | ||||
|                     value: type.id, | ||||
|                     regex: regexPatterns.regex, | ||||
|                     extractRegex: regexPatterns.extractRegex, | ||||
|                     group: customTypes?.length ? 'Standard Types' : undefined, | ||||
|                 }; | ||||
|             }); | ||||
|  | ||||
|             if (!customTypes?.length) { | ||||
|                 return standardTypes; | ||||
|             } | ||||
|  | ||||
|             // Add custom types as options | ||||
|             const customTypeOptions: SelectBoxOption[] = customTypes.map( | ||||
|                 (type) => ({ | ||||
|                     label: type.name, | ||||
|                     value: type.name, | ||||
|                     description: | ||||
|                         type.kind === 'enum' | ||||
|                             ? `${type.values?.join(' | ')}` | ||||
|                             : '', | ||||
|                     group: 'Custom Types', | ||||
|                 }) | ||||
|             ); | ||||
|  | ||||
|             return [...standardTypes, ...customTypeOptions]; | ||||
|         }, [dataTypes, customTypes]); | ||||
|  | ||||
|         // Focus on specific field when opened from pencil click, or table name when double-clicked | ||||
|         useEffect(() => { | ||||
|             // Only perform initial focus once | ||||
|             if (hasInitialFocusedRef.current) { | ||||
|                 return; | ||||
|             } | ||||
|  | ||||
|             hasInitialFocusedRef.current = true; | ||||
|  | ||||
|             if (focusFieldId && scrollContainerRef.current) { | ||||
|                 // Find the field index to calculate scroll position | ||||
|                 const fieldIndex = localFields.findIndex( | ||||
|                     (f) => f.id === focusFieldId | ||||
|                 ); | ||||
|                 if (fieldIndex !== -1) { | ||||
|                     // Scroll to the field (each field is approximately 56px height) | ||||
|                     const scrollPosition = fieldIndex * 56; | ||||
|                     scrollContainerRef.current.scrollTo({ | ||||
|                         top: scrollPosition, | ||||
|                         behavior: 'smooth', | ||||
|                     }); | ||||
|  | ||||
|                     // Focus and select the field name after scroll | ||||
|                     setTimeout(() => { | ||||
|                         const input = fieldInputRefs.current[focusFieldId]; | ||||
|                         if (input) { | ||||
|                             input.focus(); | ||||
|                             input.select(); | ||||
|                         } | ||||
|                     }, 300); | ||||
|                 } | ||||
|             } else if (!focusFieldId && tableNameInputRef.current) { | ||||
|                 // Only focus table name on initial mount when no specific field is targeted | ||||
|                 // This prevents focus jumping when typing in field names | ||||
|                 setTimeout(() => { | ||||
|                     if (tableNameInputRef.current) { | ||||
|                         tableNameInputRef.current.focus(); | ||||
|                         tableNameInputRef.current.select(); | ||||
|                     } | ||||
|                 }, 100); | ||||
|             } | ||||
|         }, [focusFieldId, localFields]); | ||||
|  | ||||
|         // Focus and select text when a new field is added | ||||
|         useEffect(() => { | ||||
|             if (newFieldId) { | ||||
|                 // Wait for the next render cycle and for the scroll to complete | ||||
|                 const focusTimer = setTimeout(() => { | ||||
|                     const input = fieldInputRefs.current[newFieldId]; | ||||
|                     if (input) { | ||||
|                         input.focus(); | ||||
|                         // Small delay to ensure focus is set before selecting | ||||
|                         setTimeout(() => { | ||||
|                             input.select(); | ||||
|                         }, 50); | ||||
|                     } | ||||
|                     setNewFieldId(null); | ||||
|                 }, 100); | ||||
|  | ||||
|                 return () => clearTimeout(focusTimer); | ||||
|             } | ||||
|         }, [newFieldId]); | ||||
|  | ||||
|         // Save all changes when closing the edit mode | ||||
|         const saveAllChanges = useCallback(() => { | ||||
|             const currentTableName = tableNameRef.current; | ||||
|             const currentLocalFields = localFieldsRef.current; | ||||
|             const currentRemovedFieldIds = removedFieldIdsRef.current; | ||||
|             const currentNewlyCreatedFields = newlyCreatedFieldsRef.current; | ||||
|  | ||||
|             // Always save to ensure field changes are persisted | ||||
|             // Build the final fields array with all changes | ||||
|             const finalFields: DBField[] = []; | ||||
|  | ||||
|             // Process all fields - both existing and new | ||||
|             for (const field of currentLocalFields) { | ||||
|                 const isNewField = currentNewlyCreatedFields.some( | ||||
|                     (f) => f.id === field.id | ||||
|                 ); | ||||
|  | ||||
|                 if (isNewField) { | ||||
|                     // For new fields, replace temp ID with a proper ID | ||||
|                     finalFields.push({ | ||||
|                         ...field, | ||||
|                         id: generateId(), // Generate a proper ID for the new field | ||||
|                         primaryKey: field.primaryKey || false, | ||||
|                         createdAt: Date.now(), | ||||
|                     }); | ||||
|                 } else if (!currentRemovedFieldIds.includes(field.id)) { | ||||
|                     // Existing field that wasn't removed - ensure all properties are included | ||||
|                     finalFields.push({ | ||||
|                         ...field, | ||||
|                         primaryKey: field.primaryKey || false, | ||||
|                     }); | ||||
|                 } | ||||
|             } | ||||
|  | ||||
|             // Build the update object with all changes | ||||
|             const tableUpdates: Partial<DBTable> = { | ||||
|                 fields: finalFields, | ||||
|             }; | ||||
|  | ||||
|             // Add name change if needed | ||||
|             if (currentTableName.trim() && currentTableName !== table.name) { | ||||
|                 tableUpdates.name = currentTableName.trim(); | ||||
|             } | ||||
|  | ||||
|             // Make a single update call with all changes | ||||
|             // Return the promise so we can handle it properly | ||||
|             return updateTable(table.id, tableUpdates); | ||||
|         }, [table, updateTable]); | ||||
|  | ||||
|         // Save on unmount if there are changes | ||||
|         useEffect(() => { | ||||
|             return () => { | ||||
|                 // Clean up refs | ||||
|                 fieldInputRefs.current = {}; | ||||
|  | ||||
|                 // Save any pending changes when component unmounts | ||||
|                 // This ensures changes are saved even if the component is unmounted quickly | ||||
|  | ||||
|                 // Check if any existing fields have been modified | ||||
|                 const fieldsModified = localFieldsRef.current.some( | ||||
|                     (localField) => { | ||||
|                         const originalField = table.fields.find( | ||||
|                             (f) => f.id === localField.id | ||||
|                         ); | ||||
|                         if (!originalField) return false; // This is a new field | ||||
|  | ||||
|                         // Check if any properties have changed | ||||
|                         return ( | ||||
|                             originalField.name !== localField.name || | ||||
|                             (originalField.primaryKey || false) !== | ||||
|                                 (localField.primaryKey || false) || | ||||
|                             originalField.type.id !== localField.type.id || | ||||
|                             originalField.nullable !== localField.nullable || | ||||
|                             originalField.unique !== localField.unique | ||||
|                         ); | ||||
|                     } | ||||
|                 ); | ||||
|  | ||||
|                 const hasChanges = | ||||
|                     tableNameRef.current !== table.name || | ||||
|                     localFieldsRef.current.length !== table.fields.length || | ||||
|                     removedFieldIdsRef.current.length > 0 || | ||||
|                     newlyCreatedFieldsRef.current.length > 0 || | ||||
|                     fieldsModified; | ||||
|  | ||||
|                 if (hasChanges) { | ||||
|                     // Use the refs directly since the component is unmounting | ||||
|                     const finalFields: DBField[] = []; | ||||
|                     for (const field of localFieldsRef.current) { | ||||
|                         const isNewField = newlyCreatedFieldsRef.current.some( | ||||
|                             (f) => f.id === field.id | ||||
|                         ); | ||||
|                         if (isNewField) { | ||||
|                             finalFields.push({ | ||||
|                                 ...field, | ||||
|                                 id: generateId(), | ||||
|                                 primaryKey: field.primaryKey || false, | ||||
|                                 createdAt: Date.now(), | ||||
|                             }); | ||||
|                         } else if ( | ||||
|                             !removedFieldIdsRef.current.includes(field.id) | ||||
|                         ) { | ||||
|                             finalFields.push({ | ||||
|                                 ...field, | ||||
|                                 primaryKey: field.primaryKey || false, | ||||
|                             }); | ||||
|                         } | ||||
|                     } | ||||
|  | ||||
|                     const tableUpdates: Partial<DBTable> = { | ||||
|                         fields: finalFields, | ||||
|                     }; | ||||
|  | ||||
|                     if ( | ||||
|                         tableNameRef.current.trim() && | ||||
|                         tableNameRef.current !== table.name | ||||
|                     ) { | ||||
|                         tableUpdates.name = tableNameRef.current.trim(); | ||||
|                     } | ||||
|  | ||||
|                     // Fire and forget - component is unmounting | ||||
|                     updateTable(table.id, tableUpdates); | ||||
|                 } | ||||
|             }; | ||||
|         }, [table, updateTable]); | ||||
|  | ||||
|         // Handle click outside - using both mousedown and click for better compatibility | ||||
|         useEffect(() => { | ||||
|             const handleClickOutside = (event: MouseEvent) => { | ||||
|                 // Check if click is inside the edit mode container | ||||
|                 if ( | ||||
|                     containerRef.current && | ||||
|                     !containerRef.current.contains(event.target as Node) | ||||
|                 ) { | ||||
|                     // Check if the click is on a select dropdown portal element | ||||
|                     const target = event.target as HTMLElement; | ||||
|                     const isSelectPortal = | ||||
|                         target.closest('[data-radix-select-viewport]') || | ||||
|                         target.closest('[role="listbox"]') || | ||||
|                         target.closest('[data-radix-popper-content-wrapper]') || | ||||
|                         target.closest('[data-state="open"]') || | ||||
|                         target.closest('[data-radix-select-content]'); | ||||
|  | ||||
|                     // Don't close if clicking on select dropdown | ||||
|                     if (isSelectPortal) { | ||||
|                         return; | ||||
|                     } | ||||
|  | ||||
|                     event.stopPropagation(); | ||||
|                     // Save and close - optimized approach | ||||
|                     const savePromise = saveAllChanges(); | ||||
|                     onClose(); | ||||
|                     // Ensure save completes even after component unmounts | ||||
|                     if (savePromise) { | ||||
|                         savePromise.catch((error) => { | ||||
|                             console.error( | ||||
|                                 'Failed to save table changes:', | ||||
|                                 error | ||||
|                             ); | ||||
|                         }); | ||||
|                     } | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Prevent wheel events from propagating to the canvas | ||||
|             const handleWheel = (event: WheelEvent) => { | ||||
|                 if ( | ||||
|                     containerRef.current && | ||||
|                     containerRef.current.contains(event.target as Node) | ||||
|                 ) { | ||||
|                     event.stopPropagation(); | ||||
|                 } | ||||
|             }; | ||||
|  | ||||
|             // Add event listener after a very small delay to avoid immediate closing | ||||
|             const timer = setTimeout(() => { | ||||
|                 // Only use mousedown to handle outside clicks | ||||
|                 // Using both mousedown and click can cause issues with dropdowns | ||||
|                 document.addEventListener( | ||||
|                     'mousedown', | ||||
|                     handleClickOutside, | ||||
|                     true | ||||
|                 ); | ||||
|                 document.addEventListener('wheel', handleWheel, { | ||||
|                     passive: false, | ||||
|                     capture: true, | ||||
|                 }); | ||||
|             }, 50); | ||||
|  | ||||
|             return () => { | ||||
|                 clearTimeout(timer); | ||||
|                 document.removeEventListener( | ||||
|                     'mousedown', | ||||
|                     handleClickOutside, | ||||
|                     true | ||||
|                 ); | ||||
|                 document.removeEventListener('wheel', handleWheel, true); | ||||
|             }; | ||||
|         }, [onClose, saveAllChanges]); | ||||
|  | ||||
|         const handleTableNameChange = useCallback( | ||||
|             (e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|                 setTableName(e.target.value); | ||||
|             }, | ||||
|             [] | ||||
|         ); | ||||
|  | ||||
|         const handleFieldNameChange = useCallback( | ||||
|             (fieldId: string, e: React.ChangeEvent<HTMLInputElement>) => { | ||||
|                 const newName = e.target.value; | ||||
|                 setLocalFields((prev) => { | ||||
|                     const newFields = [...prev]; | ||||
|                     const index = newFields.findIndex((f) => f.id === fieldId); | ||||
|                     if (index !== -1) { | ||||
|                         newFields[index] = { | ||||
|                             ...newFields[index], | ||||
|                             name: newName, | ||||
|                         }; | ||||
|                     } | ||||
|                     return newFields; | ||||
|                 }); | ||||
|             }, | ||||
|             [] | ||||
|         ); | ||||
|  | ||||
|         const handleFieldTypeChange = useCallback( | ||||
|             (fieldId: string, typeId: string, regexMatches?: string[]) => { | ||||
|                 const field = localFields.find((f) => f.id === fieldId); | ||||
|                 if (!field) return; | ||||
|  | ||||
|                 const dataType = dataTypes.find((v) => v.id === typeId) ?? { | ||||
|                     id: typeId, | ||||
|                     name: typeId, | ||||
|                 }; | ||||
|  | ||||
|                 let characterMaximumLength: string | undefined = undefined; | ||||
|                 let precision: number | undefined = undefined; | ||||
|                 let scale: number | undefined = undefined; | ||||
|  | ||||
|                 if (regexMatches?.length) { | ||||
|                     if (dataType?.fieldAttributes?.hasCharMaxLength) { | ||||
|                         characterMaximumLength = regexMatches[1]?.toLowerCase(); | ||||
|                     } else if ( | ||||
|                         dataType?.fieldAttributes?.precision && | ||||
|                         dataType?.fieldAttributes?.scale | ||||
|                     ) { | ||||
|                         precision = parseInt(regexMatches[1]); | ||||
|                         scale = regexMatches[2] | ||||
|                             ? parseInt(regexMatches[2]) | ||||
|                             : undefined; | ||||
|                     } else if (dataType?.fieldAttributes?.precision) { | ||||
|                         precision = parseInt(regexMatches[1]); | ||||
|                     } | ||||
|                 } else { | ||||
|                     // Preserve existing values if compatible | ||||
|                     if ( | ||||
|                         dataType?.fieldAttributes?.hasCharMaxLength && | ||||
|                         field.characterMaximumLength | ||||
|                     ) { | ||||
|                         characterMaximumLength = field.characterMaximumLength; | ||||
|                     } | ||||
|  | ||||
|                     if ( | ||||
|                         dataType?.fieldAttributes?.precision && | ||||
|                         field.precision | ||||
|                     ) { | ||||
|                         precision = field.precision; | ||||
|                     } | ||||
|  | ||||
|                     if (dataType?.fieldAttributes?.scale && field.scale) { | ||||
|                         scale = field.scale; | ||||
|                     } | ||||
|                 } | ||||
|  | ||||
|                 setLocalFields((prev) => | ||||
|                     prev.map((f) => | ||||
|                         f.id === fieldId | ||||
|                             ? { | ||||
|                                   ...f, | ||||
|                                   type: dataTypeDataToDataType(dataType), | ||||
|                                   characterMaximumLength, | ||||
|                                   precision, | ||||
|                                   scale, | ||||
|                                   increment: undefined, | ||||
|                                   default: undefined, | ||||
|                               } | ||||
|                             : f | ||||
|                     ) | ||||
|                 ); | ||||
|             }, | ||||
|             [dataTypes, localFields] | ||||
|         ); | ||||
|  | ||||
|         const handleFieldPrimaryKeyChange = useCallback( | ||||
|             (fieldId: string, primaryKey: boolean) => { | ||||
|                 setLocalFields((prev) => | ||||
|                     prev.map((field) => | ||||
|                         field.id === fieldId | ||||
|                             ? { ...field, primaryKey: primaryKey } | ||||
|                             : field | ||||
|                     ) | ||||
|                 ); | ||||
|             }, | ||||
|             [] | ||||
|         ); | ||||
|  | ||||
|         const handleAddField = useCallback(() => { | ||||
|             // Create a temporary field locally without saving to database | ||||
|             // Default to varchar(100) if available, otherwise use first type | ||||
|             const varcharType = dataTypes.find( | ||||
|                 (dt) => dt.id === 'varchar' || dt.id === 'character_varying' | ||||
|             ); | ||||
|             const defaultType = varcharType || | ||||
|                 dataTypes[0] || { id: 'text', name: 'text' }; | ||||
|  | ||||
|             const tempField: DBField = { | ||||
|                 id: `temp-${generateId()}`, // Temporary ID | ||||
|                 name: `field${localFields.length + 1}`, | ||||
|                 type: dataTypeDataToDataType(defaultType), | ||||
|                 characterMaximumLength: varcharType ? '100' : undefined, | ||||
|                 nullable: true, | ||||
|                 unique: false, | ||||
|                 primaryKey: false, | ||||
|                 createdAt: Date.now(), | ||||
|             }; | ||||
|  | ||||
|             setLocalFields((prev) => [...prev, tempField]); | ||||
|             setNewlyCreatedFields((prev) => [...prev, tempField]); | ||||
|             setNewFieldId(tempField.id); | ||||
|  | ||||
|             // Scroll to bottom after a minimal delay to ensure the new field is rendered | ||||
|             requestAnimationFrame(() => { | ||||
|                 if (scrollContainerRef.current) { | ||||
|                     scrollContainerRef.current.scrollTo({ | ||||
|                         top: scrollContainerRef.current.scrollHeight, | ||||
|                         behavior: 'smooth', | ||||
|                     }); | ||||
|                 } | ||||
|             }); | ||||
|         }, [localFields.length, dataTypes]); | ||||
|  | ||||
|         const handleRemoveField = useCallback( | ||||
|             (fieldId: string) => { | ||||
|                 // Check if this field was created in this session | ||||
|                 const isNewField = newlyCreatedFields.some( | ||||
|                     (f) => f.id === fieldId | ||||
|                 ); | ||||
|  | ||||
|                 if (isNewField) { | ||||
|                     // Just remove from local state, it was never saved to database | ||||
|                     setNewlyCreatedFields((prev) => | ||||
|                         prev.filter((f) => f.id !== fieldId) | ||||
|                     ); | ||||
|                     setLocalFields((prev) => | ||||
|                         prev.filter((field) => field.id !== fieldId) | ||||
|                     ); | ||||
|                 } else { | ||||
|                     // Mark existing field for removal on close | ||||
|                     setRemovedFieldIds((prev) => [...prev, fieldId]); | ||||
|                     setLocalFields((prev) => | ||||
|                         prev.filter((field) => field.id !== fieldId) | ||||
|                     ); | ||||
|                 } | ||||
|             }, | ||||
|             [newlyCreatedFields] | ||||
|         ); | ||||
|  | ||||
|         // Calculate dynamic height based on number of fields | ||||
|         // Max height is 80% of viewport or 600px, whichever is smaller | ||||
|         const maxHeight = Math.min(window.innerHeight * 0.8, 600); | ||||
|         const calculatedHeight = 240 + localFields.length * 56; // header + fields + button + padding | ||||
|         const editModeHeight = Math.min( | ||||
|             maxHeight, | ||||
|             Math.max(320, calculatedHeight) | ||||
|         ); | ||||
|         const isScrollable = calculatedHeight > maxHeight; | ||||
|  | ||||
|         return ( | ||||
|             <> | ||||
|                 {/* Invisible overlay to capture clicks in the canvas */} | ||||
|                 <div | ||||
|                     className="fixed inset-[-9999px] z-40" | ||||
|                     style={{ width: '99999px', height: '99999px' }} | ||||
|                     onMouseDown={(e) => { | ||||
|                         e.stopPropagation(); | ||||
|                         // Save and close | ||||
|                         const savePromise = saveAllChanges(); | ||||
|                         onClose(); | ||||
|                         // Ensure save completes | ||||
|                         if (savePromise) { | ||||
|                             savePromise.catch((error) => { | ||||
|                                 console.error( | ||||
|                                     'Failed to save table changes:', | ||||
|                                     error | ||||
|                                 ); | ||||
|                             }); | ||||
|                         } | ||||
|                     }} | ||||
|                 /> | ||||
|                 <div | ||||
|                     ref={containerRef} | ||||
|                     // eslint-disable-next-line tailwindcss/no-custom-classname | ||||
|                     className="nowheel nopan nodrag absolute z-50 flex min-w-[500px] flex-col rounded-lg border-2 border-blue-500 bg-white shadow-2xl dark:bg-slate-950" | ||||
|                     style={{ | ||||
|                         left: '-50%', | ||||
|                         right: '-50%', | ||||
|                         top: '50%', | ||||
|                         transform: 'translateY(-50%)', | ||||
|                         height: `${editModeHeight}px`, | ||||
|                     }} | ||||
|                     onClick={(e) => e.stopPropagation()} | ||||
|                     onMouseDown={(e) => e.stopPropagation()} | ||||
|                     onPointerDown={(e) => e.stopPropagation()} | ||||
|                     onPointerMove={(e) => e.stopPropagation()} | ||||
|                     onWheel={(e) => e.stopPropagation()} | ||||
|                 > | ||||
|                     {/* Color header bar */} | ||||
|                     <div | ||||
|                         className="h-2 rounded-t-[6px]" | ||||
|                         style={{ backgroundColor: color || '#6b7280' }} | ||||
|                     /> | ||||
|                     <div className="flex items-center justify-between border-b bg-slate-100 p-4 dark:bg-slate-900"> | ||||
|                         <Input | ||||
|                             ref={tableNameInputRef} | ||||
|                             value={tableName} | ||||
|                             onChange={handleTableNameChange} | ||||
|                             className="mr-3 h-10 flex-1 text-base font-bold" | ||||
|                             placeholder="Table name" | ||||
|                         /> | ||||
|                         <Button | ||||
|                             variant="ghost" | ||||
|                             size="sm" | ||||
|                             onClick={() => { | ||||
|                                 // Save and close | ||||
|                                 const savePromise = saveAllChanges(); | ||||
|                                 onClose(); | ||||
|                                 // Ensure save completes | ||||
|                                 if (savePromise) { | ||||
|                                     savePromise.catch((error) => { | ||||
|                                         console.error( | ||||
|                                             'Failed to save table changes:', | ||||
|                                             error | ||||
|                                         ); | ||||
|                                     }); | ||||
|                                 } | ||||
|                             }} | ||||
|                             className="hover:bg-slate-200 dark:hover:bg-slate-800" | ||||
|                         > | ||||
|                             <X className="size-5" /> | ||||
|                         </Button> | ||||
|                     </div> | ||||
|  | ||||
|                     <div className="relative flex flex-1 flex-col overflow-hidden"> | ||||
|                         <div className="p-4 pb-0"> | ||||
|                             {localFields.length > 0 && ( | ||||
|                                 <div className="mb-3 grid grid-cols-[1fr,150px,60px,40px] gap-3 px-2 text-sm font-semibold text-slate-700 dark:text-slate-300"> | ||||
|                                     <div>Field Name</div> | ||||
|                                     <div>Type</div> | ||||
|                                     <div className="text-center">PK</div> | ||||
|                                     <div></div> | ||||
|                                 </div> | ||||
|                             )} | ||||
|                         </div> | ||||
|  | ||||
|                         <div | ||||
|                             ref={scrollContainerRef} | ||||
|                             className={cn( | ||||
|                                 'flex-1 overflow-y-auto px-4 pr-3 custom-scrollbar relative nowheel', | ||||
|                                 isScrollable && 'pb-2' | ||||
|                             )} | ||||
|                             style={{ | ||||
|                                 scrollbarGutter: 'stable', | ||||
|                                 overflowY: | ||||
|                                     localFields.length > 0 ? 'auto' : 'hidden', | ||||
|                             }} | ||||
|                             onWheel={(e) => e.stopPropagation()} | ||||
|                             onPointerMove={(e) => e.stopPropagation()} | ||||
|                             onMouseMove={(e) => e.stopPropagation()} | ||||
|                         > | ||||
|                             {localFields.length > 0 ? ( | ||||
|                                 <> | ||||
|                                     {localFields.map((field) => ( | ||||
|                                         <FieldRow | ||||
|                                             key={field.id} | ||||
|                                             field={field} | ||||
|                                             dataTypeOptions={dataTypeOptions} | ||||
|                                             databaseType={databaseType} | ||||
|                                             onNameChange={handleFieldNameChange} | ||||
|                                             onTypeChange={handleFieldTypeChange} | ||||
|                                             onPrimaryKeyChange={ | ||||
|                                                 handleFieldPrimaryKeyChange | ||||
|                                             } | ||||
|                                             onRemove={handleRemoveField} | ||||
|                                             inputRef={(el) => { | ||||
|                                                 if (el) | ||||
|                                                     fieldInputRefs.current[ | ||||
|                                                         field.id | ||||
|                                                     ] = el; | ||||
|                                             }} | ||||
|                                         /> | ||||
|                                     ))} | ||||
|                                 </> | ||||
|                             ) : ( | ||||
|                                 <div className="py-8 text-center text-base text-slate-500 dark:text-slate-400"> | ||||
|                                     No fields yet. Click "Add Field" to create | ||||
|                                     one. | ||||
|                                 </div> | ||||
|                             )} | ||||
|                         </div> | ||||
|  | ||||
|                         {/* Fade overlay at bottom when scrollable */} | ||||
|                         {isScrollable && localFields.length > 5 && ( | ||||
|                             <div className="pointer-events-none absolute inset-x-0 bottom-0 h-8 bg-gradient-to-t from-white to-transparent dark:from-slate-950" /> | ||||
|                         )} | ||||
|  | ||||
|                         <div className="relative z-10 flex items-center justify-between gap-4 border-t bg-slate-50 p-4 dark:bg-slate-900"> | ||||
|                             <Button | ||||
|                                 variant="outline" | ||||
|                                 size="default" | ||||
|                                 className="h-11 max-w-[45%] flex-1 text-base" | ||||
|                                 onClick={handleAddField} | ||||
|                             > | ||||
|                                 <Plus className="mr-2 size-5" /> | ||||
|                                 Add Field | ||||
|                             </Button> | ||||
|                             <span className="mr-2 text-sm font-medium text-slate-600 dark:text-slate-400"> | ||||
|                                 {localFields.length}{' '} | ||||
|                                 {localFields.length === 1 | ||||
|                                     ? 'Column' | ||||
|                                     : 'Columns'} | ||||
|                             </span> | ||||
|                         </div> | ||||
|                     </div> | ||||
|                 </div> | ||||
|             </> | ||||
|         ); | ||||
|     } | ||||
| ); | ||||
|  | ||||
| TableEditMode.displayName = 'TableEditMode'; | ||||
| @@ -19,7 +19,7 @@ import { | ||||
|     SquareDot, | ||||
|     SquareMinus, | ||||
|     SquarePlus, | ||||
|     Trash2, | ||||
|     Pencil, | ||||
| } from 'lucide-react'; | ||||
| import { generateDBFieldSuffix, type DBField } from '@/lib/domain/db-field'; | ||||
| import { useChartDB } from '@/hooks/use-chartdb'; | ||||
| @@ -49,6 +49,7 @@ export interface TableNodeFieldProps { | ||||
|     highlighted: boolean; | ||||
|     visible: boolean; | ||||
|     isConnectable: boolean; | ||||
|     onOpenEditMode?: () => void; | ||||
| } | ||||
|  | ||||
| const arePropsEqual = ( | ||||
| @@ -72,19 +73,23 @@ const arePropsEqual = ( | ||||
|         prevProps.highlighted === nextProps.highlighted && | ||||
|         prevProps.visible === nextProps.visible && | ||||
|         prevProps.isConnectable === nextProps.isConnectable && | ||||
|         prevProps.tableNodeId === nextProps.tableNodeId | ||||
|         prevProps.tableNodeId === nextProps.tableNodeId && | ||||
|         prevProps.onOpenEditMode === nextProps.onOpenEditMode | ||||
|     ); | ||||
| }; | ||||
|  | ||||
| export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo( | ||||
|     ({ field, focused, tableNodeId, highlighted, visible, isConnectable }) => { | ||||
|         const { | ||||
|             removeField, | ||||
|             relationships, | ||||
|             readonly, | ||||
|             updateField, | ||||
|             highlightedCustomType, | ||||
|         } = useChartDB(); | ||||
|     ({ | ||||
|         field, | ||||
|         focused, | ||||
|         tableNodeId, | ||||
|         highlighted, | ||||
|         visible, | ||||
|         isConnectable, | ||||
|         onOpenEditMode, | ||||
|     }) => { | ||||
|         const { relationships, readonly, updateField, highlightedCustomType } = | ||||
|             useChartDB(); | ||||
|         const [editMode, setEditMode] = useState(false); | ||||
|         const [fieldName, setFieldName] = useState(field.name); | ||||
|         const inputRef = React.useRef<HTMLInputElement>(null); | ||||
| @@ -514,20 +519,21 @@ export const TableNodeField: React.FC<TableNodeFieldProps> = React.memo( | ||||
|                                 )} | ||||
|                             </span> | ||||
|                         </div> | ||||
|                         {readonly ? null : ( | ||||
|                             <div className="hidden flex-row group-hover:flex"> | ||||
|                         {!readonly && onOpenEditMode ? ( | ||||
|                             <div className="hidden items-center group-hover:flex"> | ||||
|                                 <Button | ||||
|                                     variant="ghost" | ||||
|                                     className="size-6 p-0 hover:bg-primary-foreground" | ||||
|                                     className="size-6 p-0.5 hover:bg-slate-200 dark:hover:bg-slate-700" | ||||
|                                     onClick={(e) => { | ||||
|                                         e.stopPropagation(); | ||||
|                                         removeField(tableNodeId, field.id); | ||||
|                                         e.preventDefault(); | ||||
|                                         onOpenEditMode(); | ||||
|                                     }} | ||||
|                                 > | ||||
|                                     <Trash2 className="size-3.5 text-red-700" /> | ||||
|                                     <Pencil className="size-3.5 text-slate-600 dark:text-slate-400" /> | ||||
|                                 </Button> | ||||
|                             </div> | ||||
|                         )} | ||||
|                         ) : null} | ||||
|                     </div> | ||||
|                 )} | ||||
|             </div> | ||||
|   | ||||
| @@ -6,7 +6,12 @@ import React, { | ||||
|     useEffect, | ||||
| } from 'react'; | ||||
| import type { NodeProps, Node } from '@xyflow/react'; | ||||
| import { NodeResizer, useConnection, useStore } from '@xyflow/react'; | ||||
| import { | ||||
|     NodeResizer, | ||||
|     useConnection, | ||||
|     useStore, | ||||
|     useReactFlow, | ||||
| } from '@xyflow/react'; | ||||
| import { Button } from '@/components/button/button'; | ||||
| import { | ||||
|     ChevronsLeftRight, | ||||
| @@ -47,6 +52,7 @@ import { | ||||
| } from '@/components/tooltip/tooltip'; | ||||
| import { useDiff } from '@/context/diff-context/use-diff'; | ||||
| import { TableNodeStatus } from './table-node-status/table-node-status'; | ||||
| import { TableEditMode } from './table-edit-mode'; | ||||
|  | ||||
| export type TableNodeType = Node< | ||||
|     { | ||||
| @@ -54,6 +60,7 @@ export type TableNodeType = Node< | ||||
|         isOverlapping: boolean; | ||||
|         highlightOverlappingTables?: boolean; | ||||
|         hasHighlightedCustomType?: boolean; | ||||
|         highlightTable?: boolean; | ||||
|     }, | ||||
|     'table' | ||||
| >; | ||||
| @@ -68,20 +75,45 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo( | ||||
|             isOverlapping, | ||||
|             highlightOverlappingTables, | ||||
|             hasHighlightedCustomType, | ||||
|             highlightTable, | ||||
|         }, | ||||
|     }) => { | ||||
|         const { updateTable, relationships, readonly } = useChartDB(); | ||||
|         const edges = useStore((store) => store.edges) as EdgeType[]; | ||||
|         const { openTableFromSidebar, selectSidebarSection } = useLayout(); | ||||
|         const { | ||||
|             openTableFromSidebar, | ||||
|             selectSidebarSection, | ||||
|             closeAllTablesInSidebar, | ||||
|         } = useLayout(); | ||||
|         const [expanded, setExpanded] = useState(table.expanded ?? false); | ||||
|         const { t } = useTranslation(); | ||||
|         const [editMode, setEditMode] = useState(false); | ||||
|         const [tableName, setTableName] = useState(table.name); | ||||
|         const inputRef = React.useRef<HTMLInputElement>(null); | ||||
|         const [isHovering, setIsHovering] = useState(false); | ||||
|         const [isTableEditMode, setIsTableEditMode] = useState(false); | ||||
|         const [focusFieldId, setFocusFieldId] = useState<string | undefined>( | ||||
|             undefined | ||||
|         ); | ||||
|         const { setNodes } = useReactFlow(); | ||||
|  | ||||
|         const connection = useConnection(); | ||||
|  | ||||
|         // Update node draggable state when edit mode changes | ||||
|         useEffect(() => { | ||||
|             setNodes((nodes) => | ||||
|                 nodes.map((node) => { | ||||
|                     if (node.id === id) { | ||||
|                         return { | ||||
|                             ...node, | ||||
|                             draggable: !isTableEditMode, | ||||
|                         }; | ||||
|                     } | ||||
|                     return node; | ||||
|                 }) | ||||
|             ); | ||||
|         }, [isTableEditMode, id, setNodes]); | ||||
|  | ||||
|         const isTarget = useMemo(() => { | ||||
|             if (!isHovering) return false; | ||||
|  | ||||
| @@ -321,6 +353,9 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo( | ||||
|                     hasHighlightedCustomType | ||||
|                         ? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-yellow-500 ring-offset-2 animate-scale' | ||||
|                         : '', | ||||
|                     highlightTable | ||||
|                         ? 'ring-2 ring-offset-slate-50 dark:ring-offset-slate-900 ring-blue-500 ring-offset-2 animate-scale-2' | ||||
|                         : '', | ||||
|                     isDiffTableChanged && | ||||
|                         !isSummaryOnly && | ||||
|                         !isDiffNewTable && | ||||
| @@ -332,18 +367,21 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo( | ||||
|                         : '', | ||||
|                     isDiffTableRemoved | ||||
|                         ? 'outline outline-[3px] outline-red-500 dark:outline-red-900 outline-offset-[5px]' | ||||
|                         : '' | ||||
|                         : '', | ||||
|                     isTableEditMode ? 'cursor-default' : '' | ||||
|                 ), | ||||
|             [ | ||||
|                 selected, | ||||
|                 isOverlapping, | ||||
|                 highlightOverlappingTables, | ||||
|                 hasHighlightedCustomType, | ||||
|                 highlightTable, | ||||
|                 isSummaryOnly, | ||||
|                 isDiffTableChanged, | ||||
|                 isDiffNewTable, | ||||
|                 isDiffTableRemoved, | ||||
|                 isTarget, | ||||
|                 isTableEditMode, | ||||
|             ] | ||||
|         ); | ||||
|  | ||||
| @@ -352,209 +390,277 @@ export const TableNode: React.FC<NodeProps<TableNodeType>> = React.memo( | ||||
|                 <div | ||||
|                     className={tableClassName} | ||||
|                     onClick={(e) => { | ||||
|                         if (e.detail === 2) { | ||||
|                             openTableInEditor(); | ||||
|                         if (e.detail === 2 && !readonly) { | ||||
|                             e.preventDefault(); | ||||
|                             e.stopPropagation(); | ||||
|                             setIsTableEditMode(true); | ||||
|                             // Close sidebar after a short delay to avoid blocking the UI | ||||
|                             setTimeout(() => { | ||||
|                                 closeAllTablesInSidebar(); | ||||
|                             }, 50); | ||||
|                         } | ||||
|                     }} | ||||
|                     onMouseEnter={() => setIsHovering(true)} | ||||
|                     onMouseLeave={() => setIsHovering(false)} | ||||
|                 > | ||||
|                     <NodeResizer | ||||
|                         isVisible={focused} | ||||
|                         lineClassName="!border-none !w-2" | ||||
|                         minWidth={MIN_TABLE_SIZE} | ||||
|                         maxWidth={MAX_TABLE_SIZE} | ||||
|                         shouldResize={(event) => event.dy === 0} | ||||
|                         handleClassName="!hidden" | ||||
|                     /> | ||||
|                     <TableNodeDependencyIndicator | ||||
|                         table={table} | ||||
|                         focused={focused} | ||||
|                     /> | ||||
|                     <TableNodeStatus | ||||
|                         status={ | ||||
|                             isDiffNewTable | ||||
|                                 ? 'new' | ||||
|                                 : isDiffTableRemoved | ||||
|                                   ? 'removed' | ||||
|                                   : isDiffTableChanged && !isSummaryOnly | ||||
|                                     ? 'changed' | ||||
|                                     : 'none' | ||||
|                         } | ||||
|                     /> | ||||
|                     <div | ||||
|                         className="h-2 rounded-t-[6px]" | ||||
|                         style={{ backgroundColor: tableColor }} | ||||
|                     ></div> | ||||
|                     <div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900"> | ||||
|                         <div className="flex min-w-0 flex-1 items-center gap-2"> | ||||
|                             {isDiffNewTable ? ( | ||||
|                                 <Tooltip> | ||||
|                                     <TooltipTrigger asChild> | ||||
|                                         <SquarePlus | ||||
|                                             className="size-3.5 shrink-0 text-green-600" | ||||
|                                             strokeWidth={2.5} | ||||
|                                         /> | ||||
|                                     </TooltipTrigger> | ||||
|                                     <TooltipContent>New Table</TooltipContent> | ||||
|                                 </Tooltip> | ||||
|                             ) : isDiffTableRemoved ? ( | ||||
|                                 <Tooltip> | ||||
|                                     <TooltipTrigger asChild> | ||||
|                                         <SquareMinus | ||||
|                                             className="size-3.5 shrink-0 text-red-600" | ||||
|                                             strokeWidth={2.5} | ||||
|                                         /> | ||||
|                                     </TooltipTrigger> | ||||
|                                     <TooltipContent> | ||||
|                                         Table Removed | ||||
|                                     </TooltipContent> | ||||
|                                 </Tooltip> | ||||
|                             ) : isDiffTableChanged && !isSummaryOnly ? ( | ||||
|                                 <Tooltip> | ||||
|                                     <TooltipTrigger asChild> | ||||
|                                         <SquareDot | ||||
|                                             className="size-3.5 shrink-0 text-sky-600" | ||||
|                                             strokeWidth={2.5} | ||||
|                                         /> | ||||
|                                     </TooltipTrigger> | ||||
|                                     <TooltipContent> | ||||
|                                         Table Changed | ||||
|                                     </TooltipContent> | ||||
|                                 </Tooltip> | ||||
|                             ) : ( | ||||
|                                 <Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" /> | ||||
|                             )} | ||||
|  | ||||
|                             {tableChangedName ? ( | ||||
|                                 <Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200"> | ||||
|                                     <span className="truncate"> | ||||
|                                         {table.name} | ||||
|                                     </span> | ||||
|                                     <span className="mx-1 font-semibold"> | ||||
|                                         → | ||||
|                                     </span> | ||||
|                                     <span className="truncate"> | ||||
|                                         {tableChangedName} | ||||
|                                     </span> | ||||
|                                 </Label> | ||||
|                             ) : isDiffNewTable ? ( | ||||
|                                 <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200"> | ||||
|                                     {table.name} | ||||
|                                 </Label> | ||||
|                             ) : isDiffTableRemoved ? ( | ||||
|                                 <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200"> | ||||
|                                     {table.name} | ||||
|                                 </Label> | ||||
|                             ) : isDiffTableChanged && !isSummaryOnly ? ( | ||||
|                                 <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200"> | ||||
|                                     {table.name} | ||||
|                                 </Label> | ||||
|                             ) : editMode && !readonly ? ( | ||||
|                                 <> | ||||
|                                     <Input | ||||
|                                         ref={inputRef} | ||||
|                                         onBlur={editTableName} | ||||
|                                         placeholder={table.name} | ||||
|                                         autoFocus | ||||
|                                         type="text" | ||||
|                                         value={tableName} | ||||
|                                         onClick={(e) => e.stopPropagation()} | ||||
|                                         onChange={(e) => | ||||
|                                             setTableName(e.target.value) | ||||
|                                         } | ||||
|                                         className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900" | ||||
|                     {/* Keep minimal table structure for connections even in edit mode */} | ||||
|                     {isTableEditMode && ( | ||||
|                         <> | ||||
|                             {/* Hidden fields to maintain connection handles */} | ||||
|                             <div | ||||
|                                 style={{ | ||||
|                                     position: 'absolute', | ||||
|                                     opacity: 0, | ||||
|                                     pointerEvents: 'none', | ||||
|                                 }} | ||||
|                             > | ||||
|                                 {table.fields.map((field: DBField) => ( | ||||
|                                     <TableNodeField | ||||
|                                         key={field.id} | ||||
|                                         focused={false} | ||||
|                                         tableNodeId={id} | ||||
|                                         field={field} | ||||
|                                         highlighted={false} | ||||
|                                         visible={false} | ||||
|                                         isConnectable={!table.isView} | ||||
|                                     /> | ||||
|                                     <Button | ||||
|                                         variant="ghost" | ||||
|                                         className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                         onClick={editTableName} | ||||
|                                     > | ||||
|                                         <Check className="size-4" /> | ||||
|                                     </Button> | ||||
|                                 </> | ||||
|                             ) : ( | ||||
|                                 <Tooltip> | ||||
|                                     <TooltipTrigger asChild> | ||||
|                                         <Label | ||||
|                                             className="text-editable truncate px-2 py-0.5 text-sm font-bold" | ||||
|                                             onDoubleClick={enterEditMode} | ||||
|                                         > | ||||
|                                 ))} | ||||
|                             </div> | ||||
|                             <TableEditMode | ||||
|                                 table={table} | ||||
|                                 color={tableColor} | ||||
|                                 focusFieldId={focusFieldId} | ||||
|                                 onClose={() => { | ||||
|                                     setIsTableEditMode(false); | ||||
|                                     setFocusFieldId(undefined); | ||||
|                                 }} | ||||
|                             /> | ||||
|                         </> | ||||
|                     )} | ||||
|                     {!isTableEditMode && ( | ||||
|                         <> | ||||
|                             {focused ? ( | ||||
|                                 <NodeResizer | ||||
|                                     isVisible={focused} | ||||
|                                     lineClassName="!border-none !w-2" | ||||
|                                     minWidth={MIN_TABLE_SIZE} | ||||
|                                     maxWidth={MAX_TABLE_SIZE} | ||||
|                                     shouldResize={(event) => event.dy === 0} | ||||
|                                     handleClassName="!hidden" | ||||
|                                 /> | ||||
|                             ) : null} | ||||
|                             <TableNodeDependencyIndicator | ||||
|                                 table={table} | ||||
|                                 focused={focused} | ||||
|                             /> | ||||
|                             <TableNodeStatus | ||||
|                                 status={ | ||||
|                                     isDiffNewTable | ||||
|                                         ? 'new' | ||||
|                                         : isDiffTableRemoved | ||||
|                                           ? 'removed' | ||||
|                                           : isDiffTableChanged && !isSummaryOnly | ||||
|                                             ? 'changed' | ||||
|                                             : 'none' | ||||
|                                 } | ||||
|                             /> | ||||
|                             <div | ||||
|                                 className="h-2 rounded-t-[6px]" | ||||
|                                 style={{ backgroundColor: tableColor }} | ||||
|                             ></div> | ||||
|                             <div className="group flex h-9 items-center justify-between bg-slate-200 px-2 dark:bg-slate-900"> | ||||
|                                 <div className="flex min-w-0 flex-1 items-center gap-2"> | ||||
|                                     {isDiffNewTable ? ( | ||||
|                                         <Tooltip> | ||||
|                                             <TooltipTrigger asChild> | ||||
|                                                 <SquarePlus | ||||
|                                                     className="size-3.5 shrink-0 text-green-600" | ||||
|                                                     strokeWidth={2.5} | ||||
|                                                 /> | ||||
|                                             </TooltipTrigger> | ||||
|                                             <TooltipContent> | ||||
|                                                 New Table | ||||
|                                             </TooltipContent> | ||||
|                                         </Tooltip> | ||||
|                                     ) : isDiffTableRemoved ? ( | ||||
|                                         <Tooltip> | ||||
|                                             <TooltipTrigger asChild> | ||||
|                                                 <SquareMinus | ||||
|                                                     className="size-3.5 shrink-0 text-red-600" | ||||
|                                                     strokeWidth={2.5} | ||||
|                                                 /> | ||||
|                                             </TooltipTrigger> | ||||
|                                             <TooltipContent> | ||||
|                                                 Table Removed | ||||
|                                             </TooltipContent> | ||||
|                                         </Tooltip> | ||||
|                                     ) : isDiffTableChanged && !isSummaryOnly ? ( | ||||
|                                         <Tooltip> | ||||
|                                             <TooltipTrigger asChild> | ||||
|                                                 <SquareDot | ||||
|                                                     className="size-3.5 shrink-0 text-sky-600" | ||||
|                                                     strokeWidth={2.5} | ||||
|                                                 /> | ||||
|                                             </TooltipTrigger> | ||||
|                                             <TooltipContent> | ||||
|                                                 Table Changed | ||||
|                                             </TooltipContent> | ||||
|                                         </Tooltip> | ||||
|                                     ) : ( | ||||
|                                         <Table2 className="size-3.5 shrink-0 text-gray-600 dark:text-primary" /> | ||||
|                                     )} | ||||
|  | ||||
|                                     {tableChangedName ? ( | ||||
|                                         <Label className="flex h-5 items-center justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200"> | ||||
|                                             <span className="truncate"> | ||||
|                                                 {table.name} | ||||
|                                             </span> | ||||
|                                             <span className="mx-1 font-semibold"> | ||||
|                                                 → | ||||
|                                             </span> | ||||
|                                             <span className="truncate"> | ||||
|                                                 {tableChangedName} | ||||
|                                             </span> | ||||
|                                         </Label> | ||||
|                                     ) : isDiffNewTable ? ( | ||||
|                                         <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-green-200 px-2 py-0.5 text-sm font-normal text-green-900 dark:bg-green-800 dark:text-green-200"> | ||||
|                                             {table.name} | ||||
|                                         </Label> | ||||
|                                     </TooltipTrigger> | ||||
|                                     <TooltipContent> | ||||
|                                         {t('tool_tips.double_click_to_edit')} | ||||
|                                     </TooltipContent> | ||||
|                                 </Tooltip> | ||||
|                             )} | ||||
|                         </div> | ||||
|                         <div className="hidden shrink-0 flex-row group-hover:flex"> | ||||
|                             {readonly || editMode ? null : ( | ||||
|                                 <Button | ||||
|                                     variant="ghost" | ||||
|                                     className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                     onClick={openTableInEditor} | ||||
|                                 > | ||||
|                                     <CircleDotDashed className="size-4" /> | ||||
|                                 </Button> | ||||
|                             )} | ||||
|                             {editMode ? null : ( | ||||
|                                 <Button | ||||
|                                     variant="ghost" | ||||
|                                     className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                     onClick={ | ||||
|                                         table.width !== MAX_TABLE_SIZE | ||||
|                                             ? expandTable | ||||
|                                             : shrinkTable | ||||
|                                     } | ||||
|                                 > | ||||
|                                     {table.width !== MAX_TABLE_SIZE ? ( | ||||
|                                         <ChevronsLeftRight className="size-4" /> | ||||
|                                     ) : isDiffTableRemoved ? ( | ||||
|                                         <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-red-200 px-2 py-0.5 text-sm font-normal text-red-900 dark:bg-red-800 dark:text-red-200"> | ||||
|                                             {table.name} | ||||
|                                         </Label> | ||||
|                                     ) : isDiffTableChanged && !isSummaryOnly ? ( | ||||
|                                         <Label className="flex h-5 flex-col justify-center truncate rounded-sm bg-sky-200 px-2 py-0.5 text-sm font-normal text-sky-900 dark:bg-sky-800 dark:text-sky-200"> | ||||
|                                             {table.name} | ||||
|                                         </Label> | ||||
|                                     ) : editMode && !readonly ? ( | ||||
|                                         <> | ||||
|                                             <Input | ||||
|                                                 ref={inputRef} | ||||
|                                                 onBlur={editTableName} | ||||
|                                                 placeholder={table.name} | ||||
|                                                 autoFocus | ||||
|                                                 type="text" | ||||
|                                                 value={tableName} | ||||
|                                                 onClick={(e) => | ||||
|                                                     e.stopPropagation() | ||||
|                                                 } | ||||
|                                                 onChange={(e) => | ||||
|                                                     setTableName(e.target.value) | ||||
|                                                 } | ||||
|                                                 className="h-6 w-full border-[0.5px] border-blue-400 bg-slate-100 focus-visible:ring-0 dark:bg-slate-900" | ||||
|                                             /> | ||||
|                                             <Button | ||||
|                                                 variant="ghost" | ||||
|                                                 className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                                 onClick={editTableName} | ||||
|                                             > | ||||
|                                                 <Check className="size-4" /> | ||||
|                                             </Button> | ||||
|                                         </> | ||||
|                                     ) : ( | ||||
|                                         <ChevronsRightLeft className="size-4" /> | ||||
|                                         <Tooltip> | ||||
|                                             <TooltipTrigger asChild> | ||||
|                                                 <Label | ||||
|                                                     className="text-editable truncate px-2 py-0.5 text-sm font-bold" | ||||
|                                                     onDoubleClick={ | ||||
|                                                         enterEditMode | ||||
|                                                     } | ||||
|                                                 > | ||||
|                                                     {table.name} | ||||
|                                                 </Label> | ||||
|                                             </TooltipTrigger> | ||||
|                                             <TooltipContent> | ||||
|                                                 {t( | ||||
|                                                     'tool_tips.double_click_to_edit' | ||||
|                                                 )} | ||||
|                                             </TooltipContent> | ||||
|                                         </Tooltip> | ||||
|                                     )} | ||||
|                                 </Button> | ||||
|                             )} | ||||
|                         </div> | ||||
|                     </div> | ||||
|                     <div | ||||
|                         className="transition-[max-height] duration-200 ease-in-out" | ||||
|                         style={{ | ||||
|                             maxHeight: expanded | ||||
|                                 ? `${fields.length * 2}rem` // h-8 per field | ||||
|                                 : `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field | ||||
|                         }} | ||||
|                     > | ||||
|                         {visibleFields.map((field: DBField) => ( | ||||
|                             <TableNodeField | ||||
|                                 key={field.id} | ||||
|                                 focused={focused} | ||||
|                                 tableNodeId={id} | ||||
|                                 field={field} | ||||
|                                 highlighted={highlightedFieldIds.has(field.id)} | ||||
|                                 visible={true} | ||||
|                                 isConnectable={!table.isView} | ||||
|                             /> | ||||
|                         ))} | ||||
|                     </div> | ||||
|                     {fields.length > TABLE_MINIMIZED_FIELDS && ( | ||||
|                         <div | ||||
|                             className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800" | ||||
|                             onClick={toggleExpand} | ||||
|                         > | ||||
|                             {expanded ? ( | ||||
|                                 <> | ||||
|                                     <ChevronUp className="mr-1 size-3.5" /> | ||||
|                                     {t('show_less')} | ||||
|                                 </> | ||||
|                             ) : ( | ||||
|                                 <> | ||||
|                                     <ChevronDown className="mr-1 size-3.5" /> | ||||
|                                     {t('show_more')} | ||||
|                                 </> | ||||
|                             )} | ||||
|                         </div> | ||||
|                                 </div> | ||||
|                                 <div className="hidden shrink-0 flex-row group-hover:flex"> | ||||
|                                     {readonly || editMode || !focused ? null : ( | ||||
|                                         <Button | ||||
|                                             variant="ghost" | ||||
|                                             className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                             onClick={openTableInEditor} | ||||
|                                         > | ||||
|                                             <CircleDotDashed className="size-4" /> | ||||
|                                         </Button> | ||||
|                                     )} | ||||
|                                     {editMode || !focused ? null : ( | ||||
|                                         <Button | ||||
|                                             variant="ghost" | ||||
|                                             className="size-6 p-0 text-slate-500 hover:bg-primary-foreground hover:text-slate-700 dark:text-slate-400 dark:hover:bg-slate-800 dark:hover:text-slate-200" | ||||
|                                             onClick={ | ||||
|                                                 table.width !== MAX_TABLE_SIZE | ||||
|                                                     ? expandTable | ||||
|                                                     : shrinkTable | ||||
|                                             } | ||||
|                                         > | ||||
|                                             {table.width !== MAX_TABLE_SIZE ? ( | ||||
|                                                 <ChevronsLeftRight className="size-4" /> | ||||
|                                             ) : ( | ||||
|                                                 <ChevronsRightLeft className="size-4" /> | ||||
|                                             )} | ||||
|                                         </Button> | ||||
|                                     )} | ||||
|                                 </div> | ||||
|                             </div> | ||||
|  | ||||
|                             <div className="relative"> | ||||
|                                 <div | ||||
|                                     className="transition-[max-height] duration-200 ease-in-out" | ||||
|                                     style={{ | ||||
|                                         maxHeight: expanded | ||||
|                                             ? `${fields.length * 2}rem` // h-8 per field | ||||
|                                             : `${TABLE_MINIMIZED_FIELDS * 2}rem`, // h-8 per field | ||||
|                                     }} | ||||
|                                 > | ||||
|                                     {visibleFields.map((field: DBField) => ( | ||||
|                                         <TableNodeField | ||||
|                                             key={field.id} | ||||
|                                             focused={focused} | ||||
|                                             tableNodeId={id} | ||||
|                                             field={field} | ||||
|                                             highlighted={highlightedFieldIds.has( | ||||
|                                                 field.id | ||||
|                                             )} | ||||
|                                             visible={true} | ||||
|                                             isConnectable={!table.isView} | ||||
|                                             onOpenEditMode={() => { | ||||
|                                                 if (!readonly) { | ||||
|                                                     setFocusFieldId(field.id); | ||||
|                                                     setIsTableEditMode(true); | ||||
|                                                     setTimeout(() => { | ||||
|                                                         closeAllTablesInSidebar(); | ||||
|                                                     }, 50); | ||||
|                                                 } | ||||
|                                             }} | ||||
|                                         /> | ||||
|                                     ))} | ||||
|                                 </div> | ||||
|                                 {fields.length > TABLE_MINIMIZED_FIELDS && ( | ||||
|                                     <div | ||||
|                                         className="z-10 flex h-8 cursor-pointer items-center justify-center rounded-b-md border-t text-xs text-muted-foreground transition-colors duration-200 hover:bg-slate-100 dark:hover:bg-slate-800" | ||||
|                                         onClick={toggleExpand} | ||||
|                                     > | ||||
|                                         {expanded ? ( | ||||
|                                             <> | ||||
|                                                 <ChevronUp className="mr-1 size-3.5" /> | ||||
|                                                 {t('show_less')} | ||||
|                                             </> | ||||
|                                         ) : ( | ||||
|                                             <> | ||||
|                                                 <ChevronDown className="mr-1 size-3.5" /> | ||||
|                                                 {t('show_more')} | ||||
|                                             </> | ||||
|                                         )} | ||||
|                                     </div> | ||||
|                                 )} | ||||
|                             </div> | ||||
|                         </> | ||||
|                     )} | ||||
|                 </div> | ||||
|             </TableNodeContextMenu> | ||||
|   | ||||
Some files were not shown because too many files have changed in this diff Show More
		Reference in New Issue
	
	Block a user