Compare commits

...

227 Commits

Author SHA1 Message Date
Stela Augustinova de76df88ab Refactor export parameters in ResultTabs and JslDataGridCore to conditionally set values based on result count 2026-04-13 14:50:01 +02:00
Stela Augustinova 13977756bc Add export parameters to AllResultsTab and ResultTabs components 2026-04-13 13:41:51 +02:00
Stela Augustinova 07fae27ad6 Add export parameters to ResultTabs and QueryTab components 2026-04-13 13:16:17 +02:00
Stela Augustinova 22967d123d Add 7.1.8 entry to CHANGELOG with fixed NPM packages build 2026-04-09 16:05:50 +02:00
Stela Augustinova 3fed650254 Add postgresql optimalization to 7.1.7 changelog 2026-04-09 16:03:24 +02:00
Stela Augustinova b57b2083d3 v7.1.8 2026-04-09 15:35:13 +02:00
Stela Augustinova 1f47e8c62e v7.1.8-alpha.7 2026-04-09 15:26:28 +02:00
CI workflows d7ce653d74 chore: auto-update github workflows 2026-04-09 13:25:14 +00:00
Stela Augustinova 07c803efee Update npm publishing steps and remove unnecessary access flag 2026-04-09 15:24:48 +02:00
Stela Augustinova 26b6d9133e v7.1.8-alpha.6 2026-04-09 15:15:37 +02:00
CI workflows 146084bdb3 chore: auto-update github workflows 2026-04-09 13:14:54 +00:00
Stela Augustinova fa82b4630b Specify npm version to 11.5.1 for consistency 2026-04-09 15:14:24 +02:00
Stela Augustinova d00841030f v7.1.8-alpha.5 2026-04-09 15:01:14 +02:00
CI workflows c517bb0be6 chore: auto-update github workflows 2026-04-09 13:00:00 +00:00
Stela Augustinova e585d8be8f Add public access to npm publish commands in build workflow 2026-04-09 14:59:25 +02:00
Stela Augustinova 8be76832a5 v7.1.8-alpha.4 2026-04-09 14:54:23 +02:00
Stela Augustinova 99df266a3e v7.1.8-aplha.4 2026-04-09 14:50:56 +02:00
CI workflows 5660874992 chore: auto-update github workflows 2026-04-09 12:50:31 +00:00
Stela Augustinova b0dade9da3 Configure NPM token in build workflow 2026-04-09 14:50:11 +02:00
Stela Augustinova a533858804 v7.1.8-alpha.3 2026-04-09 14:20:39 +02:00
CI workflows d3bcc984e7 chore: auto-update github workflows 2026-04-09 12:18:26 +00:00
Stela Augustinova 99e8307a80 Enable NPM token configuration in build workflow 2026-04-09 14:17:59 +02:00
Stela Augustinova 73926ea392 v7.1.8-alpha.2 2026-04-09 14:12:17 +02:00
CI workflows 5ff24526b7 chore: auto-update github workflows 2026-04-09 12:11:15 +00:00
Stela Augustinova 32ed1c57bd Update Node.js setup to use yarn caching and remove npm install step 2026-04-09 14:10:50 +02:00
Stela Augustinova f4c3a95348 v7.1.8-alpha.1 2026-04-09 14:02:38 +02:00
CI workflows b1a908343a chore: auto-update github workflows 2026-04-09 11:58:25 +00:00
Stela Augustinova 7f9d7eb36e Update Node.js setup action and enable npm caching 2026-04-09 13:57:51 +02:00
Stela Augustinova 30820e29fc Update CHANGELOG for version 7.1.7 2026-04-09 13:23:07 +02:00
Stela Augustinova a85ea2e0f7 v7.1.7 2026-04-09 12:56:57 +02:00
Stela Augustinova 993e713955 v7.1.7-premium-beta.5 2026-04-09 12:11:02 +02:00
Stela Augustinova 3151e30db1 SYNC: Update translations 2026-04-09 08:59:26 +00:00
Jan Prochazka eb5219dd68 Merge pull request #1422 from dbgate/feature/duplicate-translation-keys
Remove duplicate translation keys
2026-04-09 10:49:30 +02:00
Stela Augustinova bb44783369 Refactor translation keys to eliminate duplicates in QueryTab component 2026-04-09 10:33:33 +02:00
CI workflows 33b46c4db3 chore: auto-update github workflows 2026-04-09 08:24:34 +00:00
Jan Prochazka 3730aae62a Merge pull request #1419 from dbgate/feature/map-referer
Added referer
2026-04-09 10:24:25 +02:00
CI workflows 065062d58a Update pro ref 2026-04-09 08:24:16 +00:00
Jan Prochazka 7b2f58e68e SYNC: Merge pull request #92 from dbgate/feature/ai-toggle 2026-04-09 08:24:02 +00:00
Stela Augustinova e2fc23fcf8 Remove duplicate translation keys 2026-04-09 10:12:39 +02:00
SPRINX0\prochazka 6f56ef284d v7.1.7-premium-beta.4 2026-04-08 16:14:19 +02:00
SPRINX0\prochazka 08a644ba39 v7.1.7-premuim-beta.4 2026-04-08 16:07:40 +02:00
CI workflows 6ae19ac4a6 chore: auto-update github workflows 2026-04-08 14:06:22 +00:00
CI workflows 7761cbe81d Update pro ref 2026-04-08 14:05:57 +00:00
Jan Prochazka f981d88150 SYNC: Merge pull request #91 from dbgate/feature/query-history-per-user 2026-04-08 14:05:40 +00:00
CI workflows e2a23eaa0d chore: auto-update github workflows 2026-04-08 12:57:03 +00:00
CI workflows 9d510b3c08 Update pro ref 2026-04-08 12:56:40 +00:00
SPRINX0\prochazka a98f5ac45e reverted yarn.lock 2026-04-08 14:03:13 +02:00
SPRINX0\prochazka b989e964c0 v7.1.7-premium-beta.3 2026-04-08 13:34:11 +02:00
CI workflows 3ff6eefa06 chore: auto-update github workflows 2026-04-08 11:29:47 +00:00
CI workflows 67fde9be3c Update pro ref 2026-04-08 11:29:28 +00:00
SPRINX0\prochazka df7ac89723 SYNC: v7.1.7-premium-beta.2 2026-04-08 11:29:18 +00:00
SPRINX0\prochazka 358df9f53b SYNC: try to fix ms entra login 2026-04-08 11:29:15 +00:00
Stela Augustinova 02e3bfaa8a Added referer 2026-04-08 12:05:42 +02:00
Jan Prochazka dde74fa73b Merge pull request #1407 from dbgate/feature/postgres-optimalization
Feature/postgres optimalization
2026-04-08 11:46:42 +02:00
SPRINX0\prochazka 100e3fe75f deleted sast workflows 2026-04-08 10:59:29 +02:00
SPRINX0\prochazka af7930cea2 Enhance aggregation functions in SQL queries for improved PostgreSQL compatibility 2026-04-08 10:55:24 +02:00
SPRINX0\prochazka 6b4f6b909c Merge branch 'feature/postgres-optimalization' of https://github.com/dbgate/dbgate into feature/postgres-optimalization 2026-04-08 10:26:35 +02:00
Jan Prochazka 9a6e5cd7cc Update plugins/dbgate-plugin-postgres/src/backend/sql/views.js
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-04-08 10:21:22 +02:00
SPRINX0\prochazka 9f64b6ec7a Merge branch 'master' into feature/postgres-optimalization 2026-04-08 10:20:28 +02:00
Stela Augustinova 77f720e34c Refactor connection handling in extractShellConnection to improve volatile ID management and ensure secure credential handling 2026-04-08 10:20:09 +02:00
Stela Augustinova 168dcb7824 Enhance error handling for connection requests in subprocesses and validate connection ID format 2026-04-08 10:20:09 +02:00
Stela Augustinova 759186a212 Improve error handling for volatile connection responses in subprocess communication 2026-04-08 10:20:09 +02:00
Stela Augustinova 71ed7a76ea Handle errors in volatile connection resolution and remove unused registration function 2026-04-08 10:20:09 +02:00
Stela Augustinova bd939b22c7 Fix volatile connection resolution to prevent multiple resolves 2026-04-08 10:20:09 +02:00
Stela Augustinova c327f77294 Refactor volatile connections handling in connections and runners modules 2026-04-08 10:20:09 +02:00
Stela Augustinova d907d79beb Streamline volatile connections handling and remove unused registration module 2026-04-08 10:20:09 +02:00
Stela Augustinova 93b879927c Implement volatile connections handling in runners and shell modules 2026-04-08 10:20:09 +02:00
Stela Augustinova 0c545d4cf9 Enhance clipboard formatters to skip empty rows, improving data handling in clipboard operations 2026-04-08 10:20:09 +02:00
Stela Augustinova 95c90c1517 Improve clipboard formatters to omit undefined values, enhancing data integrity in exports 2026-04-08 10:20:09 +02:00
CI workflows cb731fa858 chore: auto-update github workflows 2026-04-08 10:20:09 +02:00
Stela Augustinova 9bb3b09ecf SYNC: Add SAST workflow for security scanning using Semgrep 2026-04-08 10:20:09 +02:00
SPRINX0\prochazka 7c8f541d3e deleted sast workflow 2026-04-08 10:18:37 +02:00
Jan Prochazka ce41687382 Merge pull request #1417 from dbgate/feature/auth-error
Implement volatile connections handling in runners and shell modules
2026-04-08 10:14:02 +02:00
Stela Augustinova 4b083dea5c Refactor connection handling in extractShellConnection to improve volatile ID management and ensure secure credential handling 2026-04-07 14:56:29 +02:00
Stela Augustinova c84473c1eb Enhance error handling for connection requests in subprocesses and validate connection ID format 2026-04-07 14:26:58 +02:00
Stela Augustinova 7fc078f3e6 Improve error handling for volatile connection responses in subprocess communication 2026-04-07 14:15:18 +02:00
Stela Augustinova cbbd538248 Handle errors in volatile connection resolution and remove unused registration function 2026-04-07 14:01:13 +02:00
Stela Augustinova 825f6e562b Fix volatile connection resolution to prevent multiple resolves 2026-04-07 13:46:34 +02:00
Stela Augustinova a278afb260 Refactor volatile connections handling in connections and runners modules 2026-04-07 13:42:11 +02:00
Stela Augustinova 2fbeea717c Streamline volatile connections handling and remove unused registration module 2026-04-07 13:26:16 +02:00
Jan Prochazka c7259e4663 Merge pull request #1412 from dbgate/feature/copy-sql
Improve clipboard formatters to omit undefined values, enhancing data…
2026-04-07 13:11:49 +02:00
Stela Augustinova 69a2669342 Implement volatile connections handling in runners and shell modules 2026-04-07 13:06:04 +02:00
CI workflows 42d1ca8fd4 chore: auto-update github workflows 2026-04-07 10:27:40 +00:00
Stela Augustinova 1cf52d8b39 SYNC: Add SAST workflow for security scanning using Semgrep 2026-04-07 10:27:24 +00:00
Jan Prochazka 6e482afab2 v7.1.7-premium-beta.1 2026-04-02 16:39:06 +02:00
SPRINX0\prochazka ddf3295e6d Merge branch 'master' into feature/postgres-optimalization 2026-04-02 16:33:25 +02:00
SPRINX0\prochazka 79e087abd3 Optimize PostgreSQL analysis queries and add support for Info Schema routines 2026-04-02 16:32:36 +02:00
CI workflows a7cf51bdf7 chore: auto-update github workflows 2026-04-02 13:55:33 +00:00
Jan Prochazka dfdb31e2f8 Merge pull request #1413 from dbgate/feature/integration-test-pro
Update test workflow to include directory changes for integration tests
2026-04-02 15:55:14 +02:00
Stela Augustinova 3508ddc3ca Update test workflow to include directory changes for integration tests 2026-04-02 11:02:36 +02:00
Stela Augustinova 137fc6b928 Enhance clipboard formatters to skip empty rows, improving data handling in clipboard operations 2026-04-02 10:29:02 +02:00
Jan Prochazka e6f5295420 Merge pull request #1410 from dbgate/feature/large-fields
Enhance binary size handling in grid cell display
2026-04-01 16:01:23 +02:00
CI workflows 2bb08921c3 chore: auto-update github workflows 2026-04-01 13:55:00 +00:00
Stela Augustinova ee2d0e4c30 Remove unnecessary restart policy for DynamoDB service 2026-04-01 15:54:35 +02:00
Jan Prochazka c43a838572 Merge pull request #1411 from dbgate/feature/unreadable-dropdown
Correct class binding and update style variables in SelectField compo…
2026-04-01 15:53:23 +02:00
CI workflows 17ff6a8013 chore: auto-update github workflows 2026-04-01 13:53:13 +00:00
Stela Augustinova 62ad6a0d08 Remove unnecessary restart policy for MongoDB service 2026-04-01 15:52:48 +02:00
CI workflows 5c049fa867 chore: auto-update github workflows 2026-04-01 13:51:09 +00:00
CI workflows 619f17114a Update pro ref 2026-04-01 13:50:58 +00:00
Stela Augustinova 1c1431014c SYNC: Merge pull request #87 from dbgate/feature/collection-test 2026-04-01 13:50:46 +00:00
Stela Augustinova 9d1d7b7e34 Improve clipboard formatters to omit undefined values, enhancing data integrity in exports 2026-04-01 15:49:35 +02:00
Stela Augustinova f68ca1e786 Correct class binding and update style variables in SelectField component 2026-04-01 13:24:34 +02:00
Stela Augustinova 8d16a30064 Fix message formatting for large binary fields in stringifyCellValue function 2026-04-01 10:55:47 +02:00
Stela Augustinova cf601c33c0 Enhance binary size handling in grid cell display 2026-04-01 10:25:40 +02:00
Jan Prochazka 588cd39d7c Merge pull request #1404 from dbgate/feature/fetch-all-button
Add fetch all button
2026-04-01 09:44:04 +02:00
Stela Augustinova 79ebfa9b7a Add fetchAll command to dataGrid menu 2026-03-31 13:37:06 +02:00
Stela Augustinova 0c6b2746d1 Fix file stream reference in jsldata and remove redundant buffer assignment in LoadingDataGridCore 2026-03-31 08:59:33 +02:00
Stela Augustinova 978972c55c Enhance file path validation in streamRows to include symlink resolution and case normalization, improving security and error handling 2026-03-31 08:31:43 +02:00
Stela Augustinova 37854fc577 Refactor fetchAll to trim lines before parsing, improving error handling for malformed data 2026-03-31 06:54:37 +02:00
Stela Augustinova 5537e193a6 Improve fetchAll error handling and cleanup process during streaming and paginated reads 2026-03-31 06:21:06 +02:00
Stela Augustinova 0d42b2b133 Refactor fetchAll cancel function to improve cleanup process and prevent errors 2026-03-30 15:48:35 +02:00
Stela Augustinova 44bd7972d4 Enhance fetchAll functionality with improved error handling and state management 2026-03-30 14:34:57 +02:00
Stela Augustinova 5143eb39f7 Implement fetchAll functionality with streaming support and error handling 2026-03-30 13:30:12 +02:00
Stela Augustinova cf51883b3e Add checkbox to skip confirmation when fetching all rows 2026-03-26 15:24:25 +01:00
Stela Augustinova 484ca0c78a Reset loaded time reference in reload function 2026-03-26 15:11:11 +01:00
Stela Augustinova 8f5cad0e2c Prevent loading next data when fetching all rows is in progress 2026-03-26 15:03:54 +01:00
Stela Augustinova 988512a571 Update warning message in FetchAllConfirmModal to simplify language 2026-03-26 14:50:09 +01:00
Stela Augustinova f8bd380051 Optimize fetchAllRows by using a local buffer to reduce array copies and improve performance 2026-03-26 14:19:11 +01:00
Stela Augustinova 281131dbba Enhance fetchAll functionality by adding loading state check 2026-03-26 14:07:12 +01:00
Stela Augustinova ea3a61077a v7.1.6 2026-03-26 12:47:09 +01:00
Stela Augustinova d1a898b40d SYNC: Add translations for cloudUnavailable message in multiple languages 2026-03-26 11:11:07 +00:00
Stela Augustinova a521a81ef0 v7.1.6-premium-beta.1 2026-03-26 11:25:13 +01:00
Stela Augustinova 2505c61975 Add fetch all button 2026-03-26 11:24:05 +01:00
Stela Augustinova ab5a54dbb6 SYNC: Merge pull request #89 from dbgate/feature/cloud-error 2026-03-26 10:12:05 +00:00
Stela Augustinova 44ad8fa60a Update CHANGELOG for version 7.1.5 2026-03-25 16:59:13 +01:00
Stela Augustinova 5b27a241d7 v7.1.5 2026-03-25 16:21:59 +01:00
Stela Augustinova 084019ca65 v7.1.5-premium-beta.3 2026-03-25 15:21:43 +01:00
Stela Augustinova ba147af8fe SYNC: v7.1.5-premium-beta.2 2026-03-25 14:08:24 +00:00
Stela Augustinova 1b3f4db07d SYNC: Merge pull request #88 from dbgate/feature/cloud-error 2026-03-25 13:39:00 +00:00
Jan Prochazka c36705d458 Merge pull request #1395 from dbgate/feature/display-uuid
Feature/display UUID
2026-03-25 10:04:58 +01:00
Stela Augustinova 0e126cb8cf Enhance BinData subType handling to support hexadecimal strings and improve validation 2026-03-25 08:32:03 +01:00
Stela Augustinova c48183a539 Enhance base64 to UUID conversion with error handling and regex improvements 2026-03-25 08:23:15 +01:00
Stela Augustinova 50f380dbbe Enhance uuidToBase64 function with validation and improve UUID parsing in parseCellValue 2026-03-24 17:15:32 +01:00
Stela Augustinova 66023a9a68 Validate base64 UUID conversion and enhance handling in stringifyCellValue 2026-03-24 17:06:52 +01:00
Stela Augustinova c3fbc3354c Validate BinData subType to ensure it is an integer between 0 and 255 2026-03-24 16:32:16 +01:00
Jan Prochazka a7d2ed11f3 SYNC: Merge pull request #86 from dbgate/feature/icon-vulnerability 2026-03-23 12:50:27 +00:00
SPRINX0\prochazka 899aec2658 v7.1.5-premium-beta.1 2026-03-20 14:24:11 +01:00
SPRINX0\prochazka 74e47587e2 Merge branch 'master' into feature/postgres-optimalization 2026-03-20 14:23:40 +01:00
Stela Augustinova 6a3dc92572 Add uuid to base64 conversion and enhance cell value parsing for UUIDs 2026-03-20 12:46:50 +01:00
Stela Augustinova e3a4667422 feat: add base64 to UUID conversion and integrate into cell value parsing 2026-03-19 14:50:08 +01:00
Stela Augustinova c4dd99bba9 Changelog 7.1.4 2026-03-19 13:07:44 +01:00
SPRINX0\prochazka cb70f3c318 postgres loading optimalization 2026-03-19 12:17:29 +01:00
Stela Augustinova 588b6f9882 v7.1.4 2026-03-19 12:13:37 +01:00
Stela Augustinova 375f69ca1e v7.1.4-alpha.2 2026-03-19 11:13:29 +01:00
Stela Augustinova a32e5cc139 v7.1.4-alpha.1 2026-03-19 10:56:16 +01:00
CI workflows 8e00137751 chore: auto-update github workflows 2026-03-19 09:33:56 +00:00
Stela Augustinova 003db50833 SYNC: Add missing publish step for rest 2026-03-19 09:33:36 +00:00
Stela Augustinova bc519c2c20 Changelog 7.1.3 2026-03-18 16:06:01 +01:00
Stela Augustinova 3b41fa8cfa v7.1.3 2026-03-18 15:31:26 +01:00
Stela Augustinova 39ed0f6d2d v7.1.3-premium-beta.7 2026-03-18 14:27:27 +01:00
CI workflows 710f796832 chore: auto-update github workflows 2026-03-18 13:15:43 +00:00
CI workflows 9ec5fb7263 Update pro ref 2026-03-18 13:15:24 +00:00
Stela Augustinova 407db457d5 SYNC: Added new translations and error codes 2026-03-18 13:15:12 +00:00
Jan Prochazka 0c5d2cfcd1 Merge pull request #1393 from dbgate/feature/script-filter
Add cloud content list integration for connection label resolution
2026-03-18 13:55:40 +01:00
CI workflows 87ace375bb chore: auto-update github workflows 2026-03-18 12:54:58 +00:00
CI workflows d010020f3b Update pro ref 2026-03-18 12:54:34 +00:00
Jan Prochazka c60227a98f SYNC: Merge pull request #85 from dbgate/feature/proxy-configuration 2026-03-18 12:54:21 +00:00
Stela Augustinova 2824681bff Refactor cloudIdToLabel assignment to use lodash's fromPairs for improved readability 2026-03-18 13:47:45 +01:00
Stela Augustinova 073a3e3946 Add cloud content list integration for connection label resolution 2026-03-18 11:23:31 +01:00
CI workflows 93e91127a0 chore: auto-update github workflows 2026-03-18 08:03:38 +00:00
CI workflows b60a6cff56 Update pro ref 2026-03-18 08:03:23 +00:00
Jan Prochazka 1f3b1963d9 SYNC: errors assign 2026-03-18 08:03:13 +00:00
SPRINX0\prochazka 4915f57abb v7.1.3-premium-beta.6 2026-03-17 15:35:35 +01:00
Jan Prochazka 97c6fc97d5 Merge pull request #1392 from dbgate/feature/duckdb-integration-test
Synchronize client and instance disconnection methods
2026-03-17 15:34:51 +01:00
Stela Augustinova b68421bbc3 Synchronize client and instance disconnection methods 2026-03-17 14:45:57 +01:00
SPRINX0\prochazka 2d10559754 v7.1.3-premium-beta.5 2026-03-17 13:38:35 +01:00
CI workflows b398a7b546 chore: auto-update github workflows 2026-03-17 11:58:40 +00:00
CI workflows 1711d2102d Update pro ref 2026-03-17 11:58:24 +00:00
Jan Prochazka 97cea230f3 SYNC: Merge pull request #83 from dbgate/feature/transaction-isolation 2026-03-17 11:58:10 +00:00
CI workflows b6a0fe9465 chore: auto-update github workflows 2026-03-17 11:46:56 +00:00
CI workflows 06c50659bb Update pro ref 2026-03-17 11:46:39 +00:00
Jan Prochazka 244b47f548 SYNC: Merge pull request #84 from dbgate/feature/proxy-configuration 2026-03-17 11:46:28 +00:00
Jan Prochazka b72a244d93 Merge pull request #1389 from dbgate/feature/duckdb-query-result
Fix getColumnsInfo loop to iterate from start to end
2026-03-17 09:55:59 +01:00
Jan Prochazka c1e069d4dc Merge pull request #1391 from dbgate/feature/script-filter
Refactor connection selection to use a dropdown instead of a button f…
2026-03-17 09:50:01 +01:00
Stela Augustinova f99994085a Refactor connection selection to use a dropdown instead of a button for improved usability 2026-03-17 09:22:18 +01:00
Stela Augustinova 32fd0dd78c Update @duckdb/node-api dependency to version 1.5.0-r.1 2026-03-16 15:52:01 +01:00
Jan Prochazka a557b6b2b4 Merge pull request #1388 from dbgate/feature/script-filter
Feature/script filter
2026-03-16 15:27:49 +01:00
Stela Augustinova e84583c776 Fix getColumnsInfo loop to iterate from start to end 2026-03-16 15:09:31 +01:00
Stela Augustinova a548b0d543 Refactor connection label assignment to use logical OR for fallback 2026-03-16 15:05:45 +01:00
Stela Augustinova de94f15383 Fix file reading to correctly handle bytes read from file 2026-03-16 14:41:38 +01:00
Stela Augustinova 7045d986ef Fix file handle management to ensure proper closure in file reading process 2026-03-16 14:31:43 +01:00
Stela Augustinova de7ae9cf09 Refactor connection filter options 2026-03-16 14:17:06 +01:00
Stela Augustinova ab3d6888dc Enhance file reading and connection filtering in SavedFilesList component 2026-03-16 14:08:19 +01:00
Stela Augustinova 98a70891f3 Refactor file reading 2026-03-16 08:12:35 +01:00
Stela Augustinova 52e7326a2c Enhance file listing to support front matter parsing and connection filtering 2026-03-16 08:02:03 +01:00
Jan Prochazka bfd2e3b07a Merge pull request #1382 from dbgate/feature/add-files-button
Enhance drag-and-drop functionality to support Electron file paths
2026-03-12 12:41:31 +01:00
Stela Augustinova 799f5e30d3 Enhance drag-and-drop functionality to support Electron file paths 2026-03-12 10:14:47 +01:00
SPRINX0\prochazka d3e544c3c0 v7.1.3-premium-beta.4 2026-03-11 08:55:53 +01:00
CI workflows 866fd55834 chore: auto-update github workflows 2026-03-10 10:17:13 +00:00
CI workflows 74ce1fba32 Update pro ref 2026-03-10 10:16:57 +00:00
Jan Prochazka a11b93b4cc SYNC: Merge pull request #80 from dbgate/feature/loading-fix 2026-03-10 10:16:46 +00:00
CI workflows 066f2baa03 chore: auto-update github workflows 2026-03-10 09:50:36 +00:00
Stela Augustinova e02396280f SYNC: Add port mappings for DynamoDB and fix formatting in e2e-pro.yaml 2026-03-10 09:50:18 +00:00
CI workflows a654c80746 chore: auto-update github workflows 2026-03-10 09:32:53 +00:00
CI workflows 3b50f4bd7c Update pro ref 2026-03-10 09:32:34 +00:00
CI workflows cc1f77f5bc chore: auto-update github workflows 2026-03-10 08:23:51 +00:00
CI workflows 381fce4a82 Update pro ref 2026-03-10 08:23:35 +00:00
Jan Prochazka bc3be97cee SYNC: Merge pull request #81 from dbgate/feature/dynamo-e2e 2026-03-10 08:22:32 +00:00
Jan Prochazka 1c389208a7 Merge pull request #1378 from dbgate/feature/add-files-button
Import getElectron in ElectronFilesInput component
2026-03-10 09:19:34 +01:00
SPRINX0\prochazka cbeed2d3d0 v7.1.3-alpha.3 2026-03-09 10:20:49 +01:00
SPRINX0\prochazka 3d974ad144 v7.1.3-alpha.2 2026-03-09 10:01:50 +01:00
SPRINX0\prochazka 749042a05d set version 2026-03-09 09:59:53 +01:00
SPRINX0\prochazka 52413b82ee v7.1.3-alpha.1 2026-03-09 09:22:26 +01:00
SPRINX0\prochazka 212a7ec083 used exact version 2026-03-09 09:21:57 +01:00
SPRINX0\prochazka cee94fe113 added missing package 2026-03-09 09:20:48 +01:00
Stela Augustinova e1ead2519a Import getElectron in ElectronFilesInput component 2026-03-09 07:35:34 +01:00
Jan Prochazka 80330a25ac Merge pull request #1372 from dbgate/feature/export-diagram
Add diagram export to png
2026-03-05 10:32:35 +01:00
Stela Augustinova 508470e970 Added import 2026-03-05 10:02:57 +01:00
Stela Augustinova bc64b4b5c7 Update ToolStripDropDownButton label to use translation for export 2026-03-04 15:36:40 +01:00
Jan Prochazka 48d8494ead SYNC: added CLAUDE.md 2026-03-04 07:42:30 +00:00
SPRINX0\prochazka 2a51d2ed96 SYNC: fix: enhance date handling in zipDataRow function 2026-03-03 16:13:49 +00:00
Stela Augustinova cfabcc7bf6 Fix import name for ToolStripDropDownButton in DiagramTab.svelte 2026-03-03 17:08:13 +01:00
Stela Augustinova 90fc8fd0fc Add diagram export to png 2026-03-03 16:54:46 +01:00
SPRINX0\prochazka ff54533e33 v7.1.2 2026-03-02 15:53:28 +01:00
SPRINX0\prochazka 2072f0b5ba SYNC: don't use random data in testing REST service 2026-03-02 14:10:12 +00:00
Jan Prochazka 6efc720a45 SYNC: Merge pull request #78 from dbgate/feature/aitest 2026-03-02 13:28:57 +00:00
SPRINX0\prochazka c7cb1efe9c v7.1.2-premium-beta.2 2026-03-02 12:59:39 +01:00
SPRINX0\prochazka e193531246 changelog 2026-03-02 12:58:03 +01:00
CI workflows 2aa53f414e chore: auto-update github workflows 2026-03-02 11:57:43 +00:00
CI workflows 843c15d754 Update pro ref 2026-03-02 11:57:27 +00:00
SPRINX0\prochazka fb19582088 v7.1.2-premium-beta.1 2026-03-02 10:34:53 +01:00
SPRINX0\prochazka 8040466cbe text 2026-03-02 10:34:16 +01:00
CI workflows 302b4d7acd chore: auto-update github workflows 2026-03-02 09:33:33 +00:00
CI workflows a8ccc24d46 Update pro ref 2026-03-02 09:33:16 +00:00
Jan Prochazka b2fb071a7b SYNC: Merge pull request #73 from dbgate/feature/openai-upgrade 2026-03-02 09:33:04 +00:00
SPRINX0\prochazka 204d7b97d5 chore: update CHANGELOG for version 7.1.1 enhancements and fixes 2026-02-27 16:08:33 +01:00
SPRINX0\prochazka f3da709aac v7.1.1 2026-02-27 15:34:12 +01:00
201 changed files with 10788 additions and 5976 deletions
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -1
View File
@@ -39,7 +39,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+2 -2
View File
@@ -35,7 +35,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
@@ -53,7 +53,7 @@ jobs:
cd dbgate-merged
node adjustNpmPackageJsonPremium
- name: Update npm
run: npm install -g npm@latest
run: npm install -g npm@11.5.1
- name: Remove dbmodel - should be not published
run: |
cd ..
+5 -2
View File
@@ -30,7 +30,7 @@ jobs:
with:
node-version: 22.x
- name: Update npm
run: npm install -g npm@latest
run: npm install -g npm@11.5.1
- name: yarn install
run: |
yarn install
@@ -56,7 +56,10 @@ jobs:
working-directory: packages/sqltree
run: |
npm publish --tag "$NPM_TAG"
- name: Publish rest
working-directory: packages/rest
run: |
npm publish --tag "$NPM_TAG"
- name: Publish api
working-directory: packages/api
run: |
+5 -1
View File
@@ -30,7 +30,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: f1708c2410d8e4a6df5532b08af1b746dc5ee1da
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
@@ -132,6 +132,10 @@ jobs:
image: redis
ports:
- '16011:6379'
dynamodb:
image: amazon/dynamodb-local
ports:
- '16015:8000'
mssql:
image: mcr.microsoft.com/mssql/server
ports:
+34
View File
@@ -23,26 +23,49 @@ jobs:
- uses: actions/checkout@v3
with:
fetch-depth: 1
- name: Checkout dbgate/dbgate-pro
uses: actions/checkout@v2
with:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: 87c3efdaf83786abee4366dee2c58fea355edc4c
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
mv dbgate-pro/* ../dbgate-pro/
cd ..
mkdir dbgate-merged
cd dbgate-pro
cd sync
yarn
node sync.js --nowatch
cd ..
- name: yarn install
run: |
cd ../dbgate-merged
yarn install
- name: Integration tests
run: |
cd ../dbgate-merged
cd integration-tests
yarn test:ci
- name: Filter parser tests
if: always()
run: |
cd ../dbgate-merged
cd packages/filterparser
yarn test:ci
- name: Datalib (perspective) tests
if: always()
run: |
cd ../dbgate-merged
cd packages/datalib
yarn test:ci
- name: Tools tests
if: always()
run: |
cd ../dbgate-merged
cd packages/tools
yarn test:ci
services:
@@ -98,3 +121,14 @@ jobs:
FIREBIRD_USE_LEGACY_AUTH: true
ports:
- '3050:3050'
mongodb:
image: mongo:4.0.12
ports:
- '27017:27017'
volumes:
- mongo-data:/data/db
- mongo-config:/data/configdb
dynamodb:
image: amazon/dynamodb-local
ports:
- '8000:8000'
+1
View File
@@ -6,3 +6,4 @@
- GUI uses Svelte4 (packages/web)
- GUI is tested with E2E tests in `e2e-tests` folder, using Cypress. Use data-testid attribute in components to make them easier to test.
- data-testid format: ComponentName_identifier. Use reasonable identifiers
- don't change content of storageModel.js - this is generated from table YAMLs with "yarn storage-json" command
+232 -40
View File
File diff suppressed because it is too large Load Diff
+119
View File
@@ -0,0 +1,119 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project Overview
DbGate is a cross-platform (no)SQL database manager supporting MySQL, PostgreSQL, SQL Server, Oracle, MongoDB, Redis, SQLite, and more. It runs as a web app (Docker/NPM), an Electron desktop app, or in a browser. The monorepo uses Yarn workspaces.
## Development Commands
```sh
yarn # install all packages (also builds TS libraries and plugins)
yarn start # run API (port 3000) + web (port 5001) concurrently
```
For more control, run these 3 commands in separate terminals:
```sh
yarn start:api # Express API on port 3000
yarn start:web # Svelte frontend on port 5001
yarn lib # watch-compile TS libraries and plugins
```
For Electron development:
```sh
yarn start:web # web on port 5001
yarn lib # watch TS libs/plugins
yarn start:app # Electron app
```
### Building
```sh
yarn build:lib # build all TS libraries (sqltree, tools, filterparser, datalib, rest)
yarn build:api # build API
yarn build:web # build web frontend
yarn ts # TypeScript type-check API and web
yarn prettier # format all source files
```
### Testing
Unit tests (in packages like `dbgate-tools`):
```sh
yarn workspace dbgate-tools test
```
Integration tests (requires Docker for database containers):
```sh
cd integration-tests
yarn test:local # run all tests
yarn test:local:path __tests__/alter-database.spec.js # run a single test file
```
E2E tests (Cypress):
```sh
yarn cy:open # open Cypress UI
cd e2e-tests && yarn cy:run:browse-data # run a specific spec headlessly
```
## Architecture
### Monorepo Structure
| Path | Package | Purpose |
|---|---|---|
| `packages/api` | `dbgate-api` | Express.js backend server |
| `packages/web` | `dbgate-web` | Svelte 4 frontend (built with Rolldown) |
| `packages/tools` | `dbgate-tools` | Shared TS utilities: SQL dumping, schema analysis, diffing, driver base classes |
| `packages/datalib` | `dbgate-datalib` | Grid display logic, changeset management, perspectives, chart definitions |
| `packages/sqltree` | `dbgate-sqltree` | SQL AST representation and dumping |
| `packages/filterparser` | `dbgate-filterparser` | Parses filter strings into SQL/Mongo conditions |
| `packages/rest` | `dbgate-rest` | REST connection support |
| `packages/types` | `dbgate-types` | TypeScript type definitions (`.d.ts` only) |
| `packages/aigwmock` | `dbgate-aigwmock` | Mock AI gateway server for E2E testing |
| `plugins/dbgate-plugin-*` | — | Database drivers and file format handlers |
| `app/` | — | Electron shell |
| `integration-tests/` | — | Jest-based DB integration tests (Docker) |
| `e2e-tests/` | — | Cypress E2E tests |
### API Backend (`packages/api`)
- Express.js server with controllers in `src/controllers/` — each file exposes REST endpoints via the `useController` utility
- Database connections run in child processes (`src/proc/`) to isolate crashes and long-running operations
- `src/shell/` contains stream-based data pipeline primitives (readers, writers, transforms) used for import/export and replication
- Plugin drivers are loaded dynamically via `requireEngineDriver`; each plugin in `plugins/` exports a driver conforming to `DriverBase` from `dbgate-tools`
### Frontend (`packages/web`)
- Svelte 4 components; builds with Rolldown (not Vite/Webpack)
- Global state in `src/stores.ts` using Svelte writable stores, with `writableWithStorage` / `writableWithForage` helpers for persistence
- API calls go through `src/utility/api.ts` (`apiCall`, `apiOff`, etc.) which handles auth, error display, and cache invalidation
- Tab system: each open editor/viewer is a "tab" tracked in `openedTabs` store; tab components live in `src/tabs/`
- Left-panel tree items are "AppObjects" in `src/appobj/`
- Metadata (table lists, column info) is loaded reactively via hooks in `src/utility/metadataLoaders.ts`
- Commands/keybindings are registered in `src/commands/`
### Plugin Architecture
Each `plugins/dbgate-plugin-*` package provides:
- **Frontend build** (`build:frontend`): bundled JS loaded by the web UI for query formatting, data rendering
- **Backend build** (`build:backend`): Node.js driver code loaded by the API for actual DB connections
Plugins are copied to `plugins/dist/` via `plugins:copydist` before building the app or Docker image.
### Key Conventions
- Error/message codes use `DBGM-00000` as placeholder — do not introduce new numbered `DBGM-NNNNN` codes
- Frontend uses **Svelte 4** (not Svelte 5)
- E2E test selectors use `data-testid` attribute with format `ComponentName_identifier`
- Prettier config: single quotes, 2-space indent, 120-char line width, trailing commas ES5
- Logging via `pinomin`; pipe through `pino-pretty` for human-readable output
### Translation System
```sh
yarn translations:extract # extract new strings
yarn translations:add-missing # add missing translations
yarn translations:check # check for issues
```
+8
View File
@@ -400,6 +400,14 @@ function createWindow() {
},
});
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
{ urls: ['https://*.tile.openstreetmap.org/*'] },
(details, callback) => {
details.requestHeaders['Referer'] = 'https://www.dbgate.io';
callback({ requestHeaders: details.requestHeaders });
}
);
if (initialConfig['winIsMaximized']) {
mainWindow.maximize();
}
+2 -1
View File
@@ -4,5 +4,6 @@ module.exports = {
mssql: true,
oracle: true,
sqlite: true,
mongo: true
mongo: true,
dynamo: true,
};
+45 -13
View File
@@ -4,24 +4,56 @@ const fs = require('fs');
const baseDir = path.join(os.homedir(), '.dbgate');
const testApiPidFile = path.join(__dirname, 'tmpdata', 'test-api.pid');
const aigwmockPidFile = path.join(__dirname, 'tmpdata', 'aigwmock.pid');
function clearTestingData() {
if (fs.existsSync(testApiPidFile)) {
function readProcessStartTime(pid) {
if (process.platform === 'linux') {
try {
const pid = Number(fs.readFileSync(testApiPidFile, 'utf-8'));
if (Number.isInteger(pid) && pid > 0) {
process.kill(pid);
}
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
return stat.split(' ')[21] || null;
} catch (err) {
// ignore stale PID files and dead processes
}
try {
fs.unlinkSync(testApiPidFile);
} catch (err) {
// ignore cleanup errors
return null;
}
}
return null;
}
function isPidStillOurs(meta) {
if (!meta || !(meta.pid > 0)) return false;
if (process.platform === 'linux' && meta.startTime) {
const current = readProcessStartTime(meta.pid);
return current === meta.startTime;
}
return true;
}
function stopProcessByPidFile(pidFile) {
if (!fs.existsSync(pidFile)) return;
try {
const content = fs.readFileSync(pidFile, 'utf-8').trim();
let meta;
try {
meta = JSON.parse(content);
} catch (_) {
const pid = Number(content);
meta = Number.isInteger(pid) && pid > 0 ? { pid } : null;
}
if (isPidStillOurs(meta)) {
process.kill(meta.pid);
}
} catch (err) {
// ignore stale PID files and dead processes
}
try {
fs.unlinkSync(pidFile);
} catch (err) {
// ignore cleanup errors
}
}
function clearTestingData() {
stopProcessByPidFile(testApiPidFile);
stopProcessByPidFile(aigwmockPidFile);
if (fs.existsSync(path.join(baseDir, 'connections-e2etests.jsonl'))) {
fs.unlinkSync(path.join(baseDir, 'connections-e2etests.jsonl'));
+3
View File
@@ -55,6 +55,9 @@ module.exports = defineConfig({
case 'redis':
serverProcess = exec('yarn start:redis');
break;
case 'ai-chat':
serverProcess = exec('yarn start:ai-chat');
break;
}
await waitOn({ resources: ['http://localhost:3000'] });
+105
View File
@@ -0,0 +1,105 @@
Cypress.on('uncaught:exception', err => {
if (err.message.includes("Failed to execute 'importScripts' on 'WorkerGlobalScope'")) {
return false;
}
});
beforeEach(() => {
cy.visit('http://localhost:3000');
cy.viewport(1250, 900);
});
describe('Database Chat (MySQL)', () => {
it('Database chat - chart of popular genres', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_databaseChat').click();
cy.wait(1000);
cy.get('body').realType('show me chart of most popular genres');
cy.get('body').realPress('Enter');
cy.testid('DatabaseChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.testid('chart-canvas', { timeout: 30000 }).should($c =>
expect($c[0].toDataURL()).to.match(/^data:image\/png;base64/)
);
cy.themeshot('database-chat-chart');
});
it('Database chat - find most popular artist', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_databaseChat').click();
cy.wait(1000);
cy.get('body').realType('find most popular artist');
cy.get('body').realPress('Enter');
cy.testid('DatabaseChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.contains('Iron Maiden', { timeout: 30000 });
cy.themeshot('database-chat-popular-artist');
});
});
describe('GraphQL Chat', () => {
it('GraphQL chat - list users', () => {
cy.contains('REST GraphQL').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_graphqlChat').click();
cy.wait(1000);
cy.get('body').realType('list all users');
cy.get('body').realPress('Enter');
cy.testid('GraphQlChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.contains('users', { timeout: 30000 });
cy.themeshot('graphql-chat-list-users');
});
it('GraphQL chat - product categories chart', () => {
cy.contains('REST GraphQL').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_graphqlChat').click();
cy.wait(1000);
cy.get('body').realType('show me a chart of product categories');
cy.get('body').realPress('Enter');
cy.testid('GraphQlChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.testid('chart-canvas', { timeout: 30000 }).should($c =>
expect($c[0].toDataURL()).to.match(/^data:image\/png;base64/)
);
cy.themeshot('graphql-chat-categories-chart');
});
it('GraphQL chat - find most expensive product', () => {
cy.contains('REST GraphQL').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_graphqlChat').click();
cy.wait(1000);
cy.get('body').realType('find the most expensive product');
cy.get('body').realPress('Enter');
cy.testid('GraphQlChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.contains('products', { timeout: 30000 });
cy.themeshot('graphql-chat-expensive-product');
});
it('GraphQL chat - show all categories', () => {
cy.contains('REST GraphQL').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_graphqlChat').click();
cy.wait(1000);
cy.get('body').realType('show all categories');
cy.get('body').realPress('Enter');
cy.testid('GraphQlChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.contains('categories', { timeout: 30000 });
cy.themeshot('graphql-chat-all-categories');
});
it('Explain query error', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_query').click();
cy.wait(1000);
cy.get('body').realType('select * from Invoice2');
cy.contains('Execute').click();
cy.testid('MessageViewRow-explainErrorButton-1').click();
cy.testid('ChatCodeRenderer_useSqlButton', { timeout: 30000 });
cy.themeshot('explain-query-error');
});
});
+39
View File
@@ -512,4 +512,43 @@ describe('Data browser data', () => {
cy.testid('DataFilterControl_input_ArtistId.Name').type('mich{enter}');
cy.themeshot('data-browser-filter-by-expanded');
});
it('DynamoDB', () => {
cy.contains('Dynamo-connection').click();
cy.contains('us-east-1').click();
cy.contains('Album').click();
cy.contains('Pearl Jam').click();
cy.themeshot('dynamodb-table-data');
cy.contains('Switch to JSON').click();
cy.themeshot('dynamodb-json-view');
cy.contains('Customer').click();
cy.testid('DataFilterControl_input_CustomerId').type('<=10{enter}');
cy.contains('Rows: 10');
cy.wait(1000);
cy.contains('Helena').click().rightclick();
cy.contains('Show cell data').click();
cy.contains('City: "Prague"');
cy.themeshot('dynamodb-query-json-view');
cy.contains('Switch to JSON').click();
cy.contains('Leonie').rightclick();
cy.contains('Edit document').click();
Array.from({ length: 11 }).forEach(() => cy.realPress('ArrowDown'));
Array.from({ length: 14 }).forEach(() => cy.realPress('ArrowRight'));
Array.from({ length: 7 }).forEach(() => cy.realPress('Delete'));
cy.realType('Italy');
cy.testid('EditJsonModal_saveButton').click();
cy.contains('Helena').rightclick();
cy.contains('Delete document').click();
cy.contains('Save').click();
cy.themeshot('dynamodb-save-changes');
cy.testid('SqlObjectList_addButton').click();
cy.contains('New collection/container').click();
cy.themeshot('dynamodb-new-collection');
});
});
-49
View File
@@ -110,55 +110,6 @@ describe('Charts', () => {
cy.themeshot('new-object-window');
});
it.skip('Database chat - charts', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_databaseChat').click();
cy.wait(1000);
cy.get('body').realType('show me chart of most popular genres');
cy.get('body').realPress('{enter}');
cy.testid('DatabaseChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.testid('chart-canvas', { timeout: 30000 }).should($c =>
expect($c[0].toDataURL()).to.match(/^data:image\/png;base64/)
);
cy.themeshot('database-chat-chart');
});
it.skip('Database chat', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_databaseChat').click();
cy.wait(1000);
cy.get('body').realType('find most popular artist');
cy.get('body').realPress('{enter}');
cy.testid('DatabaseChatTab_executeAllQueries', { timeout: 30000 }).click();
cy.wait(30000);
// cy.contains('Iron Maiden');
cy.themeshot('database-chat');
// cy.testid('DatabaseChatTab_promptInput').click();
// cy.get('body').realType('I need top 10 songs with the biggest income');
// cy.get('body').realPress('{enter}');
// cy.contains('Hot Girl', { timeout: 20000 });
// cy.wait(1000);
// cy.themeshot('database-chat');
});
it.skip('Explain query error', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.testid('TabsPanel_buttonNewObject').click();
cy.testid('NewObjectModal_query').click();
cy.wait(1000);
cy.get('body').realType('select * from Invoice2');
cy.contains('Execute').click();
cy.testid('MessageViewRow-explainErrorButton-1').click();
cy.testid('ChatCodeRenderer_useSqlButton', { timeout: 30000 });
cy.themeshot('explain-query-error');
});
it('Switch language', () => {
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
+3
View File
@@ -52,6 +52,9 @@ function multiTest(testProps, testDefinition) {
if (localconfig.mongo && !testProps.skipMongo) {
it('MongoDB', () => testDefinition('Mongo-connection', 'my_guitar_shop', 'mongo@dbgate-plugin-mongo'));
}
if (localconfig.dynamo && !testProps.skipMongo) {
it('DynamoDB', () => testDefinition('Dynamo-connection', null, 'dynamodb@dbgate-plugin-dynamodb'));
}
}
describe('Transactions', () => {
+12 -7
View File
@@ -5,14 +5,14 @@ services:
restart: always
environment:
POSTGRES_PASSWORD: Pwd2020Db
ports:
ports:
- 16000:5432
mariadb:
image: mariadb
command: --default-authentication-plugin=mysql_native_password
restart: always
ports:
ports:
- 16004:3306
environment:
- MYSQL_ROOT_PASSWORD=Pwd2020Db
@@ -20,21 +20,21 @@ services:
mysql-ssh-login:
build: containers/mysql-ssh-login
restart: always
ports:
ports:
- 16017:3306
- "16012:22"
- '16012:22'
mysql-ssh-keyfile:
build: containers/mysql-ssh-keyfile
restart: always
ports:
ports:
- 16007:3306
- "16008:22"
- '16008:22'
dex:
build: containers/dex
ports:
- "16009:5556"
- '16009:5556'
mongo:
image: mongo:4.4.29
@@ -50,6 +50,11 @@ services:
ports:
- 16011:6379
dynamodb:
image: amazon/dynamodb-local
ports:
- 16015:8000
mssql:
image: mcr.microsoft.com/mssql/server
restart: always
+14
View File
@@ -0,0 +1,14 @@
CONNECTIONS=mysql,graphql
LOCAL_AI_GATEWAY=true
LABEL_mysql=MySql-connection
SERVER_mysql=localhost
USER_mysql=root
PASSWORD_mysql=Pwd2020Db
PORT_mysql=16004
ENGINE_mysql=mysql@dbgate-plugin-mysql
LABEL_graphql=REST GraphQL
ENGINE_graphql=graphql@rest
APISERVERURL1_graphql=http://localhost:4444/graphql/noauth
+7 -1
View File
@@ -1,4 +1,4 @@
CONNECTIONS=mysql,postgres,mongo
CONNECTIONS=mysql,postgres,mongo,dynamo
LABEL_mysql=MySql-connection
SERVER_mysql=localhost
@@ -22,3 +22,9 @@ USER_mongo=root
PASSWORD_mongo=Pwd2020Db
PORT_mongo=16010
ENGINE_mongo=mongo@dbgate-plugin-mongo
LABEL_dynamo=Dynamo-connection
SERVER_dynamo=localhost
PORT_dynamo=16015
AUTH_TYPE_dynamo=onpremise
ENGINE_dynamo=dynamodb@dbgate-plugin-dynamodb
+8 -1
View File
@@ -1,4 +1,4 @@
CONNECTIONS=mysql,postgres,mssql,oracle,sqlite,mongo
CONNECTIONS=mysql,postgres,mssql,oracle,sqlite,mongo,dynamo
LOG_CONNECTION_SENSITIVE_VALUES=true
LABEL_mysql=MySql-connection
@@ -43,3 +43,10 @@ PASSWORD_mongo=Pwd2020Db
PORT_mongo=16010
ENGINE_mongo=mongo@dbgate-plugin-mongo
LABEL_dynamo=Dynamo-connection
SERVER_dynamo=localhost
PORT_dynamo=16015
AUTH_TYPE_dynamo=onpremise
DATABASE_dynamo=localhost
ENGINE_dynamo=dynamodb@dbgate-plugin-dynamodb
+168
View File
@@ -0,0 +1,168 @@
const fs = require('fs');
const path = require('path');
const { spawn, spawnSync } = require('child_process');
const rootDir = path.resolve(__dirname, '..', '..');
const testApiDir = path.join(rootDir, 'test-api');
const aigwmockDir = path.join(rootDir, 'packages', 'aigwmock');
const tmpDataDir = path.resolve(__dirname, '..', 'tmpdata');
const testApiPidFile = path.join(tmpDataDir, 'test-api.pid');
const aigwmockPidFile = path.join(tmpDataDir, 'aigwmock.pid');
const isWindows = process.platform === 'win32';
const dbgateApi = require('dbgate-api');
dbgateApi.initializeApiEnvironment();
const dbgatePluginMysql = require('dbgate-plugin-mysql');
dbgateApi.registerPlugins(dbgatePluginMysql);
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
// --- MySQL setup (same as charts init) ---
async function initMySqlDatabase(dbname, inputFile) {
const connection = {
server: process.env.SERVER_mysql,
user: process.env.USER_mysql,
password: process.env.PASSWORD_mysql,
port: process.env.PORT_mysql,
engine: 'mysql@dbgate-plugin-mysql',
};
await dbgateApi.executeQuery({
connection,
sql: `DROP DATABASE IF EXISTS ${dbname}`,
});
await dbgateApi.executeQuery({
connection,
sql: `CREATE DATABASE ${dbname}`,
});
await dbgateApi.importDatabase({
connection: { ...connection, database: dbname },
inputFile,
});
}
// --- Process management helpers ---
function readProcessStartTime(pid) {
if (process.platform === 'linux') {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
return stat.split(' ')[21] || null;
} catch (err) {
return null;
}
}
return null;
}
function isPidStillOurs(meta) {
if (!meta || !(meta.pid > 0)) return false;
if (process.platform === 'linux' && meta.startTime) {
const current = readProcessStartTime(meta.pid);
return current === meta.startTime;
}
return true;
}
function stopProcess(pidFile) {
if (!fs.existsSync(pidFile)) return;
try {
const content = fs.readFileSync(pidFile, 'utf-8').trim();
let meta;
try {
meta = JSON.parse(content);
} catch (_) {
const pid = Number(content);
meta = Number.isInteger(pid) && pid > 0 ? { pid } : null;
}
if (isPidStillOurs(meta)) {
process.kill(meta.pid);
}
} catch (err) {
// ignore stale pid or already terminated
}
try {
fs.unlinkSync(pidFile);
} catch (err) {
// ignore
}
}
function ensureDependencies(dir, checkFile) {
if (fs.existsSync(checkFile)) return;
const command = isWindows ? 'cmd.exe' : 'yarn';
const args = isWindows ? ['/c', 'yarn install --silent'] : ['install', '--silent'];
const result = spawnSync(command, args, {
cwd: dir,
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
throw new Error(`DBGM-00297 Failed to install dependencies in ${dir}`);
}
}
function startBackgroundProcess(dir, pidFile, port) {
const command = isWindows ? 'cmd.exe' : 'yarn';
const args = isWindows ? ['/c', 'yarn start'] : ['start'];
const child = spawn(command, args, {
cwd: dir,
env: { ...process.env, PORT: String(port) },
detached: true,
stdio: 'ignore',
});
child.unref();
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
const meta = { pid: child.pid };
const startTime = readProcessStartTime(child.pid);
if (startTime) meta.startTime = startTime;
fs.writeFileSync(pidFile, JSON.stringify(meta));
}
async function waitForReady(url, timeoutMs = 30000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch(url);
if (response.ok) return;
} catch (err) {
// continue waiting
}
await delay(500);
}
throw new Error(`DBGM-00305 Server at ${url} did not start in time`);
}
// --- Main ---
async function run() {
// 1. Set up MyChinook MySQL database
console.log('[ai-chat init] Setting up MyChinook database...');
await initMySqlDatabase('MyChinook', path.resolve(path.join(__dirname, '../data/chinook-mysql.sql')));
// 2. Start test-api (GraphQL/REST server on port 4444)
console.log('[ai-chat init] Starting test-api on port 4444...');
stopProcess(testApiPidFile);
ensureDependencies(testApiDir, path.join(testApiDir, 'node_modules', 'swagger-jsdoc', 'package.json'));
startBackgroundProcess(testApiDir, testApiPidFile, 4444);
await waitForReady('http://localhost:4444/openapi.json');
console.log('[ai-chat init] test-api is ready');
// 3. Start aigwmock (AI Gateway mock on port 3110)
console.log('[ai-chat init] Starting aigwmock on port 3110...');
stopProcess(aigwmockPidFile);
ensureDependencies(aigwmockDir, path.join(aigwmockDir, 'node_modules', 'express', 'package.json'));
startBackgroundProcess(aigwmockDir, aigwmockPidFile, 3110);
await waitForReady('http://localhost:3110/openrouter/v1/models');
console.log('[ai-chat init] aigwmock is ready');
}
run().catch(err => {
console.error(err);
process.exit(1);
});
+32
View File
@@ -8,6 +8,8 @@ const dbgatePluginMysql = require('dbgate-plugin-mysql');
dbgateApi.registerPlugins(dbgatePluginMysql);
const dbgatePluginPostgres = require('dbgate-plugin-postgres');
dbgateApi.registerPlugins(dbgatePluginPostgres);
const dbgatePluginDynamodb = require('dbgate-plugin-dynamodb');
dbgateApi.registerPlugins(dbgatePluginDynamodb);
async function initMySqlDatabase(dbname, inputFile) {
await dbgateApi.executeQuery({
@@ -125,6 +127,34 @@ async function initMongoDatabase(dbname, inputDirectory) {
// });
}
async function initDynamoDatabase(inputDirectory) {
const dynamodbConnection = {
server: process.env.SERVER_dynamo,
port: process.env.PORT_dynamo,
authType: 'onpremise',
engine: 'dynamodb@dbgate-plugin-dynamodb',
};
const driver = dbgatePluginDynamodb.drivers.find(d => d.engine === 'dynamodb@dbgate-plugin-dynamodb');
const pool = await driver.connect(dynamodbConnection);
const collections = await driver.listCollections(pool);
for (const collection of collections) {
await driver.dropTable(pool, collection);
}
await driver.disconnect(pool);
for (const file of fs.readdirSync(inputDirectory)) {
const pureName = path.parse(file).name;
const src = await dbgateApi.jsonLinesReader({ fileName: path.join(inputDirectory, file) });
const dst = await dbgateApi.tableWriter({
connection: dynamodbConnection,
pureName,
createIfNotExists: true,
});
await dbgateApi.copyStream(src, dst);
}
}
const baseDir = path.join(os.homedir(), '.dbgate');
async function copyFolder(source, target) {
@@ -148,6 +178,8 @@ async function run() {
await initMongoDatabase('MgChinook', path.resolve(path.join(__dirname, '../data/chinook-jsonl')));
await initMongoDatabase('MgRivers', path.resolve(path.join(__dirname, '../data/rivers-jsonl')));
await initDynamoDatabase(path.resolve(path.join(__dirname, '../data/chinook-jsonl')));
await copyFolder(
path.resolve(path.join(__dirname, '../data/chinook-jsonl')),
path.join(baseDir, 'archive-e2etests', 'default')
+24
View File
@@ -7,6 +7,8 @@ const dbgatePluginMysql = require('dbgate-plugin-mysql');
dbgateApi.registerPlugins(dbgatePluginMysql);
const dbgatePluginPostgres = require('dbgate-plugin-postgres');
dbgateApi.registerPlugins(dbgatePluginPostgres);
const dbgatePluginDynamodb = require('dbgate-plugin-dynamodb');
dbgateApi.registerPlugins(dbgatePluginDynamodb);
async function createDb(connection, dropDbSql, createDbSql, database = 'my_guitar_shop', { dropDatabaseName } = {}) {
if (dropDbSql) {
@@ -125,6 +127,28 @@ async function run() {
{ dropDatabaseName: 'my_guitar_shop' }
);
}
if (localconfig.dynamo) {
const dynamodbConnection = {
server: process.env.SERVER_dynamo,
port: process.env.PORT_dynamo,
authType: 'onpremise',
engine: 'dynamodb@dbgate-plugin-dynamodb',
};
const driver = dbgatePluginDynamodb.drivers.find(d => d.engine === 'dynamodb@dbgate-plugin-dynamodb');
const pool = await driver.connect(dynamodbConnection);
const collections = await driver.listCollections(pool);
for (const collection of collections) {
await driver.dropTable(pool, collection);
}
await driver.disconnect(pool);
await dbgateApi.importDbFromFolder({
connection: dynamodbConnection,
folder: path.resolve(path.join(__dirname, '../data/my-guitar-shop')),
});
}
}
dbgateApi.runScript(run);
+37 -6
View File
@@ -27,7 +27,28 @@ async function waitForApiReady(timeoutMs = 30000) {
await delay(500);
}
throw new Error('DBGM-00000 test-api did not start on port 4444 in time');
throw new Error('DBGM-00306 test-api did not start on port 4444 in time');
}
function readProcessStartTime(pid) {
if (process.platform === 'linux') {
try {
const stat = fs.readFileSync(`/proc/${pid}/stat`, 'utf-8');
return stat.split(' ')[21] || null;
} catch (err) {
return null;
}
}
return null;
}
function isPidStillOurs(meta) {
if (!meta || !(meta.pid > 0)) return false;
if (process.platform === 'linux' && meta.startTime) {
const current = readProcessStartTime(meta.pid);
return current === meta.startTime;
}
return true;
}
function stopPreviousTestApi() {
@@ -36,9 +57,16 @@ function stopPreviousTestApi() {
}
try {
const pid = Number(fs.readFileSync(pidFile, 'utf-8'));
if (Number.isInteger(pid) && pid > 0) {
process.kill(pid);
const content = fs.readFileSync(pidFile, 'utf-8').trim();
let meta;
try {
meta = JSON.parse(content);
} catch (_) {
const pid = Number(content);
meta = Number.isInteger(pid) && pid > 0 ? { pid } : null;
}
if (isPidStillOurs(meta)) {
process.kill(meta.pid);
}
} catch (err) {
// ignore stale pid file or already terminated process
@@ -67,7 +95,10 @@ function startTestApi() {
child.unref();
fs.mkdirSync(path.dirname(pidFile), { recursive: true });
fs.writeFileSync(pidFile, String(child.pid));
const meta = { pid: child.pid };
const startTime = readProcessStartTime(child.pid);
if (startTime) meta.startTime = startTime;
fs.writeFileSync(pidFile, JSON.stringify(meta));
}
function ensureTestApiDependencies() {
@@ -85,7 +116,7 @@ function ensureTestApiDependencies() {
});
if (result.status !== 0) {
throw new Error('DBGM-00000 Failed to install test-api dependencies');
throw new Error('DBGM-00307 Failed to install test-api dependencies');
}
}
+4 -1
View File
@@ -25,6 +25,7 @@
"cy:run:cloud": "cypress run --spec cypress/e2e/cloud.cy.js",
"cy:run:charts": "cypress run --spec cypress/e2e/charts.cy.js",
"cy:run:redis": "cypress run --spec cypress/e2e/redis.cy.js",
"cy:run:ai-chat": "cypress run --spec cypress/e2e/ai-chat.cy.js",
"start:add-connection": "node clearTestingData && cd .. && node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:portal": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/portal/.env node e2e-tests/init/portal.js && env-cmd -f e2e-tests/env/portal/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:oauth": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/oauth/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
@@ -35,6 +36,7 @@
"start:cloud": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/cloud/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:charts": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/charts/.env node e2e-tests/init/charts.js && env-cmd -f e2e-tests/env/charts/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:redis": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/redis/.env node e2e-tests/init/redis.js && env-cmd -f e2e-tests/env/redis/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:ai-chat": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/ai-chat/.env node e2e-tests/init/ai-chat.js && env-cmd -f e2e-tests/env/ai-chat/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"test:add-connection": "start-server-and-test start:add-connection http://localhost:3000 cy:run:add-connection",
"test:portal": "start-server-and-test start:portal http://localhost:3000 cy:run:portal",
"test:oauth": "start-server-and-test start:oauth http://localhost:3000 cy:run:oauth",
@@ -45,7 +47,8 @@
"test:cloud": "start-server-and-test start:cloud http://localhost:3000 cy:run:cloud",
"test:charts": "start-server-and-test start:charts http://localhost:3000 cy:run:charts",
"test:redis": "start-server-and-test start:redis http://localhost:3000 cy:run:redis",
"test": "yarn test:add-connection && yarn test:portal && yarn test:oauth && yarn test:browse-data && yarn test:rest && yarn test:team && yarn test:multi-sql && yarn test:cloud && yarn test:charts && yarn test:redis",
"test:ai-chat": "start-server-and-test start:ai-chat http://localhost:3000 cy:run:ai-chat",
"test": "yarn test:add-connection && yarn test:portal && yarn test:oauth && yarn test:browse-data && yarn test:rest && yarn test:team && yarn test:multi-sql && yarn test:cloud && yarn test:charts && yarn test:redis && yarn test:ai-chat",
"test:ci": "yarn test"
},
"dependencies": {}
+2 -1
View File
@@ -1 +1,2 @@
test-api.pid
test-api.pid
aigwmock.pid
@@ -0,0 +1,536 @@
const requireEngineDriver = require('dbgate-api/src/utility/requireEngineDriver');
const crypto = require('crypto');
const stream = require('stream');
const { mongoDbEngine, dynamoDbEngine } = require('../engines');
const tableWriter = require('dbgate-api/src/shell/tableWriter');
const tableReader = require('dbgate-api/src/shell/tableReader');
const copyStream = require('dbgate-api/src/shell/copyStream');
function randomCollectionName() {
return 'test_' + crypto.randomBytes(6).toString('hex');
}
const documentEngines = [
{ label: 'MongoDB', engine: mongoDbEngine },
{ label: 'DynamoDB', engine: dynamoDbEngine },
];
async function connectEngine(engine) {
const driver = requireEngineDriver(engine.connection);
const conn = await driver.connect(engine.connection);
return { driver, conn };
}
async function createCollection(driver, conn, collectionName, engine) {
if (engine.connection.engine.startsWith('dynamodb')) {
await driver.operation(conn, {
type: 'createCollection',
collection: {
name: collectionName,
partitionKey: '_id',
partitionKeyType: 'S',
},
});
} else {
await driver.operation(conn, {
type: 'createCollection',
collection: { name: collectionName },
});
}
}
async function dropCollection(driver, conn, collectionName) {
try {
await driver.operation(conn, {
type: 'dropCollection',
collection: collectionName,
});
} catch (e) {
// Ignore errors when dropping (collection may not exist)
}
}
async function insertDocument(driver, conn, collectionName, doc) {
return driver.updateCollection(conn, {
inserts: [{ pureName: collectionName, document: {}, fields: doc }],
updates: [],
deletes: [],
});
}
async function readAll(driver, conn, collectionName) {
return driver.readCollection(conn, { pureName: collectionName, limit: 1000 });
}
async function updateDocument(driver, conn, collectionName, condition, fields) {
return driver.updateCollection(conn, {
inserts: [],
updates: [{ pureName: collectionName, condition, fields }],
deletes: [],
});
}
async function deleteDocument(driver, conn, collectionName, condition) {
return driver.updateCollection(conn, {
inserts: [],
updates: [],
deletes: [{ pureName: collectionName, condition }],
});
}
describe('Collection CRUD', () => {
describe.each(documentEngines.map(e => [e.label, e.engine]))('%s', (label, engine) => {
let driver;
let conn;
let collectionName;
beforeAll(async () => {
const result = await connectEngine(engine);
driver = result.driver;
conn = result.conn;
});
afterAll(async () => {
if (conn) {
await driver.close(conn);
}
});
beforeEach(async () => {
collectionName = randomCollectionName();
await createCollection(driver, conn, collectionName, engine);
});
afterEach(async () => {
await dropCollection(driver, conn, collectionName);
});
// ---- INSERT ----
test('insert a single document', async () => {
const res = await insertDocument(driver, conn, collectionName, {
_id: 'doc1',
name: 'Alice',
age: 30,
});
expect(res.inserted.length).toBe(1);
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Alice');
expect(all.rows[0].age).toBe(30);
});
test('insert multiple documents', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'a1', name: 'Alice' });
await insertDocument(driver, conn, collectionName, { _id: 'a2', name: 'Bob' });
await insertDocument(driver, conn, collectionName, { _id: 'a3', name: 'Charlie' });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(3);
const names = all.rows.map(r => r.name).sort();
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
});
test('insert document with nested object', async () => {
await insertDocument(driver, conn, collectionName, {
_id: 'nested1',
name: 'Alice',
address: { city: 'Prague', zip: '11000' },
});
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].address.city).toBe('Prague');
expect(all.rows[0].address.zip).toBe('11000');
});
// ---- READ ----
test('read from empty collection returns no rows', async () => {
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(0);
});
test('read with limit', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'l1', name: 'A' });
await insertDocument(driver, conn, collectionName, { _id: 'l2', name: 'B' });
await insertDocument(driver, conn, collectionName, { _id: 'l3', name: 'C' });
const limited = await driver.readCollection(conn, {
pureName: collectionName,
limit: 2,
});
expect(limited.rows.length).toBe(2);
});
test('count documents', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'c1', name: 'A' });
await insertDocument(driver, conn, collectionName, { _id: 'c2', name: 'B' });
const result = await driver.readCollection(conn, {
pureName: collectionName,
countDocuments: true,
});
expect(result.count).toBe(2);
});
test('count documents on empty collection returns zero', async () => {
const result = await driver.readCollection(conn, {
pureName: collectionName,
countDocuments: true,
});
expect(result.count).toBe(0);
});
// ---- UPDATE ----
test('update an existing document', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'u1', name: 'Alice', age: 25 });
const res = await updateDocument(driver, conn, collectionName, { _id: 'u1' }, { name: 'Alice Updated' });
expect(res.errorMessage).toBeUndefined();
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Alice Updated');
});
test('update does not create new document', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'u2', name: 'Bob' });
await updateDocument(driver, conn, collectionName, { _id: 'nonexistent' }, { name: 'Ghost' });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Bob');
});
test('update only specified fields', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'u3', name: 'Carol', age: 40, city: 'London' });
await updateDocument(driver, conn, collectionName, { _id: 'u3' }, { age: 41 });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Carol');
expect(all.rows[0].age).toBe(41);
expect(all.rows[0].city).toBe('London');
});
// ---- DELETE ----
test('delete an existing document', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'd1', name: 'Alice' });
await insertDocument(driver, conn, collectionName, { _id: 'd2', name: 'Bob' });
const res = await deleteDocument(driver, conn, collectionName, { _id: 'd1' });
expect(res.errorMessage).toBeUndefined();
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Bob');
});
test('delete non-existing document does not affect collection', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'dx1', name: 'Alice' });
await deleteDocument(driver, conn, collectionName, { _id: 'nonexistent' });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Alice');
});
test('delete all documents leaves empty collection', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'da1', name: 'A' });
await insertDocument(driver, conn, collectionName, { _id: 'da2', name: 'B' });
await deleteDocument(driver, conn, collectionName, { _id: 'da1' });
await deleteDocument(driver, conn, collectionName, { _id: 'da2' });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(0);
});
// ---- EDGE CASES ----
test('insert and read document with empty string field', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'e1', name: '', value: 'test' });
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('');
expect(all.rows[0].value).toBe('test');
});
test('insert and read document with numeric values', async () => {
await insertDocument(driver, conn, collectionName, {
_id: 'n1',
intVal: 42,
floatVal: 3.14,
zero: 0,
negative: -10,
});
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].intVal).toBe(42);
expect(all.rows[0].floatVal).toBeCloseTo(3.14);
expect(all.rows[0].zero).toBe(0);
expect(all.rows[0].negative).toBe(-10);
});
test('insert and read document with boolean values', async () => {
await insertDocument(driver, conn, collectionName, {
_id: 'b1',
active: true,
deleted: false,
});
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].active).toBe(true);
expect(all.rows[0].deleted).toBe(false);
});
test('reading non-existing collection returns error or empty', async () => {
const result = await driver.readCollection(conn, {
pureName: 'nonexistent_collection_' + crypto.randomBytes(4).toString('hex'),
limit: 10,
});
// Depending on the driver, this may return an error or empty rows
if (result.errorMessage) {
expect(typeof result.errorMessage).toBe('string');
} else {
expect(result.rows.length).toBe(0);
}
});
test('replace full document via update with document field', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'r1', name: 'Original', extra: 'data' });
await driver.updateCollection(conn, {
inserts: [],
updates: [
{
pureName: collectionName,
condition: { _id: 'r1' },
document: { _id: 'r1', name: 'Replaced' },
fields: {},
},
],
deletes: [],
});
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].name).toBe('Replaced');
});
test('insert then update then delete lifecycle', async () => {
// Insert
await insertDocument(driver, conn, collectionName, { _id: 'life1', name: 'Lifecycle', status: 'created' });
let all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(1);
expect(all.rows[0].status).toBe('created');
// Update
await updateDocument(driver, conn, collectionName, { _id: 'life1' }, { status: 'updated' });
all = await readAll(driver, conn, collectionName);
expect(all.rows[0].status).toBe('updated');
// Delete
await deleteDocument(driver, conn, collectionName, { _id: 'life1' });
all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(0);
});
});
});
function createDocumentImportStream(documents) {
const pass = new stream.PassThrough({ objectMode: true });
pass.write({ __isStreamHeader: true, __isDynamicStructure: true });
for (const doc of documents) {
pass.write(doc);
}
pass.end();
return pass;
}
function createExportStream() {
const writable = new stream.Writable({ objectMode: true });
writable.resultArray = [];
writable._write = (chunk, encoding, callback) => {
writable.resultArray.push(chunk);
callback();
};
return writable;
}
describe('Collection Import/Export', () => {
describe.each(documentEngines.map(e => [e.label, e.engine]))('%s', (label, engine) => {
let driver;
let conn;
let collectionName;
beforeAll(async () => {
const result = await connectEngine(engine);
driver = result.driver;
conn = result.conn;
});
afterAll(async () => {
if (conn) {
await driver.close(conn);
}
});
beforeEach(async () => {
collectionName = randomCollectionName();
await createCollection(driver, conn, collectionName, engine);
});
afterEach(async () => {
await dropCollection(driver, conn, collectionName);
});
test('import documents via stream', async () => {
const documents = [
{ _id: 'imp1', name: 'Alice', age: 30 },
{ _id: 'imp2', name: 'Bob', age: 25 },
{ _id: 'imp3', name: 'Charlie', age: 35 },
];
const reader = createDocumentImportStream(documents);
const writer = await tableWriter({
systemConnection: conn,
driver,
pureName: collectionName,
createIfNotExists: true,
});
await copyStream(reader, writer);
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(3);
const names = all.rows.map(r => r.name).sort();
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
});
test('export documents via stream', async () => {
await insertDocument(driver, conn, collectionName, { _id: 'exp1', name: 'Alice', city: 'Prague' });
await insertDocument(driver, conn, collectionName, { _id: 'exp2', name: 'Bob', city: 'Vienna' });
await insertDocument(driver, conn, collectionName, { _id: 'exp3', name: 'Charlie', city: 'Berlin' });
const reader = await tableReader({
systemConnection: conn,
driver,
pureName: collectionName,
});
const writer = createExportStream();
await copyStream(reader, writer);
const rows = writer.resultArray.filter(x => !x.__isStreamHeader);
expect(rows.length).toBe(3);
const names = rows.map(r => r.name).sort();
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
});
test('import then export round-trip', async () => {
const documents = [
{ _id: 'rt1', name: 'Alice', value: 100 },
{ _id: 'rt2', name: 'Bob', value: 200 },
{ _id: 'rt3', name: 'Charlie', value: 300 },
{ _id: 'rt4', name: 'Diana', value: 400 },
];
// Import
const importReader = createDocumentImportStream(documents);
const importWriter = await tableWriter({
systemConnection: conn,
driver,
pureName: collectionName,
createIfNotExists: true,
});
await copyStream(importReader, importWriter);
// Export
const exportReader = await tableReader({
systemConnection: conn,
driver,
pureName: collectionName,
});
const exportWriter = createExportStream();
await copyStream(exportReader, exportWriter);
const rows = exportWriter.resultArray.filter(x => !x.__isStreamHeader);
expect(rows.length).toBe(4);
const sortedRows = rows.sort((a, b) => a._id.localeCompare(b._id));
for (const doc of documents) {
const found = sortedRows.find(r => r._id === doc._id);
expect(found).toBeDefined();
expect(found.name).toBe(doc.name);
expect(found.value).toBe(doc.value);
}
});
test('import documents with nested objects', async () => {
const documents = [
{ _id: 'nest1', name: 'Alice', address: { city: 'Prague', zip: '11000' } },
{ _id: 'nest2', name: 'Bob', address: { city: 'Vienna', zip: '1010' } },
];
const reader = createDocumentImportStream(documents);
const writer = await tableWriter({
systemConnection: conn,
driver,
pureName: collectionName,
createIfNotExists: true,
});
await copyStream(reader, writer);
const all = await readAll(driver, conn, collectionName);
expect(all.rows.length).toBe(2);
const alice = all.rows.find(r => r.name === 'Alice');
expect(alice.address.city).toBe('Prague');
expect(alice.address.zip).toBe('11000');
});
test('import many documents', async () => {
const documents = [];
for (let i = 0; i < 150; i++) {
documents.push({ _id: `many${i}`, name: `Name${i}`, index: i });
}
const reader = createDocumentImportStream(documents);
const writer = await tableWriter({
systemConnection: conn,
driver,
pureName: collectionName,
createIfNotExists: true,
});
await copyStream(reader, writer);
const result = await driver.readCollection(conn, {
pureName: collectionName,
countDocuments: true,
});
expect(result.count).toBe(150);
});
test('export empty collection returns no data rows', async () => {
const reader = await tableReader({
systemConnection: conn,
driver,
pureName: collectionName,
});
const writer = createExportStream();
await copyStream(reader, writer);
const rows = writer.resultArray.filter(x => !x.__isStreamHeader);
expect(rows.length).toBe(0);
});
});
});
+17
View File
@@ -123,5 +123,22 @@ services:
retries: 3
start_period: 40s
mongodb:
image: mongo:4.0.12
restart: always
volumes:
- mongo-data:/data/db
- mongo-config:/data/configdb
ports:
- 27017:27017
dynamodb:
image: amazon/dynamodb-local
restart: always
ports:
- 8000:8000
volumes:
firebird-data:
mongo-data:
mongo-config:
+23
View File
@@ -738,6 +738,27 @@ const firebirdEngine = {
skipDropReferences: true,
};
/** @type {import('dbgate-types').TestEngineInfo} */
const mongoDbEngine = {
label: 'MongoDB',
connection: {
engine: 'mongo@dbgate-plugin-mongo',
server: 'localhost',
port: 27017,
},
};
/** @type {import('dbgate-types').TestEngineInfo} */
const dynamoDbEngine = {
label: 'DynamoDB',
connection: {
engine: 'dynamodb@dbgate-plugin-dynamodb',
server: 'localhost',
port: 8000,
authType: 'onpremise',
},
};
const enginesOnCi = [
// all engines, which would be run on GitHub actions
mysqlEngine,
@@ -788,3 +809,5 @@ module.exports.libsqlFileEngine = libsqlFileEngine;
module.exports.libsqlWsEngine = libsqlWsEngine;
module.exports.duckdbEngine = duckdbEngine;
module.exports.firebirdEngine = firebirdEngine;
module.exports.mongoDbEngine = mongoDbEngine;
module.exports.dynamoDbEngine = dynamoDbEngine;
+4 -2
View File
@@ -1,5 +1,6 @@
const requireEngineDriver = require('dbgate-api/src/utility/requireEngineDriver');
const engines = require('./engines');
const { mongoDbEngine, dynamoDbEngine } = require('./engines');
global.DBGATE_PACKAGES = {
'dbgate-tools': require('dbgate-tools'),
'dbgate-sqltree': require('dbgate-sqltree'),
@@ -9,7 +10,7 @@ global.DBGATE_PACKAGES = {
async function connectEngine(engine) {
const { connection } = engine;
const driver = requireEngineDriver(connection);
for (;;) {
for (; ;) {
try {
const conn = await driver.connect(connection);
await driver.getVersion(conn);
@@ -26,7 +27,8 @@ async function connectEngine(engine) {
async function run() {
await new Promise(resolve => setTimeout(resolve, 10000));
await Promise.all(engines.map(engine => connectEngine(engine)));
const documentEngines = [mongoDbEngine, dynamoDbEngine];
await Promise.all([...engines, ...documentEngines].map(engine => connectEngine(engine)));
}
run();
+1 -1
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "7.1.1-packer-beta.3",
"version": "7.1.8",
"name": "dbgate-all",
"workspaces": [
"packages/*",
+14
View File
@@ -0,0 +1,14 @@
{
"name": "dbgate-aigwmock",
"version": "1.0.0",
"description": "Mock AI Gateway server for E2E testing",
"main": "src/index.js",
"scripts": {
"start": "node src/index.js"
},
"license": "GPL-3.0",
"dependencies": {
"cors": "^2.8.6",
"express": "^5.2.1"
}
}
+202
View File
@@ -0,0 +1,202 @@
const express = require('express');
const cors = require('cors');
const fs = require('fs');
const path = require('path');
const app = express();
app.use(cors());
app.use(express.json({ limit: '50mb' }));
const responses = JSON.parse(fs.readFileSync(path.join(__dirname, 'mockResponses.json'), 'utf-8'));
let callCounter = 0;
// GET /openrouter/v1/models
app.get('/openrouter/v1/models', (req, res) => {
res.json({
data: [{ id: 'mock-model', name: 'Mock Model' }],
preferredModel: 'mock-model',
});
});
// POST /openrouter/v1/chat/completions
app.post('/openrouter/v1/chat/completions', (req, res) => {
const messages = req.body.messages || [];
// Find the first user message (skip system messages)
const userMessage = messages.find(m => m.role === 'user');
if (!userMessage) {
return streamTextResponse(res, "I don't have enough context to help. Please ask a question.");
}
// Count assistant messages to determine the current step
const assistantCount = messages.filter(m => m.role === 'assistant').length;
// Find matching scenario by regex
const scenario = responses.scenarios.find(s => {
const regex = new RegExp(s.match, 'i');
return regex.test(userMessage.content);
});
if (!scenario) {
console.log(`[aigwmock] No scenario matched for: "${userMessage.content}"`);
return streamTextResponse(res, "I'm a mock AI assistant. I don't have a prepared response for that question.");
}
const step = scenario.steps[assistantCount];
if (!step) {
console.log(`[aigwmock] No more steps for scenario (step ${assistantCount})`);
return streamTextResponse(res, "I've completed my analysis of this topic.");
}
console.log(`[aigwmock] Scenario matched: "${scenario.match}", step ${assistantCount}, type: ${step.type}`);
if (step.type === 'tool_calls') {
return streamToolCallResponse(res, step.tool_calls);
} else {
return streamTextResponse(res, step.content);
}
});
function streamTextResponse(res, content) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const id = `chatcmpl-mock-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
// Split content into chunks for realistic streaming
const chunkSize = 20;
const chunks = [];
for (let i = 0; i < content.length; i += chunkSize) {
chunks.push(content.substring(i, i + chunkSize));
}
// Send initial role chunk
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [{ index: 0, delta: { role: 'assistant', content: '' }, finish_reason: null }],
});
// Send content chunks
for (const chunk of chunks) {
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [{ index: 0, delta: { content: chunk }, finish_reason: null }],
});
}
// Send finish
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [{ index: 0, delta: {}, finish_reason: 'stop' }],
});
res.write('data: [DONE]\n\n');
res.end();
}
function streamToolCallResponse(res, toolCalls) {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const id = `chatcmpl-mock-${Date.now()}`;
const created = Math.floor(Date.now() / 1000);
for (let i = 0; i < toolCalls.length; i++) {
const tc = toolCalls[i];
const callId = `call_mock_${++callCounter}`;
const args = JSON.stringify(tc.arguments);
if (i === 0) {
// First tool call: include role
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [
{
index: 0,
delta: {
role: 'assistant',
content: null,
tool_calls: [{ index: i, id: callId, type: 'function', function: { name: tc.name, arguments: '' } }],
},
finish_reason: null,
},
],
});
} else {
// Additional tool calls
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: i, id: callId, type: 'function', function: { name: tc.name, arguments: '' } }],
},
finish_reason: null,
},
],
});
}
// Stream the arguments
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [
{
index: 0,
delta: {
tool_calls: [{ index: i, function: { arguments: args } }],
},
finish_reason: null,
},
],
});
}
// Send finish with tool_calls reason
writeSSE(res, {
id,
object: 'chat.completion.chunk',
created,
model: 'mock-model',
choices: [{ index: 0, delta: {}, finish_reason: 'tool_calls' }],
});
res.write('data: [DONE]\n\n');
res.end();
}
function writeSSE(res, data) {
res.write(`data: ${JSON.stringify(data)}\n\n`);
}
const port = process.env.PORT || 3110;
app.listen(port, () => {
console.log(`[aigwmock] AI Gateway mock server listening on port ${port}`);
});
+193
View File
@@ -0,0 +1,193 @@
{
"scenarios": [
{
"match": "chart.*popular.*genre|popular.*genre.*chart|most popular genre",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Genre" } }
]
},
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Track" } }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_sql_select",
"arguments": {
"sql": "SELECT g.Name AS genre, COUNT(t.TrackId) AS track_count FROM Genre g JOIN Track t ON g.GenreId = t.GenreId GROUP BY g.Name ORDER BY track_count DESC LIMIT 10"
}
}
]
},
{
"type": "text",
"content": "Here is a chart showing the most popular genres by track count:\n\n```chart\n{\"type\":\"bar\",\"data\":{\"labels\":[\"Rock\",\"Latin\",\"Metal\",\"Alternative & Punk\",\"Jazz\",\"Blues\",\"Classical\",\"R&B/Soul\",\"Reggae\",\"Pop\"],\"datasets\":[{\"label\":\"Track Count\",\"data\":[1297,579,374,332,130,81,74,61,58,48]}]},\"options\":{\"plugins\":{\"title\":{\"display\":true,\"text\":\"Most Popular Genres by Track Count\"}}}}\n```"
}
]
},
{
"match": "most popular artist|popular artist|top artist",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Artist" } }
]
},
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Album" } }
]
},
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Track" } }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_sql_select",
"arguments": {
"sql": "SELECT ar.Name AS artist, COUNT(t.TrackId) AS track_count FROM Artist ar JOIN Album al ON ar.ArtistId = al.ArtistId JOIN Track t ON al.AlbumId = t.AlbumId GROUP BY ar.Name ORDER BY track_count DESC LIMIT 10"
}
}
]
},
{
"type": "text",
"content": "The most popular artist by number of tracks is **Iron Maiden** with 213 tracks, followed by **U2** with 135 tracks and **Led Zeppelin** with 114 tracks."
}
]
},
{
"match": "list.*user|show.*user|get.*user",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "graphql_introspect_schema", "arguments": {} }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_graphql_query",
"arguments": {
"query": "{ users { id firstName lastName email } }"
}
}
]
},
{
"type": "text",
"content": "Here are the users from the GraphQL API. The system contains multiple registered users with their names and email addresses."
}
]
},
{
"match": "chart.*product.*categor|product.*categor.*chart|chart.*categor",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "graphql_introspect_schema", "arguments": {} }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_graphql_query",
"arguments": {
"query": "{ products { category } }"
}
}
]
},
{
"type": "text",
"content": "Here is a bar chart showing the distribution of products across categories:\n\n```chart\n{\"type\":\"bar\",\"data\":{\"labels\":[\"Electronics\",\"Clothing\",\"Books\",\"Home & Garden\",\"Sports\",\"Toys\"],\"datasets\":[{\"label\":\"Number of Products\",\"data\":[35,30,33,38,32,32]}]},\"options\":{\"plugins\":{\"title\":{\"display\":true,\"text\":\"Products by Category\"}}}}\n```"
}
]
},
{
"match": "most expensive product|expensive.*product|highest price",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "graphql_introspect_schema", "arguments": {} }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_graphql_query",
"arguments": {
"query": "{ products { id name price category } }"
}
}
]
},
{
"type": "text",
"content": "Based on the query results, I found the most expensive product in the system. The product details are shown in the query results above."
}
]
},
{
"match": "show.*categor|list.*categor|all.*categor",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "graphql_introspect_schema", "arguments": {} }
]
},
{
"type": "tool_calls",
"tool_calls": [
{
"name": "execute_graphql_query",
"arguments": {
"query": "{ categories { id name description active } }"
}
}
]
},
{
"type": "text",
"content": "Here are all the categories available in the system. Each category has a name, description, and active status indicating whether it is currently in use."
}
]
},
{
"match": "Explain the following error|doesn't exist|does not exist",
"steps": [
{
"type": "tool_calls",
"tool_calls": [
{ "name": "get_table_schema", "arguments": { "table": "Invoice" } }
]
},
{
"type": "text",
"content": "The error occurs because the table `Invoice2` does not exist in the `MyChinook` database. The correct table name is `Invoice`. Here is the corrected query:\n\n```sql\nSELECT * FROM Invoice\n```\n\nThe table name had a typo — `Invoice2` instead of `Invoice`. The `Invoice` table contains columns like `InvoiceId`, `CustomerId`, `InvoiceDate`, `Total`, and billing address fields."
}
]
}
]
}
+55 -1
View File
@@ -492,7 +492,61 @@ module.exports = {
return mask && !platformInfo.allowShellConnection ? maskConnection(res) : encryptConnection(res);
}
const res = await this.datastore.get(conid);
return res || null;
if (res) return res;
// In a forked runner-script child process, ask the parent for connections that may be
// volatile (in-memory only, e.g. ask-for-password). We only do this when
// there really is a parent (process.send exists) to avoid an infinite loop
// when the parent's own getCore falls through here.
// The check is intentionally narrow: only runner scripts pass
// --process-display-name script, so connect/session/ssh-forward subprocesses
// are not affected and continue to return null immediately.
if (process.send && processArgs.processDisplayName === 'script') {
const conn = await new Promise(resolve => {
let resolved = false;
const cleanup = () => {
process.removeListener('message', handler);
process.removeListener('disconnect', onDisconnect);
clearTimeout(timeout);
};
const settle = value => {
if (!resolved) {
resolved = true;
cleanup();
resolve(value);
}
};
const handler = message => {
if (message?.msgtype === 'volatile-connection-response' && message.conid === conid) {
settle(message.conn || null);
}
};
const onDisconnect = () => settle(null);
const timeout = setTimeout(() => settle(null), 5000);
// Don't let the timer alone keep the process alive if all other work is done
timeout.unref();
process.on('message', handler);
process.once('disconnect', onDisconnect);
try {
process.send({ msgtype: 'get-volatile-connection', conid });
} catch {
settle(null);
}
});
if (conn) {
volatileConnections[conn._id] = conn; // cache for subsequent calls
return conn;
}
}
return null;
},
get_meta: true,
@@ -95,10 +95,12 @@ module.exports = {
}
},
handle_response(conid, database, { msgid, ...response }) {
const [resolve, reject, additionalData] = this.requests[msgid];
resolve(response);
if (additionalData?.auditLogger) {
additionalData?.auditLogger(response);
const [resolve, reject, additionalData] = this.requests[msgid] || [];
if (resolve) {
resolve(response);
if (additionalData?.auditLogger) {
additionalData?.auditLogger(response);
}
}
delete this.requests[msgid];
},
@@ -239,7 +241,7 @@ module.exports = {
sendRequest(conn, message, additionalData = {}) {
const msgid = crypto.randomUUID();
const promise = new Promise((resolve, reject) => {
this.requests[msgid] = [resolve, reject, additionalData];
this.requests[msgid] = [resolve, reject, additionalData, conn.conid, conn.database];
try {
const serializedMessage = serializeJsTypesForJsonStringify({ msgid, ...message });
conn.subprocess.send(serializedMessage);
@@ -264,12 +266,12 @@ module.exports = {
},
sqlSelect_meta: true,
async sqlSelect({ conid, database, select, auditLogSessionGroup }, req) {
async sqlSelect({ conid, database, select, commandTimeout, auditLogSessionGroup }, req) {
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
const res = await this.sendRequest(
opened,
{ msgtype: 'sqlSelect', select },
{ msgtype: 'sqlSelect', select, commandTimeout },
{
auditLogger:
auditLogSessionGroup && select?.from?.name?.pureName
@@ -344,9 +346,12 @@ module.exports = {
},
collectionData_meta: true,
async collectionData({ conid, database, options, auditLogSessionGroup }, req) {
async collectionData({ conid, database, options, commandTimeout, auditLogSessionGroup }, req) {
await testConnectionPermission(conid, req);
const opened = await this.ensureOpened(conid, database);
if (commandTimeout && options) {
options.commandTimeout = commandTimeout;
}
const res = await this.sendRequest(
opened,
{ msgtype: 'collectionData', options },
@@ -580,6 +585,24 @@ module.exports = {
};
},
pingDatabases_meta: true,
async pingDatabases({ databases }, req) {
if (!databases || !Array.isArray(databases)) return { status: 'ok' };
for (const { conid, database } of databases) {
if (!conid || !database) continue;
const existing = this.opened.find(x => x.conid == conid && x.database == database);
if (existing) {
try {
existing.subprocess.send({ msgtype: 'ping' });
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00308 Error pinging DB connection');
this.close(conid, database);
}
}
}
return { status: 'ok' };
},
refresh_meta: true,
async refresh({ conid, database, keepOpen }, req) {
await testConnectionPermission(conid, req);
@@ -622,6 +645,15 @@ module.exports = {
structure: existing.structure,
};
socket.emitChanged(`database-status-changed`, { conid, database });
// Reject all pending requests for this connection
for (const [msgid, entry] of Object.entries(this.requests)) {
const [resolve, reject, additionalData, reqConid, reqDatabase] = entry;
if (reqConid === conid && reqDatabase === database) {
reject('DBGM-00309 Database connection closed');
delete this.requests[msgid];
}
}
}
},
+45 -4
View File
@@ -15,7 +15,8 @@ const getDiagramExport = require('../utility/getDiagramExport');
const apps = require('./apps');
const getMapExport = require('../utility/getMapExport');
const dbgateApi = require('../shell');
const { getLogger } = require('dbgate-tools');
const { getLogger, getSqlFrontMatter } = require('dbgate-tools');
const yaml = require('js-yaml');
const platformInfo = require('../utility/platformInfo');
const { checkSecureFilePathsWithoutDirectory, checkSecureDirectories } = require('../utility/security');
const { copyAppLogsIntoFile, getRecentAppLogRecords } = require('../utility/appLogStore');
@@ -35,13 +36,46 @@ function deserialize(format, text) {
module.exports = {
list_meta: true,
async list({ folder }, req) {
async list({ folder, parseFrontMatter }, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
if (!hasPermission(`files/${folder}/read`, loadedPermissions)) return [];
const dir = path.join(filesdir(), folder);
if (!(await fs.exists(dir))) return [];
const files = (await fs.readdir(dir)).map(file => ({ folder, file }));
return files;
const fileNames = await fs.readdir(dir);
if (!parseFrontMatter) {
return fileNames.map(file => ({ folder, file }));
}
const result = [];
for (const file of fileNames) {
const item = { folder, file };
let fh;
try {
fh = await require('fs').promises.open(path.join(dir, file), 'r');
const buf = new Uint8Array(512);
const { bytesRead } = await fh.read(buf, 0, 512, 0);
let text = Buffer.from(buf.buffer, 0, bytesRead).toString('utf-8');
if (text.includes('-- >>>') && !text.includes('-- <<<')) {
const stat = await fh.stat();
const fullSize = Math.min(stat.size, 4096);
if (fullSize > 512) {
const fullBuf = new Uint8Array(fullSize);
const { bytesRead: fullBytesRead } = await fh.read(fullBuf, 0, fullSize, 0);
text = Buffer.from(fullBuf.buffer, 0, fullBytesRead).toString('utf-8');
}
}
const fm = getSqlFrontMatter(text, yaml);
if (fm?.connectionId) item.connectionId = fm.connectionId;
if (fm?.databaseName) item.databaseName = fm.databaseName;
} catch (e) {
// ignore read errors for individual files
} finally {
if (fh) await fh.close().catch(() => {});
}
result.push(item);
}
return result;
},
listAll_meta: true,
@@ -257,6 +291,13 @@ module.exports = {
return true;
},
exportDiagramPng_meta: true,
async exportDiagramPng({ filePath, pngBase64 }) {
const base64 = pngBase64.replace(/^data:image\/png;base64,/, '');
await fs.writeFile(filePath, Buffer.from(base64, 'base64'));
return true;
},
getFileRealPath_meta: true,
async getFileRealPath({ folder, file }, req) {
const loadedPermissions = await loadPermissionsFromRequest(req);
+74 -1
View File
@@ -1,5 +1,8 @@
const { filterName } = require('dbgate-tools');
const { filterName, getLogger, extractErrorLogData } = require('dbgate-tools');
const logger = getLogger('jsldata');
const { jsldir, archivedir } = require('../utility/directories');
const fs = require('fs');
const path = require('path');
const lineReader = require('line-reader');
const _ = require('lodash');
const { __ } = require('lodash/fp');
@@ -149,6 +152,10 @@ module.exports = {
getRows_meta: true,
async getRows({ jslid, offset, limit, filters, sort, formatterFunction }) {
const fileName = getJslFileName(jslid);
if (!fs.existsSync(fileName)) {
return [];
}
const datastore = await this.ensureDatastore(jslid, formatterFunction);
return datastore.getRows(offset, limit, _.isEmpty(filters) ? null : filters, _.isEmpty(sort) ? null : sort);
},
@@ -159,6 +166,72 @@ module.exports = {
return fs.existsSync(fileName);
},
streamRows_meta: {
method: 'get',
raw: true,
},
streamRows(req, res) {
const { jslid } = req.query;
if (!jslid) {
res.status(400).json({ apiErrorMessage: 'Missing jslid' });
return;
}
// Reject file:// jslids — they resolve to arbitrary server-side paths
if (jslid.startsWith('file://')) {
res.status(403).json({ apiErrorMessage: 'Forbidden jslid scheme' });
return;
}
const fileName = getJslFileName(jslid);
if (!fs.existsSync(fileName)) {
res.status(404).json({ apiErrorMessage: 'File not found' });
return;
}
// Dereference symlinks and normalize case (Windows) before the allow-list check.
// realpathSync is safe here because existsSync confirmed the file is present.
// path.resolve() alone cannot dereference symlinks, so a symlink inside an allowed
// root could otherwise point to an arbitrary external path.
const normalize = p => (process.platform === 'win32' ? p.toLowerCase() : p);
const resolveRoot = r => { try { return fs.realpathSync(r); } catch { return path.resolve(r); } };
let realFile;
try {
realFile = fs.realpathSync(fileName);
} catch {
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
return;
}
const allowedRoots = [jsldir(), archivedir()].map(r => normalize(resolveRoot(r)) + path.sep);
const isAllowed = allowedRoots.some(root => normalize(realFile).startsWith(root));
if (!isAllowed) {
logger.warn({ jslid, realFile }, 'DBGM-00000 streamRows rejected path outside allowed roots');
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
return;
}
res.setHeader('Content-Type', 'application/x-ndjson');
res.setHeader('Cache-Control', 'no-cache');
const stream = fs.createReadStream(realFile, 'utf-8');
req.on('close', () => {
stream.destroy();
});
stream.on('error', err => {
logger.error(extractErrorLogData(err), 'DBGM-00000 Error streaming JSONL file');
if (!res.headersSent) {
res.status(500).json({ apiErrorMessage: 'Stream error' });
} else {
res.end();
}
});
stream.pipe(res);
},
getStats_meta: true,
getStats({ jslid }) {
const file = `${getJslFileName(jslid)}.stats`;
+22 -6
View File
@@ -33,19 +33,35 @@ function readCore(reader, skip, limit, filter) {
});
}
module.exports = {
read_meta: true,
async read({ skip, limit, filter }) {
function readJsonl({ skip, limit, filter }) {
return new Promise(async (resolve, reject) => {
const fileName = path.join(datadir(), 'query-history.jsonl');
// @ts-ignore
if (!(await fs.exists(fileName))) return [];
if (!(await fs.exists(fileName))) return resolve([]);
const reader = fsReverse(fileName);
const res = await readCore(reader, skip, limit, filter);
return res;
resolve(res);
});
}
module.exports = {
read_meta: true,
async read({ skip, limit, filter }, req) {
const storage = require('./storage');
const storageResult = await storage.readQueryHistory({ skip, limit, filter }, req);
if (storageResult) return storageResult;
return readJsonl({ skip, limit, filter });
},
write_meta: true,
async write({ data }) {
async write({ data }, req) {
const storage = require('./storage');
const written = await storage.writeQueryHistory({ data }, req);
if (written) {
socket.emit('query-history-changed');
return 'OK';
}
const fileName = path.join(datadir(), 'query-history.jsonl');
await fs.appendFile(fileName, JSON.stringify(data) + '\n');
socket.emit('query-history-changed');
+21
View File
@@ -196,6 +196,27 @@ module.exports = {
// @ts-ignore
const { msgtype } = message;
if (handleProcessCommunication(message, subprocess)) return;
if (msgtype === 'get-volatile-connection') {
const connections = require('./connections');
// @ts-ignore
const conid = message.conid;
if (!conid || typeof conid !== 'string') return;
const trySend = payload => {
if (!subprocess.connected) return;
try {
subprocess.send(payload);
} catch {
// child disconnected between the check and the send — ignore
}
};
connections.getCore({ conid }).then(conn => {
trySend({ msgtype: 'volatile-connection-response', conid, conn: conn?.unsaved ? conn : null });
}).catch(err => {
logger.error({ ...extractErrorLogData(err), conid }, 'DBGM-00000 Error resolving volatile connection for child process');
trySend({ msgtype: 'volatile-connection-response', conid, conn: null });
});
return;
}
this[`handle_${msgtype}`](runid, message);
});
return _.pick(newOpened, ['runid']);
+13
View File
@@ -228,6 +228,19 @@ module.exports = {
return { state: 'ok' };
},
setIsolationLevel_meta: true,
async setIsolationLevel({ sesid, level }) {
const session = this.opened.find(x => x.sesid == sesid);
if (!session) {
throw new Error('Invalid session');
}
logger.info({ sesid, level }, 'DBGM-00315 Setting transaction isolation level');
session.subprocess.send({ msgtype: 'setIsolationLevel', level });
return { state: 'ok' };
},
executeReader_meta: true,
async executeReader({ conid, database, sql, queryName, appFolder }) {
const { sesid } = await this.create({ conid, database });
@@ -234,12 +234,12 @@ async function handleRunOperation({ msgid, operation, useTransaction }, skipRead
}
}
async function handleQueryData({ msgid, sql, range }, skipReadonlyCheck = false) {
async function handleQueryData({ msgid, sql, range, commandTimeout }, skipReadonlyCheck = false) {
await waitConnected();
const driver = requireEngineDriver(storedConnection);
try {
if (!skipReadonlyCheck) ensureExecuteCustomScript(driver);
const res = await driver.query(dbhan, sql, { range });
const res = await driver.query(dbhan, sql, { range, commandTimeout });
process.send({ msgtype: 'response', msgid, ...serializeJsTypesForJsonStringify(res) });
} catch (err) {
process.send({
@@ -250,11 +250,11 @@ async function handleQueryData({ msgid, sql, range }, skipReadonlyCheck = false)
}
}
async function handleSqlSelect({ msgid, select }) {
async function handleSqlSelect({ msgid, select, commandTimeout }) {
const driver = requireEngineDriver(storedConnection);
const dmp = driver.createDumper();
dumpSqlSelect(dmp, select);
return handleQueryData({ msgid, sql: dmp.s, range: select.range }, true);
return handleQueryData({ msgid, sql: dmp.s, range: select.range, commandTimeout }, true);
}
async function handleDriverDataCore(msgid, callMethod, { logName }) {
+33
View File
@@ -77,6 +77,38 @@ async function handleStopProfiler({ jslid }) {
currentProfiler = null;
}
async function handleSetIsolationLevel({ level }) {
lastActivity = new Date().getTime();
await waitConnected();
const driver = requireEngineDriver(storedConnection);
if (!driver.setTransactionIsolationLevel) {
process.send({ msgtype: 'done', skipFinishedMessage: true });
return;
}
if (driver.isolationLevels && level && !driver.isolationLevels.includes(level)) {
process.send({
msgtype: 'info',
info: {
message: `Isolation level "${level}" is not supported by this driver. Supported levels: ${driver.isolationLevels.join(', ')}`,
severity: 'error',
},
});
process.send({ msgtype: 'done', skipFinishedMessage: true });
return;
}
executingScripts++;
try {
await driver.setTransactionIsolationLevel(dbhan, level);
process.send({ msgtype: 'done', controlCommand: 'setIsolationLevel' });
} finally {
executingScripts--;
}
}
async function handleExecuteControlCommand({ command }) {
lastActivity = new Date().getTime();
@@ -210,6 +242,7 @@ const messageHandlers = {
connect: handleConnect,
executeQuery: handleExecuteQuery,
executeControlCommand: handleExecuteControlCommand,
setIsolationLevel: handleSetIsolationLevel,
executeReader: handleExecuteReader,
startProfiler: handleStartProfiler,
stopProfiler: handleStopProfiler,
+1
View File
@@ -7,6 +7,7 @@ async function runScript(func) {
if (processArgs.checkParent) {
childProcessChecker();
}
try {
await func();
process.exit(0);
+132
View File
@@ -698,6 +698,30 @@ module.exports = {
"columnName": "id_original",
"dataType": "varchar(250)",
"notNull": false
},
{
"pureName": "connections",
"columnName": "httpProxyUrl",
"dataType": "varchar(250)",
"notNull": false
},
{
"pureName": "connections",
"columnName": "httpProxyUser",
"dataType": "varchar(250)",
"notNull": false
},
{
"pureName": "connections",
"columnName": "httpProxyPassword",
"dataType": "varchar(250)",
"notNull": false
},
{
"pureName": "connections",
"columnName": "defaultIsolationLevel",
"dataType": "varchar(250)",
"notNull": false
}
],
"foreignKeys": [
@@ -850,6 +874,114 @@ module.exports = {
}
]
},
{
"pureName": "query_history",
"columns": [
{
"pureName": "query_history",
"columnName": "id",
"dataType": "int",
"autoIncrement": true,
"notNull": true
},
{
"pureName": "query_history",
"columnName": "created",
"dataType": "bigint",
"notNull": true
},
{
"pureName": "query_history",
"columnName": "user_id",
"dataType": "int",
"notNull": false
},
{
"pureName": "query_history",
"columnName": "role_id",
"dataType": "int",
"notNull": false
},
{
"pureName": "query_history",
"columnName": "sql",
"dataType": "text",
"notNull": false
},
{
"pureName": "query_history",
"columnName": "conid",
"dataType": "varchar(100)",
"notNull": false
},
{
"pureName": "query_history",
"columnName": "database",
"dataType": "varchar(200)",
"notNull": false
}
],
"foreignKeys": [
{
"constraintType": "foreignKey",
"constraintName": "FK_query_history_user_id",
"pureName": "query_history",
"refTableName": "users",
"deleteAction": "CASCADE",
"columns": [
{
"columnName": "user_id",
"refColumnName": "id"
}
]
},
{
"constraintType": "foreignKey",
"constraintName": "FK_query_history_role_id",
"pureName": "query_history",
"refTableName": "roles",
"deleteAction": "CASCADE",
"columns": [
{
"columnName": "role_id",
"refColumnName": "id"
}
]
}
],
"indexes": [
{
"constraintName": "idx_query_history_user_id",
"pureName": "query_history",
"constraintType": "index",
"columns": [
{
"columnName": "user_id"
}
]
},
{
"constraintName": "idx_query_history_role_id",
"pureName": "query_history",
"constraintType": "index",
"columns": [
{
"columnName": "role_id"
}
]
}
],
"primaryKey": {
"pureName": "query_history",
"constraintType": "primaryKey",
"constraintName": "PK_query_history",
"columns": [
{
"columnName": "id"
}
]
}
},
{
"pureName": "roles",
"columns": [
+29 -1
View File
@@ -132,7 +132,35 @@ async function connectUtility(driver, storedConnection, connectionMode, addition
}
connection.ssl = await extractConnectionSslParams(connection);
connection.axios = axios.default;
const proxyUrl = String(connection.httpProxyUrl ?? '').trim();
const proxyUser = String(connection.httpProxyUser ?? '').trim();
const proxyPassword = String(connection.httpProxyPassword ?? '').trim();
if (!proxyUrl && (proxyUser || proxyPassword)) {
throw new Error('DBGM-00329 Proxy user or password is set but proxy URL is missing');
}
if (proxyUrl) {
let parsedProxy;
try {
const parsed = new URL(proxyUrl.includes('://') ? proxyUrl : `http://${proxyUrl}`);
parsedProxy = {
protocol: parsed.protocol.replace(':', ''),
host: parsed.hostname,
port: parsed.port ? parseInt(parsed.port, 10) : (parsed.protocol === 'https:' ? 443 : 80),
};
const username = connection.httpProxyUser ?? parsed.username;
const rawPassword = connection.httpProxyPassword ?? parsed.password;
const password = decryptPasswordString(rawPassword);
if (username) {
parsedProxy.auth = { username, password: password ?? '' };
}
} catch (err) {
throw new Error(`DBGM-00334 Invalid proxy URL "${proxyUrl}": ${err && err.message ? err.message : err}`);
}
connection.axios = axios.default.create({ proxy: parsedProxy });
} else {
connection.axios = axios.default;
}
const conn = await driver.connect({ conid: connectionLoaded?._id, ...connection, ...additionalOptions });
return conn;
+1 -1
View File
@@ -101,7 +101,7 @@ function decryptObjectPasswordField(obj, field, encryptor = null) {
return obj;
}
const fieldsToEncrypt = ['password', 'sshPassword', 'sshKeyfilePassword', 'connectionDefinition'];
const fieldsToEncrypt = ['password', 'sshPassword', 'sshKeyfilePassword', 'connectionDefinition', 'httpProxyPassword'];
const additionalFieldsToMask = [
'databaseUrl',
'server',
+1 -1
View File
@@ -30,7 +30,7 @@ export const graphQlDriver: EngineDriver = {
icon: '<svg version="1.1" id="GraphQL_Logo" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px" viewBox="0 0 400 400" enable-background="new 0 0 400 400" xml:space="preserve"><g><g><g><rect x="122" y="-0.4" transform="matrix(-0.866 -0.5 0.5 -0.866 163.3196 363.3136)" fill="#E535AB" width="16.6" height="320.3"/></g></g><g><g><rect x="39.8" y="272.2" fill="#E535AB" width="320.3" height="16.6"/></g></g><g><g><rect x="37.9" y="312.2" transform="matrix(-0.866 -0.5 0.5 -0.866 83.0693 663.3409)" fill="#E535AB" width="185" height="16.6"/></g></g><g><g><rect x="177.1" y="71.1" transform="matrix(-0.866 -0.5 0.5 -0.866 463.3409 283.0693)" fill="#E535AB" width="185" height="16.6"/></g></g><g><g><rect x="122.1" y="-13" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7903 232.1221)" fill="#E535AB" width="16.6" height="185"/></g></g><g><g><rect x="109.6" y="151.6" transform="matrix(-0.5 -0.866 0.866 -0.5 266.0828 473.3766)" fill="#E535AB" width="320.3" height="16.6"/></g></g><g><g><rect x="52.5" y="107.5" fill="#E535AB" width="16.6" height="185"/></g></g><g><g><rect x="330.9" y="107.5" fill="#E535AB" width="16.6" height="185"/></g></g><g><g><rect x="262.4" y="240.1" transform="matrix(-0.5 -0.866 0.866 -0.5 126.7953 714.2875)" fill="#E535AB" width="14.5" height="160.9"/></g></g><path fill="#E535AB" d="M369.5,297.9c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C373.5,259.9,379.2,281.2,369.5,297.9"/><path fill="#E535AB" d="M90.9,137c-9.6,16.7-31,22.4-47.7,12.8c-16.7-9.6-22.4-31-12.8-47.7c9.6-16.7,31-22.4,47.7-12.8 C94.8,99,100.5,120.3,90.9,137"/><path fill="#E535AB" d="M30.5,297.9c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6,16.7,3.9,38-12.8,47.7 C61.4,320.3,40.1,314.6,30.5,297.9"/><path fill="#E535AB" d="M309.1,137c-9.6-16.7-3.9-38,12.8-47.7c16.7-9.6,38-3.9,47.7,12.8c9.6-16.7,3.9-38-12.8,47.7 C340.1,159.4,318.7,153.7,309.1,137"/><path fill="#E535AB" d="M200,395.8c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,380.1,219.3,395.8,200,395.8"/><path fill="#E535AB" d="M200,74c-19.3,0-34.9-15.6-34.9-34.9c0-19.3,15.6-34.9,34.9-34.9c19.3,0,34.9,15.6,34.9,34.9 C234.9,58.4,219.3,74,200,74"/></g></svg>',
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (apiDriverBase.showConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
return false;
},
+4 -4
View File
@@ -24,7 +24,7 @@ function resolveServiceRoot(contextUrl: string | undefined, fallbackUrl: string)
async function loadODataServiceDocument(dbhan: any) {
if (!dbhan?.connection?.apiServerUrl1) {
throw new Error('DBGM-00000 OData endpoint URL is not configured');
throw new Error('DBGM-00330 OData endpoint URL is not configured');
}
const response = await dbhan.axios.get(dbhan.connection.apiServerUrl1, {
@@ -33,11 +33,11 @@ async function loadODataServiceDocument(dbhan: any) {
const document = response?.data;
if (!document || typeof document !== 'object') {
throw new Error('DBGM-00000 OData service document is empty or invalid');
throw new Error('DBGM-00331 OData service document is empty or invalid');
}
if (!document['@odata.context']) {
throw new Error('DBGM-00000 OData service document does not contain @odata.context');
throw new Error('DBGM-00332 OData service document does not contain @odata.context');
}
return document;
@@ -60,7 +60,7 @@ export const oDataDriver: EngineDriver = {
apiServerUrl1Label: 'OData Service URL',
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (apiDriverBase.showConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
return false;
},
+1 -1
View File
@@ -39,7 +39,7 @@ export const openApiDriver: EngineDriver = {
loadApiServerUrl2Options: true,
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (apiDriverBase.showConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
if (field === 'apiServerUrl2') return true;
return false;
+8
View File
@@ -39,4 +39,12 @@ export const apiDriverBase = {
}
return false;
},
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (field === 'httpProxyUrl') return true;
if (field === 'httpProxyUser') return true;
if (field === 'httpProxyPassword') return true;
return false;
},
};
+82 -1
View File
@@ -12,6 +12,13 @@ import isPlainObject from 'lodash/isPlainObject';
import md5 from 'blueimp-md5';
export const MAX_GRID_TEXT_LENGTH = 1000; // maximum length of text in grid cell, longer text is truncated
export const MAX_GRID_BINARY_SIZE = 10000; // maximum binary size (base64 chars or byte count) before showing 'too large' in grid cell
function formatByteSize(bytes: number): string {
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
return `${bytes} B`;
}
export type EditorDataType =
| 'null'
@@ -49,6 +56,26 @@ export function base64ToHex(base64String) {
return '0x' + hexString.toUpperCase();
}
export function base64ToUuid(base64String): string | null {
let binaryString: string;
try {
binaryString = atob(base64String);
} catch {
return null;
}
if (binaryString.length !== 16) {
return null;
}
const hex = Array.from(binaryString, c => c.charCodeAt(0).toString(16).padStart(2, '0')).join('');
return [
hex.slice(0, 8),
hex.slice(8, 12),
hex.slice(12, 16),
hex.slice(16, 20),
hex.slice(20, 32),
].join('-');
}
export function hexToBase64(hexString) {
const binaryString = hexString
.match(/.{1,2}/g)
@@ -57,6 +84,23 @@ export function hexToBase64(hexString) {
return btoa(binaryString);
}
const uuidPattern = '[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}';
const uuidRegex = new RegExp(`^${uuidPattern}$`);
const uuid3WrapperRegex = new RegExp(`^UUID3\\("(${uuidPattern})"\\)$`);
const uuid4WrapperRegex = new RegExp(`^UUID\\("(${uuidPattern})"\\)$`);
export function uuidToBase64(uuid: string): string | null {
if (!uuid || !uuidRegex.test(uuid)) {
return null;
}
const hex = uuid.replace(/-/g, '');
const binaryString = hex
.match(/.{1,2}/g)
.map(byte => String.fromCharCode(parseInt(byte, 16)))
.join('');
return btoa(binaryString);
}
export function parseCellValue(value, editorTypes?: DataEditorTypesBehaviour) {
if (!_isString(value)) return value;
@@ -65,6 +109,20 @@ export function parseCellValue(value, editorTypes?: DataEditorTypesBehaviour) {
}
if (editorTypes?.parseHexAsBuffer) {
const mUuid3 = value.match(uuid3WrapperRegex);
if (mUuid3) {
const base64Uuid3 = uuidToBase64(mUuid3[1]);
if (base64Uuid3 != null) return { $binary: { base64: base64Uuid3, subType: '03' } };
}
const mUuid4 = value.match(uuid4WrapperRegex);
if (mUuid4) {
const base64Uuid4 = uuidToBase64(mUuid4[1]);
if (base64Uuid4 != null) return { $binary: { base64: base64Uuid4, subType: '04' } };
}
if (uuidRegex.test(value)) {
const base64UuidPlain = uuidToBase64(value);
if (base64UuidPlain != null) return { $binary: { base64: base64UuidPlain, subType: '04' } };
}
const mHex = value.match(/^0x([0-9a-fA-F][0-9a-fA-F])+$/);
if (mHex) {
return {
@@ -266,6 +324,21 @@ export function stringifyCellValue(
if (value === false) return { value: 'false', gridStyle: 'valueCellStyle' };
if (value?.$binary?.base64) {
const subType = value.$binary.subType;
if (subType === '03' || subType === '04') {
const uuidStr = base64ToUuid(value.$binary.base64);
if (uuidStr != null) {
if (intent === 'gridCellIntent' || intent === 'exportIntent' || intent === 'clipboardIntent' || intent === 'stringConversionIntent') {
return { value: uuidStr, gridStyle: 'valueCellStyle' };
}
// For editing intents: tag with subType so parseCellValue can round-trip it
const tag = subType === '03' ? 'UUID3' : 'UUID';
return { value: `${tag}("${uuidStr}")`, gridStyle: 'valueCellStyle' };
}
}
if (intent === 'gridCellIntent' && value.$binary.base64.length > MAX_GRID_BINARY_SIZE) {
return { value: `(Field too large, ${formatByteSize(Math.round(value.$binary.base64.length * 3 / 4))})`, gridStyle: 'nullCellStyle' };
}
return {
value: base64ToHex(value.$binary.base64),
gridStyle: 'valueCellStyle',
@@ -354,6 +427,14 @@ export function stringifyCellValue(
}
}
if (value?.type === 'Buffer' && _isArray(value.data)) {
if (intent === 'gridCellIntent') {
return value.data.length > MAX_GRID_BINARY_SIZE
? { value: `(Field too large, ${formatByteSize(value.data.length)})`, gridStyle: 'nullCellStyle' }
: { value: '0x' + arrayToHexString(value.data), gridStyle: 'valueCellStyle' };
}
}
if (_isArray(value)) {
switch (intent) {
case 'gridCellIntent':
@@ -482,7 +563,7 @@ export function shouldOpenMultilineDialog(value) {
}
export function isJsonLikeLongString(value) {
return _isString(value) && value.length > 100 && value.match(/^\s*\{.*\}\s*$|^\s*\[.*\]\s*$/m);
return _isString(value) && value.length > 100 && value.length <= MAX_GRID_BINARY_SIZE && value.match(/^\s*\{.*\}\s*$|^\s*\[.*\]\s*$/m);
}
export function getIconForRedisType(type) {
+5
View File
@@ -59,6 +59,7 @@ export interface QueryOptions {
importSqlDump?: boolean;
range?: { offset: number; limit: number };
readonly?: boolean;
commandTimeout?: number;
}
export interface WriteTableOptions {
@@ -423,6 +424,10 @@ export interface EngineDriver<TClient = any, TDataBase = any> extends FilterBeha
engine: string;
conid?: string;
};
setTransactionIsolationLevel?(dbhan: DatabaseHandle<TClient, TDataBase>, level: string): Promise<void>;
isolationLevels?: string[];
defaultIsolationLevel?: string;
}
export interface DatabaseModification {
+9 -7
View File
@@ -30,10 +30,10 @@
"cross-env": "^7.0.3",
"dbgate-datalib": "^7.0.0-alpha.1",
"dbgate-query-splitter": "^4.12.0",
"dbgate-rest": "^7.0.0-alpha.1",
"dbgate-sqltree": "^7.0.0-alpha.1",
"dbgate-tools": "^7.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"dbgate-rest": "^7.0.0-alpha.1",
"diff": "^5.0.0",
"diff2html": "^3.4.13",
"file-selector": "^0.2.4",
@@ -54,27 +54,29 @@
"svelte": "^4.2.20",
"svelte-check": "^1.0.0",
"svelte-markdown": "^0.1.4",
"svelte-preprocess": "^4.9.5",
"svelte-preprocess": "^6.0.0",
"svelte-select": "^4.4.7",
"tailwindcss": "^4.1.18",
"tslib": "^2.3.1",
"typescript": "^4.4.3",
"typescript": "^5.7.0",
"uuid": "^3.4.0"
},
"dependencies": {
"@langchain/core": "^0.3.72",
"@langchain/langgraph": "^0.4.9",
"@langchain/openai": "^0.6.9",
"@langchain/core": "^1.1.29",
"@langchain/langgraph": "^1.2.0",
"@langchain/openai": "^1.2.11",
"@messageformat/core": "^3.4.0",
"chartjs-plugin-zoom": "^1.2.0",
"date-fns": "^4.1.0",
"debug": "^4.3.4",
"dom-to-image": "^2.6.0",
"dompurify": "^3.3.2",
"flatpickr": "^4.6.13",
"fuzzy": "^0.1.3",
"highlight.js": "^11.11.1",
"interval-operations": "^1.0.7",
"leaflet": "^1.8.0",
"openai": "^5.10.1",
"openai": "^6.24.0",
"wellknown": "^0.5.0",
"xml-formatter": "^3.6.4",
"zod": "^4.1.5"
+11 -1
View File
@@ -54,6 +54,9 @@ export default defineConfig([
cssEntryFileNames: 'bundle.css',
minify: production,
},
// dbgate-types is a TypeScript-only package (no runtime code).
// Mark it external so rolldown doesn't try to bundle it.
external: ['dbgate-types'],
platform: 'browser',
resolve: {
conditionNames: ['svelte', 'browser', 'import'],
@@ -122,7 +125,14 @@ export default defineConfig([
}),
svelte({
preprocess: sveltePreprocess({ sourceMap: !production }),
preprocess: sveltePreprocess({
sourceMap: !production,
typescript: {
compilerOptions: {
verbatimModuleSyntax: true,
},
},
}),
compilerOptions: {
// enable run-time checks when not in production
dev: !production,
@@ -1,4 +1,5 @@
<script>
import _ from 'lodash';
import Link from '../elements/Link.svelte';
import { plusExpandIcon } from '../icons/expandIcons';
File diff suppressed because it is too large Load Diff
@@ -1,4 +1,5 @@
<script lang="ts" context="module">
import { _t } from '../translations';
import { copyTextToClipboard } from '../utility/clipboard';
export const extractKey = props => props.name;
@@ -221,6 +222,18 @@
});
};
const handleGraphQlChat = () => {
openNewTab({
title: 'GraphQL Chat',
icon: 'img ai',
tabComponent: 'GraphQlChatTab',
props: {
conid: connection._id,
database: name,
},
});
};
const handleCompareWithCurrentDb = () => {
openNewTab(
{
@@ -529,11 +542,19 @@ await dbgateApi.executeQuery(${JSON.stringify(
text: _t('database.exportDbModel', { defaultMessage: 'Export DB model' }),
},
isProApp() &&
!isAiDisabled() &&
driver?.databaseEngineTypes?.includes('sql') &&
hasPermission('dbops/chat') && {
onClick: handleDatabaseChat,
text: _t('database.databaseChat', { defaultMessage: 'Database chat' }),
},
isProApp() &&
!isAiDisabled() &&
driver?.databaseEngineTypes?.includes('graphql') &&
hasPermission('dbops/chat') && {
onClick: handleGraphQlChat,
text: _t('database.graphqlChat', { defaultMessage: 'GraphQL chat' }),
},
isSqlOrDoc &&
_.get($currentDatabase, 'connection._id') &&
hasPermission('dbops/model/compare') &&
@@ -668,9 +689,7 @@ await dbgateApi.executeQuery(${JSON.stringify(
import ChooseArchiveFolderModal from '../modals/ChooseArchiveFolderModal.svelte';
import { extractShellConnection } from '../impexp/createImpExpScript';
import { getNumberIcon } from '../icons/FontIcon.svelte';
import { getDatabaseClickActionSetting } from '../settings/settingsTools';
import { _t } from '../translations';
import { getDatabaseClickActionSetting, isAiDisabled } from '../settings/settingsTools';
export let data;
export let passProps;
export let passExtInfo = undefined;
@@ -1,6 +1,6 @@
<script lang="ts" context="module">
import { copyTextToClipboard } from '../utility/clipboard';
import { _t, _tval, DefferedTranslationResult } from '../translations';
import { _t, _tval, type DefferedTranslationResult } from '../translations';
import sqlFormatter from 'sql-formatter';
export const extractKey = ({ schemaName, pureName }) => (schemaName ? `${schemaName}.${pureName}` : pureName);
@@ -1,4 +1,5 @@
<script context="module">
import { __t } from '../translations';
registerCommand({
id: 'commandPalette.show',
category: __t('command.commandPalette', { defaultMessage: 'Command palette' }),
@@ -87,7 +88,7 @@
import { getLocalStorage } from '../utility/storageCache';
import registerCommand from './registerCommand';
import { formatKeyText, switchCurrentDatabase } from '../utility/common';
import { _tval, __t, _t } from '../translations';
import { _tval, _t } from '../translations';
import { getDriverIcon } from '../utility/driverIcons';
import { currentThemeType } from '../plugins/themes';
+27 -2
View File
@@ -46,7 +46,7 @@ import { isProApp } from '../utility/proTools';
import { openWebLink } from '../utility/simpleTools';
import { _t } from '../translations';
import ExportImportConnectionsModal from '../modals/ExportImportConnectionsModal.svelte';
import { getBoolSettingsValue } from '../settings/settingsTools';
import { getBoolSettingsValue, isAiDisabled } from '../settings/settingsTools';
import { __t } from '../translations';
// function themeCommand(theme: ThemeDefinition) {
@@ -753,7 +753,8 @@ if (isProApp()) {
testEnabled: () =>
getCurrentDatabase() != null &&
findEngineDriver(getCurrentDatabase()?.connection, getExtensions())?.databaseEngineTypes?.includes('sql') &&
hasPermission('dbops/chat'),
hasPermission('dbops/chat') &&
!isAiDisabled(),
onClick: () => {
openNewTab({
title: 'Chat',
@@ -766,6 +767,30 @@ if (isProApp()) {
});
},
});
registerCommand({
id: 'graphql.chat',
category: __t('command.database', { defaultMessage: 'Database' }),
name: __t('command.graphql.chat', { defaultMessage: 'GraphQL chat' }),
toolbar: true,
icon: 'icon ai',
testEnabled: () =>
getCurrentDatabase() != null &&
findEngineDriver(getCurrentDatabase()?.connection, getExtensions())?.databaseEngineTypes?.includes('graphql') &&
hasPermission('dbops/chat') &&
!isAiDisabled(),
onClick: () => {
openNewTab({
title: 'GraphQL Chat',
icon: 'img ai',
tabComponent: 'GraphQlChatTab',
props: {
conid: getCurrentDatabase()?.connection?._id,
database: getCurrentDatabase()?.name,
},
});
},
});
}
if (hasPermission('settings/change')) {
@@ -1,4 +1,5 @@
<script context="module" lang="ts">
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('CollectionDataGridCore');
registerCommand({
@@ -103,17 +104,37 @@
async function loadRowCount(props) {
const { conid, database } = props;
const response = await apiCall('database-connections/collection-data', {
conid,
database,
options: {
pureName: props.pureName,
countDocuments: true,
condition: buildConditionForGrid(props),
},
});
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Row count query timed out')), 3000)
);
return response.count;
try {
const response = await Promise.race([
apiCall('database-connections/collection-data', {
conid,
database,
commandTimeout: 3000,
options: {
pureName: props.pureName,
countDocuments: true,
condition: buildConditionForGrid(props),
},
}),
timeoutPromise,
]);
if (response && typeof response === 'object' && (response as any).errorMessage) {
return { errorMessage: (response as any).errorMessage };
}
if (response && typeof response === 'object' && typeof (response as any).count === 'number') {
return (response as any).count;
}
return { errorMessage: 'Error loading row count' };
} catch (err) {
return { errorMessage: err.message || 'Error loading row count' };
}
}
</script>
@@ -140,8 +161,6 @@
import LoadingDataGridCore from './LoadingDataGridCore.svelte';
import { mongoFilterBehaviour, standardFilterBehaviours } from 'dbgate-tools';
import { openImportExportTab } from '../utility/importExportTools';
import { __t } from '../translations';
export let conid;
export let display;
export let database;
+2 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts" context="module">
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('DataGrid');
registerCommand({
@@ -79,7 +80,7 @@
import registerCommand from '../commands/registerCommand';
import { registerMenu } from '../utility/contextMenu';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import { __t, _t } from '../translations';
import { _t } from '../translations';
import { isProApp } from '../utility/proTools';
import CellDataWidget from '../widgets/CellDataWidget.svelte';
import { useSettings } from '../utility/metadataLoaders';
+70 -1
View File
@@ -1,4 +1,5 @@
<script lang="ts" context="module">
import { __t } from '../translations';
const getCurrentDataGrid = () => getActiveComponent('DataGridCore');
registerCommand({
@@ -25,6 +26,18 @@
onClick: () => getCurrentDataGrid().deepRefresh(),
});
registerCommand({
id: 'dataGrid.fetchAll',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.datagrid.fetchAll', { defaultMessage: 'Fetch all rows' }),
toolbarName: __t('command.datagrid.fetchAll.toolbar', { defaultMessage: 'Fetch all' }),
icon: 'icon download',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentDataGrid()?.canFetchAll(),
onClick: () => getCurrentDataGrid().fetchAll(),
});
registerCommand({
id: 'dataGrid.revertRowChanges',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
@@ -431,6 +444,7 @@
import CollapseButton from './CollapseButton.svelte';
import GenerateSqlFromDataModal from '../modals/GenerateSqlFromDataModal.svelte';
import { showModal } from '../modals/modalTools';
import FetchAllConfirmModal from '../modals/FetchAllConfirmModal.svelte';
import StatusBarTabItem from '../widgets/StatusBarTabItem.svelte';
import { findCommand } from '../commands/runCommand';
import { openJsonDocument } from '../tabs/JsonTab.svelte';
@@ -446,13 +460,14 @@
import { openJsonLinesData } from '../utility/openJsonLinesData';
import contextMenuActivator from '../utility/contextMenuActivator';
import InputTextModal from '../modals/InputTextModal.svelte';
import { __t, _t, _tval } from '../translations';
import { _t, _tval } from '../translations';
import { isProApp } from '../utility/proTools';
import SaveArchiveModal from '../modals/SaveArchiveModal.svelte';
import hasPermission from '../utility/hasPermission';
import macros from '../macro/macros';
export let onLoadNextData = undefined;
export let onFetchAllRows = undefined;
export let grider = undefined;
export let display: GridDisplay = undefined;
export let conid = undefined;
@@ -460,6 +475,8 @@
export let frameSelection = undefined;
export let isLoading = false;
export let allRowCount = undefined;
export let allRowCountError = undefined;
export let onReloadRowCount = undefined;
export let onReferenceSourceChanged = undefined;
export let onPublishedCellsChanged = undefined;
export let onReferenceClick = undefined;
@@ -470,6 +487,9 @@
export let errorMessage = undefined;
export let pureName = undefined;
export let schemaName = undefined;
export let isFetchingAll = false;
export let isFetchingFromDb = false;
export let fetchAllLoadedCount = 0;
export let allowDefineVirtualReferences = false;
export let formatterFunction;
export let passAllRows = null;
@@ -644,6 +664,21 @@
return canRefresh() && !!conid && !!database;
}
export function canFetchAll() {
return !!onFetchAllRows && !isLoadedAll && !isFetchingAll && !isLoading;
}
export function fetchAll() {
if (!canFetchAll()) return;
const settings = $settingsValue || {};
if (settings['dataGrid.skipFetchAllConfirm']) {
onFetchAllRows();
} else {
showModal(FetchAllConfirmModal, { onConfirm: () => onFetchAllRows() });
}
}
export async function deepRefresh() {
callUnsubscribeDbRefresh();
await apiCall('database-connections/sync-model', { conid, database });
@@ -1974,6 +2009,7 @@
registerMenu(
{ command: 'dataGrid.refresh' },
{ command: 'dataGrid.fetchAll', hideDisabled: true },
{ placeTag: 'copy' },
{
text: _t('datagrid.copyAdvanced', { defaultMessage: 'Copy advanced' }),
@@ -2399,14 +2435,38 @@
<div class="row-count-label">
{_t('datagrid.rows', { defaultMessage: 'Rows' })}: {allRowCount.toLocaleString()}
</div>
{:else if allRowCountError && multipleGridsOnTab}
<!-- svelte-ignore a11y-click-events-have-key-events -->
<div class="row-count-label row-count-error" title={allRowCountError} on:click={onReloadRowCount}>
{_t('datagrid.rows', { defaultMessage: 'Rows' })}: {_t('datagrid.rowCountMany', { defaultMessage: 'Many' })}
</div>
{/if}
{#if isLoading}
<LoadingInfo wrapper message="Loading data" />
{/if}
{#if isFetchingAll}
<LoadingInfo
wrapper
message={isFetchingFromDb
? _t('datagrid.fetchAll.progressDb', { defaultMessage: 'Fetching data from database...' })
: _t('datagrid.fetchAll.progress', {
defaultMessage: 'Fetching all rows... {count} loaded',
values: { count: fetchAllLoadedCount.toLocaleString() },
})}
/>
{/if}
{#if !tabControlHiddenTab && !multipleGridsOnTab && allRowCount != null}
<StatusBarTabItem text={`${_t('datagrid.rows', { defaultMessage: 'Rows' })}: ${allRowCount.toLocaleString()}`} />
{:else if !tabControlHiddenTab && !multipleGridsOnTab && allRowCountError}
<StatusBarTabItem
text={`${_t('datagrid.rows', { defaultMessage: 'Rows' })}: ${_t('datagrid.rowCountMany', { defaultMessage: 'Many' })}`}
title={allRowCountError}
clickable
onClick={onReloadRowCount}
/>
{/if}
</div>
{/if}
@@ -2471,6 +2531,15 @@
opacity: 1;
}
.row-count-error {
cursor: pointer;
color: var(--theme-font-3);
}
.row-count-error:hover {
text-decoration: underline;
}
.selection-menu {
position: absolute;
background-color: var(--theme-datagrid-corner-label-background);
@@ -1,4 +1,5 @@
<script context="module" lang="ts">
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('JslDataGridCore');
registerCommand({
@@ -51,13 +52,13 @@
import createActivator, { getActiveComponent } from '../utility/createActivator';
import createQuickExportMenu from '../utility/createQuickExportMenu';
import { exportQuickExportFile } from '../utility/exportFileTools';
import { extractShellConnectionHostable, extractShellHostConnection } from '../impexp/createImpExpScript';
import { getConnectionInfo } from '../utility/metadataLoaders';
import useEffect from '../utility/useEffect';
import ChangeSetGrider from './ChangeSetGrider';
import LoadingDataGridCore from './LoadingDataGridCore.svelte';
import { openImportExportTab } from '../utility/importExportTools';
import { __t } from '../translations';
export let jslid;
export let display;
export let formatterFunction;
@@ -68,8 +69,22 @@
export let macroPreview;
export let macroValues;
export let onPublishedCellsChanged;
export let exportQuery = null;
export let exportConid = null;
export let exportDatabase = null;
export const activator = createActivator('JslDataGridCore', false);
function isReadOnlyQuery(sql) {
if (!sql) return false;
const trimmed = sql
.trim()
.replace(/^\/\*[\s\S]*?\*\//g, '')
.trim();
return /^(SELECT|WITH)\b/i.test(trimmed);
}
$: safeExportQuery = exportQuery && isReadOnlyQuery(exportQuery) ? exportQuery : null;
export let setLoadedRows;
let publishedCells = [];
@@ -136,7 +151,7 @@
// $: grider = new RowsArrayGrider(loadedRows);
export function exportGrid() {
export async function exportGrid() {
const initialValues = {} as any;
const archiveMatch = jslid.match(/^archive:\/\/([^/]+)\/(.*)$/);
if (archiveMatch) {
@@ -144,6 +159,14 @@
initialValues.sourceArchiveFolder = archiveMatch[1];
initialValues.sourceList = [archiveMatch[2]];
initialValues[`columns_${archiveMatch[2]}`] = display.getExportColumnMap();
} else if (safeExportQuery && exportConid) {
initialValues.sourceStorageType = 'query';
initialValues.sourceConnectionId = exportConid;
initialValues.sourceDatabaseName = exportDatabase;
initialValues.sourceQuery = safeExportQuery;
initialValues.sourceQueryType = 'native';
initialValues.sourceList = ['query-data'];
initialValues[`columns_query-data`] = display.getExportColumnMap();
} else {
initialValues.sourceStorageType = 'jsldata';
initialValues.sourceJslId = jslid;
@@ -169,6 +192,22 @@
fmt,
display.getExportColumnMap()
);
} else if (safeExportQuery && exportConid) {
const coninfo = await getConnectionInfo({ conid: exportConid });
exportQuickExportFile(
'Query',
{
functionName: 'queryReader',
props: {
...extractShellConnectionHostable(coninfo, exportDatabase),
queryType: 'native',
query: safeExportQuery,
},
hostConnection: extractShellHostConnection(coninfo, exportDatabase),
},
fmt,
display.getExportColumnMap()
);
} else {
exportQuickExportFile(
'Query',
@@ -1,14 +1,18 @@
<script lang="ts">
import { getIntSettingsValue } from '../settings/settingsTools';
import { onDestroy } from 'svelte';
import createRef from '../utility/createRef';
import { useSettings } from '../utility/metadataLoaders';
import { fetchAll, type FetchAllHandle } from '../utility/fetchAll';
import { apiCall } from '../utility/api';
import DataGridCore from './DataGridCore.svelte';
export let loadDataPage;
export let dataPageAvailable;
export let loadRowCount;
export let startFetchAll = null;
export let grider;
export let display;
export let masterLoadedTime = undefined;
@@ -25,9 +29,16 @@
let isLoadedAll = false;
let loadedTime = new Date().getTime();
let allRowCount = null;
let allRowCountError = null;
let errorMessage = null;
let domGrid;
let isFetchingAll = false;
let isFetchingFromDb = false;
let fetchAllLoadedCount = 0;
let fetchAllHandle: FetchAllHandle | null = null;
let readerJslid: string | null = null;
const loadNextDataRef = createRef(false);
const loadedTimeRef = createRef(null);
@@ -37,8 +48,14 @@
}
const handleLoadRowCount = async () => {
const rowCount = await loadRowCount($$props);
allRowCount = rowCount;
const result = await loadRowCount($$props);
if (result != null && typeof result === 'object' && result.errorMessage) {
allRowCount = null;
allRowCountError = result.errorMessage;
} else {
allRowCount = result;
allRowCountError = null;
}
};
async function loadNextData() {
@@ -89,11 +106,161 @@
// console.log('LOADED', nextRows, loadedRows);
}
async function fetchAllRows() {
if (isFetchingAll || isLoadedAll) return;
const jslid = ($$props as any).jslid;
if (jslid) {
// Already have a JSONL file (e.g. query tab) — read directly
fetchAllViaJslid(jslid);
} else if (startFetchAll) {
// SQL/table grid: execute full query → stream to JSONL → read from it
fetchAllViaReader();
} else {
fetchAllRowsLegacy();
}
}
function stopReader() {
if (readerJslid) {
apiCall('sessions/stop-loading-reader', { jslid: readerJslid });
readerJslid = null;
}
}
async function fetchAllViaReader() {
isFetchingAll = true;
isFetchingFromDb = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
// Token guards against a reload/destroy that happens while we await startFetchAll.
// loadedTimeRef is already updated by reload(), so we reuse it as our token.
const token = loadedTime;
let jslid;
try {
jslid = await startFetchAll($$props);
} catch (err) {
if (loadedTime !== token) return; // reload() already reset state
errorMessage = err?.message ?? 'Failed to start data reader';
isFetchingAll = false;
isFetchingFromDb = false;
return;
}
// If reload()/onDestroy ran while we were awaiting, discard the result and
// immediately stop the reader that was just started on the server.
if (loadedTime !== token) {
if (jslid) apiCall('sessions/stop-loading-reader', { jslid });
return;
}
if (!jslid) {
errorMessage = 'Failed to start data reader';
isFetchingAll = false;
isFetchingFromDb = false;
return;
}
readerJslid = jslid;
fetchAllViaJslid(jslid);
}
function fetchAllViaJslid(jslid: string) {
if (!isFetchingAll) {
isFetchingAll = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
}
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
const buffer: any[] = [];
const jslLoadDataPage = async (offset: number, limit: number) => {
return apiCall('jsldata/get-rows', { jslid, offset, limit });
};
fetchAllHandle = fetchAll(
jslid,
jslLoadDataPage,
{
onPage(rows) {
if (rows.length > 0) isFetchingFromDb = false;
const processed = preprocessLoadedRow ? rows.map(preprocessLoadedRow) : rows;
buffer.push(...processed);
fetchAllLoadedCount = buffer.length;
},
onFinished() {
loadedRows = buffer;
isLoadedAll = true;
isFetchingAll = false;
isFetchingFromDb = false;
fetchAllHandle = null;
readerJslid = null;
if (allRowCount == null && !isRawMode) handleLoadRowCount();
},
onError(msg) {
errorMessage = msg;
isFetchingAll = false;
isFetchingFromDb = false;
fetchAllHandle = null;
stopReader();
},
},
pageSize
);
}
async function fetchAllRowsLegacy() {
isFetchingAll = true;
fetchAllLoadedCount = loadedRows.length;
errorMessage = null;
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
const fetchStart = new Date().getTime();
loadedTimeRef.set(fetchStart);
// Accumulate into a local buffer to avoid O(n²) full-array copies each iteration.
const buffer = [...loadedRows];
try {
while (!isLoadedAll) {
const nextRows = await loadDataPage($$props, buffer.length, pageSize);
if (loadedTimeRef.get() !== fetchStart) {
// a reload was triggered; abort without overwriting loadedRows with stale data
return;
}
if (nextRows.errorMessage) {
errorMessage = nextRows.errorMessage;
break;
}
if (nextRows.length === 0) {
isLoadedAll = true;
break;
}
const processed = preprocessLoadedRow ? nextRows.map(preprocessLoadedRow) : nextRows;
buffer.push(...processed);
fetchAllLoadedCount = buffer.length;
}
// Single assignment triggers Svelte reactivity once for all accumulated rows.
loadedRows = buffer;
if (allRowCount == null && !isRawMode) handleLoadRowCount();
} finally {
isFetchingAll = false;
}
}
// $: griderProps = { ...$$props, sourceRows: loadProps.loadedRows };
// $: grider = griderFactory(griderProps);
function handleLoadNextData() {
if (!isLoadedAll && !errorMessage && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
if (!isLoadedAll && !errorMessage && !isFetchingAll && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
if (dataPageAvailable($$props)) {
// If not, callbacks to load missing metadata are dispatched
loadNextData();
@@ -102,13 +269,23 @@
}
function reload() {
if (fetchAllHandle) {
fetchAllHandle.cancel();
fetchAllHandle = null;
}
stopReader();
isFetchingFromDb = false;
allRowCount = null;
allRowCountError = null;
isLoading = false;
isFetchingAll = false;
fetchAllLoadedCount = 0;
loadedRows = [];
isLoadedAll = false;
loadedTime = new Date().getTime();
errorMessage = null;
loadNextDataRef.set(false);
loadedTimeRef.set(null);
// loadNextDataToken = 0;
}
@@ -122,6 +299,13 @@
}
}
onDestroy(() => {
if (fetchAllHandle) {
fetchAllHandle.cancel();
}
stopReader();
});
$: if (setLoadedRows) setLoadedRows(loadedRows);
</script>
@@ -129,9 +313,15 @@
{...$$props}
bind:this={domGrid}
onLoadNextData={handleLoadNextData}
onFetchAllRows={fetchAllRows}
{errorMessage}
{isLoading}
{isFetchingAll}
{isFetchingFromDb}
{fetchAllLoadedCount}
allRowCount={rowCountLoaded || allRowCount}
{allRowCountError}
onReloadRowCount={handleLoadRowCount}
{isLoadedAll}
{loadedTime}
{grider}
@@ -1,11 +1,14 @@
<script context="module" lang="ts">
import { __t, _t } from '../translations'
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import hasPermission from '../utility/hasPermission';
import { __t, _t } from '../translations';
const getCurrentEditor = () => getActiveComponent('SqlDataGridCore');
registerCommand({
id: 'sqlDataGrid.openQuery',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('command.openQuery', { defaultMessage : 'Open query' }),
name: __t('command.openQuery', { defaultMessage: 'Open query' }),
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/query'),
onClick: () => getCurrentEditor().openQuery(),
});
@@ -13,7 +16,7 @@
registerCommand({
id: 'sqlDataGrid.export',
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
name: __t('common.export', { defaultMessage : 'Export' }),
name: __t('common.export', { defaultMessage: 'Export' }),
icon: 'icon export',
keyText: 'CtrlOrCommand+E',
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/export'),
@@ -24,8 +27,6 @@
<script lang="ts">
import _ from 'lodash';
import { registerQuickExportHandler } from '../buttons/ToolStripExportButton.svelte';
import registerCommand from '../commands/registerCommand';
import {
extractShellConnection,
extractShellConnectionHostable,
@@ -34,7 +35,7 @@
import { apiCall } from '../utility/api';
import { registerMenu } from '../utility/contextMenu';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import createActivator from '../utility/createActivator';
import createQuickExportMenu from '../utility/createQuickExportMenu';
import { exportQuickExportFile } from '../utility/exportFileTools';
import { getConnectionInfo } from '../utility/metadataLoaders';
@@ -42,7 +43,6 @@
import ChangeSetGrider from './ChangeSetGrider';
import LoadingDataGridCore from './LoadingDataGridCore.svelte';
import hasPermission from '../utility/hasPermission';
import { openImportExportTab } from '../utility/importExportTools';
import { getIntSettingsValue } from '../settings/settingsTools';
import OverlayDiffGrider from './OverlayDiffGrider';
@@ -211,13 +211,40 @@
const select = display.getCountQuery();
const response = await apiCall('database-connections/sql-select', {
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Row count query timed out')), 3000)
);
try {
const response = await Promise.race([
apiCall('database-connections/sql-select', {
conid,
database,
select,
commandTimeout: 3000,
}),
timeoutPromise,
]);
if (response.errorMessage) return { errorMessage: response.errorMessage };
return parseInt(response.rows[0].count);
} catch (err) {
return { errorMessage: err.message || 'Error loading row count' };
}
}
async function startFetchAll(props) {
const { display, conid, database } = props;
const sql = display.getExportQuery();
if (!sql) return null;
const resp = await apiCall('sessions/execute-reader', {
conid,
database,
select,
sql,
});
return parseInt(response.rows[0].count);
if (!resp || resp.errorMessage) return null;
return resp.jslid;
}
</script>
@@ -226,6 +253,7 @@
{loadDataPage}
{dataPageAvailable}
{loadRowCount}
{startFetchAll}
setLoadedRows={handleSetLoadedRows}
onPublishedCellsChanged={value => {
publishedCells = value;
+47 -5
View File
@@ -1,4 +1,7 @@
<script lang="ts" context="module">
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('Designer');
registerCommand({
@@ -16,8 +19,8 @@
registerCommand({
id: 'diagram.export',
category: __t('command.designer', { defaultMessage: 'Designer' }),
toolbarName: __t('command.designer.exportDiagram', { defaultMessage: 'Export diagram' }),
name: __t('command.designer.exportDiagram', { defaultMessage: 'Export diagram' }),
toolbarName: __t('command.designer.exportDiagram', { defaultMessage: 'Export diagram as HTML' }),
name: __t('command.designer.exportDiagram', { defaultMessage: 'Export diagram as HTML' }),
icon: 'icon report',
toolbar: true,
isRelatedToTab: true,
@@ -25,6 +28,17 @@
testEnabled: () => getCurrentEditor()?.canExport(),
});
registerCommand({
id: 'diagram.exportPng',
category: __t('command.designer', { defaultMessage: 'Designer' }),
name: __t('command.designer.exportDiagramPng', { defaultMessage: 'Export diagram as PNG' }),
icon: 'icon report',
toolbar: true,
isRelatedToTab: true,
onClick: () => getCurrentEditor().exportDiagramPng(),
testEnabled: () => getCurrentEditor()?.canExport(),
});
registerCommand({
id: 'diagram.deleteSelectedTables',
category: __t('command.designer', { defaultMessage: 'Designer' }),
@@ -49,11 +63,11 @@
import { tick } from 'svelte';
import contextMenu from '../utility/contextMenu';
import stableStringify from 'json-stable-stringify';
import registerCommand from '../commands/registerCommand';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import createActivator from '../utility/createActivator';
import { GraphDefinition, GraphLayout } from './GraphLayout';
import { saveFileToDisk } from '../utility/exportFileTools';
import { apiCall } from '../utility/api';
import domtoimage from 'dom-to-image';
import moveDrag from '../utility/moveDrag';
import { rectanglesHaveIntersection } from './designerMath';
import { showModal } from '../modals/modalTools';
@@ -67,7 +81,7 @@
import { isProApp } from '../utility/proTools';
import dragScroll from '../utility/dragScroll';
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import { __t, _t } from '../translations';
import { _t } from '../translations';
export let value;
export let onChange;
@@ -828,6 +842,34 @@
});
}
export async function exportDiagramPng() {
const rects = _.values(domTables).map((x: any) => x.getRect());
const contentWidth = rects.length > 0 ? _.max(rects.map((x: any) => x.right)) + 50 : canvasWidth;
const contentHeight = rects.length > 0 ? _.max(rects.map((x: any) => x.bottom)) + 50 : canvasHeight;
const scale = 2;
const backgroundColor = getComputedStyle(domWrapper).getPropertyValue('--theme-designer-background');
const pngBase64 = await domtoimage.toPng(domCanvas, {
width: contentWidth * scale,
height: contentHeight * scale,
style: {
transform: `scale(${scale})`,
transformOrigin: 'top left',
width: contentWidth + 'px',
height: contentHeight + 'px',
backgroundColor,
},
});
saveFileToDisk(
async filePath => {
await apiCall('files/export-diagram-png', {
filePath,
pngBase64,
});
},
{ formatLabel: 'PNG image', formatExtension: 'png', defaultFileName: 'diagram.png' }
);
}
const changeStyleFunc = (name, value) => () => {
callChange(current => {
return {
@@ -1,170 +1,169 @@
<script context="module">
export function computeSplitterSize(initialValue, clientSize, customRatio, initialSizeRight) {
if (customRatio != null) {
return clientSize * customRatio;
}
if (initialSizeRight) {
return clientSize - initialSizeRight;
}
if (_.isString(initialValue) && initialValue.startsWith('~') && initialValue.endsWith('px'))
return clientSize - parseInt(initialValue.slice(1, -2));
if (_.isString(initialValue) && initialValue.endsWith('px')) return parseInt(initialValue.slice(0, -2));
if (_.isString(initialValue) && initialValue.endsWith('%'))
return (clientSize * parseFloat(initialValue.slice(0, -1))) / 100;
return clientSize / 2;
}
</script>
<script>
import _ from 'lodash';
import FontIcon from '../icons/FontIcon.svelte';
import splitterDrag from '../utility/splitterDrag';
export let isSplitter = true;
export let initialValue = undefined;
export let initialSizeRight = undefined;
export let hideFirst = false;
export let allowCollapseChild1 = false;
export let allowCollapseChild2 = false;
let collapsed1 = false;
let collapsed2 = false;
export let size = 0;
export let onChangeSize = null;
let clientWidth;
let customRatio = null;
$: size = computeSplitterSize(initialValue, clientWidth, customRatio, initialSizeRight);
$: if (onChangeSize) onChangeSize(size, clientWidth - size);
</script>
<div class="container" bind:clientWidth>
{#if !hideFirst}
<div
class="child1"
style={isSplitter
? collapsed1
? 'display:none'
: collapsed2
? 'flex:1'
: `width:${size}px; min-width:${size}px; max-width:${size}px}`
: `flex:1`}
>
<slot name="1" />
</div>
{/if}
{#if isSplitter}
{#if !hideFirst}
<div
class="horizontal-split-handle"
style={collapsed1 || collapsed2 ? 'display:none' : ''}
use:splitterDrag={'clientX'}
on:resizeSplitter={e => {
size += e.detail;
if (clientWidth > 0) customRatio = size / clientWidth;
}}
/>
{/if}
<div
class={collapsed1 ? 'child1' : 'child2'}
style={collapsed2 ? 'display:none' : collapsed1 ? 'flex:1' : 'child2'}
>
<slot name="2" />
</div>
{/if}
{#if allowCollapseChild1 && !collapsed2 && isSplitter}
{#if collapsed1}
<div
class="collapse"
style={`left: 0px`}
on:click={() => {
collapsed1 = false;
}}
>
<FontIcon icon="icon chevron-double-right" />
</div>
{:else}
<div
class="collapse"
style={`left: ${size - 16}px`}
on:click={() => {
collapsed1 = true;
}}
>
<FontIcon icon="icon chevron-double-left" />
</div>
{/if}
{/if}
{#if allowCollapseChild2 && !collapsed1 && isSplitter}
{#if collapsed2}
<div
class="collapse"
style={`right: 0px`}
on:click={() => {
collapsed2 = false;
}}
>
<FontIcon icon="icon chevron-double-left" />
</div>
{:else}
<div
class="collapse"
style={`left: ${size}px`}
on:click={() => {
collapsed2 = true;
}}
>
<FontIcon icon="icon chevron-double-right" />
</div>
{/if}
{/if}
</div>
<style>
.container {
flex: 1;
display: flex;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.child1 {
display: flex;
position: relative;
overflow: hidden;
}
.child2 {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.collapse {
position: absolute;
bottom: 16px;
height: 40px;
width: 16px;
background: var(--theme-splitter-button-background);
display: flex;
flex-direction: column;
justify-content: center;
z-index: 100;
}
.collapse:hover {
color: var(--theme-splitter-button-foreground);
background: var(--theme-splitter-button-background-active);
cursor: pointer;
}
</style>
export function computeSplitterSize(initialValue, clientSize, customRatio, initialSizeRight) {
if (customRatio != null) {
return clientSize * customRatio;
}
if (initialSizeRight) {
return clientSize - initialSizeRight;
}
if (_.isString(initialValue) && initialValue.startsWith('~') && initialValue.endsWith('px'))
return clientSize - parseInt(initialValue.slice(1, -2));
if (_.isString(initialValue) && initialValue.endsWith('px')) return parseInt(initialValue.slice(0, -2));
if (_.isString(initialValue) && initialValue.endsWith('%'))
return (clientSize * parseFloat(initialValue.slice(0, -1))) / 100;
return clientSize / 2;
}
</script>
<script> import FontIcon from '../icons/FontIcon.svelte';
import splitterDrag from '../utility/splitterDrag';
export let isSplitter = true;
export let initialValue = undefined;
export let initialSizeRight = undefined;
export let hideFirst = false;
export let allowCollapseChild1 = false;
export let allowCollapseChild2 = false;
let collapsed1 = false;
let collapsed2 = false;
export let size = 0;
export let onChangeSize = null;
let clientWidth;
let customRatio = null;
$: size = computeSplitterSize(initialValue, clientWidth, customRatio, initialSizeRight);
$: if (onChangeSize) onChangeSize(size, clientWidth - size);
</script>
<div class="container" bind:clientWidth>
{#if !hideFirst}
<div
class="child1"
style={isSplitter
? collapsed1
? 'display:none'
: collapsed2
? 'flex:1'
: `width:${size}px; min-width:${size}px; max-width:${size}px}`
: `flex:1`}
>
<slot name="1" />
</div>
{/if}
{#if isSplitter}
{#if !hideFirst}
<div
class="horizontal-split-handle"
style={collapsed1 || collapsed2 ? 'display:none' : ''}
use:splitterDrag={'clientX'}
on:resizeSplitter={e => {
size += e.detail;
if (clientWidth > 0) customRatio = size / clientWidth;
}}
/>
{/if}
<div
class={collapsed1 ? 'child1' : 'child2'}
style={collapsed2 ? 'display:none' : collapsed1 ? 'flex:1' : 'child2'}
>
<slot name="2" />
</div>
{/if}
{#if allowCollapseChild1 && !collapsed2 && isSplitter}
{#if collapsed1}
<div
class="collapse"
style={`left: 0px`}
on:click={() => {
collapsed1 = false;
}}
>
<FontIcon icon="icon chevron-double-right" />
</div>
{:else}
<div
class="collapse"
style={`left: ${size - 16}px`}
on:click={() => {
collapsed1 = true;
}}
>
<FontIcon icon="icon chevron-double-left" />
</div>
{/if}
{/if}
{#if allowCollapseChild2 && !collapsed1 && isSplitter}
{#if collapsed2}
<div
class="collapse"
style={`right: 0px`}
on:click={() => {
collapsed2 = false;
}}
>
<FontIcon icon="icon chevron-double-left" />
</div>
{:else}
<div
class="collapse"
style={`left: ${size}px`}
on:click={() => {
collapsed2 = true;
}}
>
<FontIcon icon="icon chevron-double-right" />
</div>
{/if}
{/if}
</div>
<style>
.container {
flex: 1;
display: flex;
position: absolute;
left: 0;
right: 0;
top: 0;
bottom: 0;
}
.child1 {
display: flex;
position: relative;
overflow: hidden;
}
.child2 {
flex: 1;
display: flex;
position: relative;
overflow: hidden;
}
.collapse {
position: absolute;
bottom: 16px;
height: 40px;
width: 16px;
background: var(--theme-splitter-button-background);
display: flex;
flex-direction: column;
justify-content: center;
z-index: 100;
}
.collapse:hover {
color: var(--theme-splitter-button-foreground);
background: var(--theme-splitter-button-background-active);
cursor: pointer;
}
</style>
@@ -1,4 +1,7 @@
<script lang="ts" context="module">
import _ from 'lodash';
import { isWktGeometry, stringifyCellValue } from 'dbgate-tools';
import wellknown from 'wellknown';
const LAT_PRIORITY_PATTERNS = [
/^lat$/i,
/^latitude$/i,
@@ -141,10 +144,7 @@
</script>
<script lang="ts">
import _ from 'lodash';
import 'leaflet/dist/leaflet.css';
import wellknown from 'wellknown';
import { isWktGeometry, stringifyCellValue } from 'dbgate-tools';
import MapView from './MapView.svelte';
export let selection;
@@ -1,5 +1,5 @@
<script lang="ts">
import SimpleFilesInput, { ProcessedFile } from '../impexp/SimpleFilesInput.svelte';
import SimpleFilesInput, { type ProcessedFile } from '../impexp/SimpleFilesInput.svelte';
import { parseFileAsString } from '../utility/parseFileAsString';
import { getFormContext } from './FormProviderCore.svelte';
import { createEventDispatcher } from 'svelte';
@@ -1,5 +1,5 @@
<script lang="ts">
import SimpleFilesInput, { ProcessedFile } from '../impexp/SimpleFilesInput.svelte';
import SimpleFilesInput, { type ProcessedFile } from '../impexp/SimpleFilesInput.svelte';
import { FileParseResult, parseFileAsJson } from '../utility/parseFileAsJson';
import { getFormContext } from './FormProviderCore.svelte';
import { createEventDispatcher } from 'svelte';
+5 -7
View File
@@ -24,7 +24,7 @@
{#if isNative}
<select
value={options.find(x => x.value == value) ? value : defaultValue}
class="{selectClass}"
class={selectClass}
{...$$restProps}
on:change={e => {
dispatch('change', e.target['value']);
@@ -47,7 +47,7 @@
{...$$restProps}
items={options ?? []}
value={isMulti
? _.compact((value && Array.isArray(value)) ? value.map(item => options?.find(x => x.value == item)) : [])
? _.compact(value && Array.isArray(value) ? value.map(item => options?.find(x => x.value == item)) : [])
: (options?.find(x => x.value == value) ?? null)}
on:select={e => {
if (isMulti) {
@@ -69,7 +69,6 @@
</div>
{/if}
<style>
.select {
--border: var(--theme-input-border);
@@ -78,10 +77,10 @@
--background: var(--theme-input-background);
--borderHoverColor: var(--theme-input-border-hover-color);
--borderFocusColor: var(--theme-input-border-focus-color);
--listBackground: var(--theme-input-list-background);
--listBackground: var(--theme-input-background);
--itemActiveBackground: var(--theme-input-item-active-background);
--itemIsActiveBG: var(--theme-input-item-active-background);
--itemHoverBG: var(--theme-input-item-hover-background);
--itemHoverBG: var(--theme-input-multi-clear-hover);
--itemColor: var(--theme-input-item-foreground);
--listEmptyColor: var(--theme-input-background);
--height: 40px;
@@ -95,9 +94,8 @@
--multiClearHoverFill: var(--theme-input-multi-clear-foreground);
--multiItemActiveBG: var(--theme-input-multi-item-background);
--multiItemActiveColor: var(--theme-input-multi-item-foreground);
--multiItemBG: var(--theme-input-multi-item-background);
--multiItemBG: var(--theme-input-multi-clear-background);
--multiItemDisabledHoverBg: var(--theme-input-multi-item-background);
--multiItemDisabledHoverColor: var(--theme-input-multi-item-foreground);
}
</style>
@@ -1,4 +1,8 @@
<script lang="ts" context="module">
import { showModal } from '../modals/modalTools';
import EditJsonModal from '../modals/EditJsonModal.svelte';
import stableStringify from 'json-stable-stringify';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
export function editJsonRowDocument(grider, rowIndex) {
const rowData = grider.getRowData(rowIndex);
showModal(EditJsonModal, {
@@ -21,13 +25,8 @@
<script lang="ts">
import JSONTree from '../jsontree/JSONTree.svelte';
import EditJsonModal from '../modals/EditJsonModal.svelte';
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
import { showModal } from '../modals/modalTools';
import { copyTextToClipboard } from '../utility/clipboard';
import { getContextMenu, registerMenu } from '../utility/contextMenu';
import stableStringify from 'json-stable-stringify';
export let rowIndex;
export let grider;
@@ -1,153 +1,151 @@
<script lang="ts" context="module">
const getCurrentEditor = () => getActiveComponent('CollectionJsonView');
registerCommand({
id: 'collectionJsonView.expandAll',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.expandAll', { defaultMessage: 'Expand all' }),
isRelatedToTab: true,
icon: 'icon expand-all',
onClick: () => getCurrentEditor().handleExpandAll(),
testEnabled: () => getCurrentEditor() != null && !getCurrentEditor()?.isExpandedAll(),
});
registerCommand({
id: 'collectionJsonView.collapseAll',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.collapseAll', { defaultMessage: 'Collapse all' }),
isRelatedToTab: true,
icon: 'icon collapse-all',
onClick: () => getCurrentEditor().handleCollapseAll(),
testEnabled: () => getCurrentEditor() != null && getCurrentEditor()?.isExpandedAll(),
});
</script>
<script lang="ts">
import _ from 'lodash';
import { onMount } from 'svelte';
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import ChangeSetGrider from '../datagrid/ChangeSetGrider';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import { loadCollectionDataPage } from '../datagrid/CollectionDataGridCore.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
import Pager from '../elements/Pager.svelte';
import contextMenu, { getContextMenu, registerMenu } from '../utility/contextMenu';
import CollectionJsonRow from './CollectionJsonRow.svelte';
import { getIntSettingsValue } from '../settings/settingsTools';
import invalidateCommands from '../commands/invalidateCommands';
import { __t } from '../translations';
export let conid;
export let database;
export let cache;
export let display;
export let setConfig;
export let changeSetState;
export let dispatchChangeSet;
export let setLoadedRows;
export const activator = createActivator('CollectionJsonView', true);
let isLoading = false;
let loadedTime = null;
let expandAll = false;
let expandKey = 0;
let loadedRows = [];
let skip = 0;
let limit = getIntSettingsValue('dataGrid.collectionPageSize', 50, 5, 1000);
async function loadData() {
isLoading = true;
// @ts-ignore
loadedRows = await loadCollectionDataPage($$props, parseInt(skip) || 0, parseInt(limit) || 50);
if (setLoadedRows) setLoadedRows(loadedRows);
isLoading = false;
loadedTime = new Date().getTime();
}
$: if (cache?.refreshTime > loadedTime) {
loadData();
}
onMount(() => {
loadData();
});
registerMenu({ placeTag: 'switch' });
const menu = getContextMenu();
$: grider = new ChangeSetGrider(loadedRows, changeSetState, dispatchChangeSet, display);
// $: console.log('GRIDER', grider);
export function handleExpandAll() {
expandAll = true;
expandKey += 1;
invalidateCommands();
}
export function handleCollapseAll() {
expandAll = false;
expandKey += 1;
invalidateCommands();
}
export function isExpandedAll() {
return expandAll;
}
</script>
<div class="wrapper" use:contextMenu={menu}>
<div class="toolbar">
<Pager bind:skip bind:limit on:load={() => display.reload()} />
</div>
<div class="json">
{#key expandKey}
{#each _.range(0, grider.rowCount) as rowIndex}
<CollectionJsonRow {grider} {rowIndex} {expandAll} />
{/each}
{/key}
</div>
</div>
{#if isLoading}
<LoadingInfo wrapper message="Loading data" />
{/if}
<style>
.toolbar {
background: var(--theme-toolstrip-background);
display: flex;
border-bottom: var(--theme-toolstrip-border);
border-top: var(--theme-toolstrip-border);
margin-bottom: 3px;
}
.toolbar :global(input){
margin-top: 3px;
margin-bottom: 3px;
height: 26px;
}
.json {
overflow: auto;
flex: 1;
/* position: relative; */
}
.wrapper {
display: flex;
flex-direction: column;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
</style>
const getCurrentEditor = () => getActiveComponent('CollectionJsonView');
registerCommand({
id: 'collectionJsonView.expandAll',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.expandAll', { defaultMessage: 'Expand all' }),
isRelatedToTab: true,
icon: 'icon expand-all',
onClick: () => getCurrentEditor().handleExpandAll(),
testEnabled: () => getCurrentEditor() != null && !getCurrentEditor()?.isExpandedAll(),
});
registerCommand({
id: 'collectionJsonView.collapseAll',
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
name: __t('command.collectionData.collapseAll', { defaultMessage: 'Collapse all' }),
isRelatedToTab: true,
icon: 'icon collapse-all',
onClick: () => getCurrentEditor().handleCollapseAll(),
testEnabled: () => getCurrentEditor() != null && getCurrentEditor()?.isExpandedAll(),
});
</script>
<script lang="ts">
import _ from 'lodash';
import { onMount } from 'svelte'; import ChangeSetGrider from '../datagrid/ChangeSetGrider';
import createActivator from '../utility/createActivator';
import { loadCollectionDataPage } from '../datagrid/CollectionDataGridCore.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
import Pager from '../elements/Pager.svelte';
import contextMenu, { getContextMenu, registerMenu } from '../utility/contextMenu';
import CollectionJsonRow from './CollectionJsonRow.svelte';
import { getIntSettingsValue } from '../settings/settingsTools';
import invalidateCommands from '../commands/invalidateCommands'; export let conid;
export let database;
export let cache;
export let display;
export let setConfig;
export let changeSetState;
export let dispatchChangeSet;
export let setLoadedRows;
export const activator = createActivator('CollectionJsonView', true);
let isLoading = false;
let loadedTime = null;
let expandAll = false;
let expandKey = 0;
let loadedRows = [];
let skip = 0;
let limit = getIntSettingsValue('dataGrid.collectionPageSize', 50, 5, 1000);
async function loadData() {
isLoading = true;
// @ts-ignore
loadedRows = await loadCollectionDataPage($$props, parseInt(skip) || 0, parseInt(limit) || 50);
if (setLoadedRows) setLoadedRows(loadedRows);
isLoading = false;
loadedTime = new Date().getTime();
}
$: if (cache?.refreshTime > loadedTime) {
loadData();
}
onMount(() => {
loadData();
});
registerMenu({ placeTag: 'switch' });
const menu = getContextMenu();
$: grider = new ChangeSetGrider(loadedRows, changeSetState, dispatchChangeSet, display);
// $: console.log('GRIDER', grider);
export function handleExpandAll() {
expandAll = true;
expandKey += 1;
invalidateCommands();
}
export function handleCollapseAll() {
expandAll = false;
expandKey += 1;
invalidateCommands();
}
export function isExpandedAll() {
return expandAll;
}
</script>
<div class="wrapper" use:contextMenu={menu}>
<div class="toolbar">
<Pager bind:skip bind:limit on:load={() => display.reload()} />
</div>
<div class="json">
{#key expandKey}
{#each _.range(0, grider.rowCount) as rowIndex}
<CollectionJsonRow {grider} {rowIndex} {expandAll} />
{/each}
{/key}
</div>
</div>
{#if isLoading}
<LoadingInfo wrapper message="Loading data" />
{/if}
<style>
.toolbar {
background: var(--theme-toolstrip-background);
display: flex;
border-bottom: var(--theme-toolstrip-border);
border-top: var(--theme-toolstrip-border);
margin-bottom: 3px;
}
.toolbar :global(input){
margin-top: 3px;
margin-bottom: 3px;
height: 26px;
}
.json {
overflow: auto;
flex: 1;
/* position: relative; */
}
.wrapper {
display: flex;
flex-direction: column;
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
}
</style>
+12 -6
View File
@@ -1,4 +1,7 @@
<script lang="ts" context="module">
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import { __t } from '../translations';
const getCurrentDataForm = () => getActiveComponent('FormView');
// registerCommand({
@@ -173,8 +176,6 @@
import { getContext } from 'svelte';
import invalidateCommands from '../commands/invalidateCommands';
import registerCommand from '../commands/registerCommand';
import DataGridCell from '../datagrid/DataGridCell.svelte';
import { dataGridRowHeight } from '../datagrid/DataGridRowHeightMeter.svelte';
import InplaceEditor from '../datagrid/InplaceEditor.svelte';
@@ -191,13 +192,13 @@
import { copyTextToClipboard, extractRowCopiedValue } from '../utility/clipboard';
import { isCtrlOrCommandKey } from '../utility/common';
import contextMenu, { getContextMenu, registerMenu } from '../utility/contextMenu';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import createActivator from '../utility/createActivator';
import createReducer from '../utility/createReducer';
import keycodes from '../utility/keycodes';
import resizeObserver from '../utility/resizeObserver';
import openReferenceForm from './openReferenceForm';
import { useSettings } from '../utility/metadataLoaders';
import { _t, __t } from '../translations';
import { _t } from '../translations';
export let conid;
export let database;
@@ -205,6 +206,8 @@
export let setConfig;
export let focusOnVisible = false;
export let allRowCount;
export let allRowCountError = null;
export let onReloadRowCount = null;
export let rowCountBefore;
export let isLoading;
export let grider;
@@ -236,12 +239,12 @@
$: columnChunks = _.chunk(display?.formColumns || [], rowCount) as any[][];
$: rowCountInfo = getRowCountInfo(allRowCount, display);
$: rowCountInfo = getRowCountInfo(allRowCount, display, allRowCountError);
const settingsValue = useSettings();
$: gridColoringMode = $settingsValue?.['dataGrid.coloringMode'];
function getRowCountInfo(allRowCount) {
function getRowCountInfo(allRowCount, _display?, _allRowCountError?) {
if (rowCountNotAvailable) {
return _t('dataForm.rowCount', { defaultMessage: 'Row: {rowCount} / ???', values: { rowCount: ((display.config.formViewRecordNumber || 0) + 1).toLocaleString() } });
}
@@ -251,6 +254,9 @@
}
return _t('dataForm.noData', { defaultMessage: 'No data' });
}
if (allRowCountError) {
return _t('dataForm.rowCountMany', { defaultMessage: 'Row: {current} / Many', values: { current: ((display.config.formViewRecordNumber || 0) + 1).toLocaleString() } });
}
if (allRowCount == null || display == null) return _t('dataForm.loadingRowCount', { defaultMessage: 'Loading row count...' });
return _t('dataForm.rowCount', { defaultMessage: 'Row: {current} / {total}', values: { current: ((display.config.formViewRecordNumber || 0) + 1).toLocaleString(), total: allRowCount.toLocaleString() } });
}
+31 -32
View File
@@ -1,34 +1,33 @@
<script lang="ts" context="module">
async function loadRow(props, index) {
const { jslid, formatterFunction, display } = props;
const response = await apiCall('jsldata/get-rows', {
jslid,
offset: index,
limit: 1,
formatterFunction,
filters: display ? display.compileJslFilters() : null,
});
if (response.errorMessage) return response;
return response[0];
}
</script>
<script lang="ts">
import { apiCall } from '../utility/api';
import _ from 'lodash';
import LoadingFormView from './LoadingFormView.svelte';
export let display;
async function handleLoadRow() {
return await loadRow($$props, display.config.formViewRecordNumber || 0);
}
async function handleLoadRowCount() {
return null;
}
</script>
<LoadingFormView {...$$props} loadRowFunc={handleLoadRow} loadRowCountFunc={handleLoadRowCount} rowCountNotAvailable />
async function loadRow(props, index) {
const { jslid, formatterFunction, display } = props;
const response = await apiCall('jsldata/get-rows', {
jslid,
offset: index,
limit: 1,
formatterFunction,
filters: display ? display.compileJslFilters() : null,
});
if (response.errorMessage) return response;
return response[0];
}
</script>
<script lang="ts"> import _ from 'lodash';
import LoadingFormView from './LoadingFormView.svelte';
export let display;
async function handleLoadRow() {
return await loadRow($$props, display.config.formViewRecordNumber || 0);
}
async function handleLoadRowCount() {
return null;
}
</script>
<LoadingFormView {...$$props} loadRowFunc={handleLoadRow} loadRowCountFunc={handleLoadRowCount} rowCountNotAvailable />
@@ -22,6 +22,7 @@
let isLoadedCount = false;
let loadedTime = new Date().getTime();
let allRowCount = null;
let allRowCountError = null;
let errorMessage = null;
const handleLoadCurrentRow = async () => {
@@ -38,7 +39,14 @@
const handleLoadRowCount = async () => {
isLoadingCount = true;
allRowCount = await loadRowCountFunc();
const result = await loadRowCountFunc();
if (result != null && typeof result === 'object' && result.errorMessage) {
allRowCount = null;
allRowCountError = result.errorMessage;
} else {
allRowCount = result;
allRowCountError = null;
}
isLoadedCount = true;
isLoadingCount = false;
};
@@ -55,6 +63,7 @@
rowData = null;
loadedTime = new Date().getTime();
allRowCount = null;
allRowCountError = null;
errorMessage = null;
}
@@ -82,4 +91,4 @@
$: if (onReferenceSourceChanged && rowData) onReferenceSourceChanged([rowData], loadedTime);
</script>
<FormView {...$$props} {grider} isLoading={isLoadingData} {allRowCount} onNavigate={handleNavigate} />
<FormView {...$$props} {grider} isLoading={isLoadingData} {allRowCount} {allRowCountError} onReloadRowCount={handleLoadRowCount} onNavigate={handleNavigate} />
+15 -4
View File
@@ -1,5 +1,6 @@
<script lang="ts" context="module">
async function loadRow(props, select) {
import { apiCall } from '../utility/api';
async function loadRow(props, select, options = {}) {
const { conid, database } = props;
if (!select) return null;
@@ -9,6 +10,7 @@
database,
select,
auditLogSessionGroup: 'data-form',
...options,
});
if (response.errorMessage) return response;
@@ -17,7 +19,6 @@
</script>
<script lang="ts">
import { apiCall } from '../utility/api';
import _ from 'lodash';
import LoadingFormView from './LoadingFormView.svelte';
@@ -28,8 +29,18 @@
}
async function handleLoadRowCount() {
const countRow = await loadRow($$props, display.getCountQuery());
return countRow ? parseInt(countRow.count) : null;
const timeoutPromise = new Promise((_, reject) =>
setTimeout(() => reject(new Error('Row count query timed out')), 3000)
);
try {
const countRow = await Promise.race([
loadRow($$props, display.getCountQuery(), { commandTimeout: 3000 }),
timeoutPromise,
]);
return countRow ? parseInt(countRow.count) : null;
} catch (err) {
return { errorMessage: err.message || 'Error loading row count' };
}
}
</script>
+4 -1
View File
@@ -26,6 +26,8 @@
</script>
<script>
import DOMPurify from 'dompurify';
export let icon;
export let title = null;
export let padLeft = false;
@@ -34,6 +36,7 @@
export let colorClass = null;
$: iconValue = typeof icon === 'string' ? icon : icon?.light || icon?.dark || '';
$: isSvgString = iconValue.trim().startsWith('<svg');
$: sanitizedSvg = isSvgString ? DOMPurify.sanitize(iconValue, { USE_PROFILES: { svg: true, svgFilters: true } }) : '';
$: isTextIcon = iconValue.trim().startsWith('text ');
const iconNames = {
@@ -379,7 +382,7 @@
{#if isSvgString}
<span class="svg-inline" class:padLeft class:padRight {title} {style} on:click data-testid={$$props['data-testid']}>
{@html iconValue}
{@html sanitizedSvg}
</span>
{:else if isTextIcon}
{@const textIconParts = iconValue.trim().split(' ')}
@@ -1,4 +1,6 @@
<script lang="ts" context="module">
import { extensions } from '../stores';
import { findFileFormat } from '../plugins/fileformats';
function getFileFilters(extensions, storageType) {
const res = [];
const format = findFileFormat(extensions, storageType);
@@ -12,10 +14,8 @@
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import LoadingInfo from '../elements/LoadingInfo.svelte';
import { getFormContext } from '../forms/FormProviderCore.svelte';
import { findFileFormat } from '../plugins/fileformats';
import { extensions } from '../stores';
import getElectron from '../utility/getElectron';
import { addFilesToSourceList } from './ImportExportConfigurator.svelte';
import getElectron from '../utility/getElectron';
let isLoading = false;
@@ -1,4 +1,8 @@
<script lang="ts" context="module">
import { extensions } from '../stores';
import getAsArray from '../utility/getAsArray';
import { findFileFormat } from '../plugins/fileformats';
import { apiCall } from '../utility/api';
async function addFileToSourceListDefault({ fileName, shortName, isDownload }, newSources, newValues) {
const sourceName = shortName;
newSources.push(sourceName);
@@ -61,10 +65,6 @@
import FontIcon from '../icons/FontIcon.svelte';
import ColumnMapModal from '../modals/ColumnMapModal.svelte';
import { showModal } from '../modals/modalTools';
import { findFileFormat } from '../plugins/fileformats';
import { extensions } from '../stores';
import { apiCall } from '../utility/api';
import getAsArray from '../utility/getAsArray';
import { useConnectionInfo, useDatabaseInfo } from '../utility/metadataLoaders';
import { setUploadListener } from '../utility/uploadFiles';
import { createPreviewReader, getTargetName } from './createImpExpScript';
@@ -6,6 +6,7 @@ import { getConnectionInfo } from '../utility/metadataLoaders';
import { findEngineDriver, findObjectLike } from 'dbgate-tools';
import { findFileFormat } from '../plugins/fileformats';
import { getCurrentConfig, getExtensions } from '../stores';
import { getVolatileRemapping } from '../utility/api';
export function getTargetName(extensions, source, values) {
const key = `targetName_${source}`;
@@ -38,6 +39,30 @@ function extractDriverApiParameters(values, direction, driver) {
export function extractShellConnection(connection, database) {
const config = getCurrentConfig();
// Case 1: connection._id is the original ID and a volatile remap exists.
// Use the volatile ID so the backend child process can look up the credentials.
const volatileId = getVolatileRemapping(connection._id);
if (volatileId !== connection._id) {
return {
_id: volatileId,
engine: connection.engine,
database,
};
}
// Case 2: apiCall.transformApiArgs already remapped the conid before the
// connection was fetched, so connection._id IS already the volatile ID and
// connection.unsaved === true. Falling through to allowShellConnection here
// would embed plaintext credentials in the generated script — always use the
// _id reference instead.
if (connection.unsaved) {
return {
_id: connection._id,
engine: connection.engine,
database,
};
}
return config.allowShellConnection
? {
..._.omitBy(
@@ -7,7 +7,7 @@
import JsonUiMarkdown from './JsonUiMarkdown.svelte';
import JsonUiTextBlock from './JsonUiTextBlock.svelte';
import JsonUiTickList from './JsonUiTickList.svelte';
import { JsonUiBlock } from './jsonuitypes';
import type { JsonUiBlock } from './jsonuitypes';
export let blocks: JsonUiBlock[] = [];
export let passProps = {};
@@ -1,4 +1,9 @@
<script context="module" lang="ts">
import { apiCall } from '../utility/api';
import { showModal } from './modalTools';
import ErrorMessageModal from './ErrorMessageModal.svelte';
import { showSnackbarSuccess } from '../utility/snackbar';
import _ from 'lodash';
export async function saveScriptToDatabase({ conid, database }, sql, syncModel = true, logMessage = null) {
const resp = await apiCall('database-connections/run-script', {
conid,
@@ -38,7 +43,6 @@
</script>
<script>
import _ from 'lodash';
import { writable } from 'svelte/store';
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormCheckboxField from '../forms/FormCheckboxField.svelte';
@@ -48,12 +52,8 @@
import FontIcon from '../icons/FontIcon.svelte';
import newQuery from '../query/newQuery';
import SqlEditor from '../query/SqlEditor.svelte';
import { apiCall } from '../utility/api';
import { showSnackbarSuccess } from '../utility/snackbar';
import ErrorMessageModal from './ErrorMessageModal.svelte';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal, showModal } from './modalTools';
import { closeCurrentModal } from './modalTools';
import { _t } from '../translations';
export let sql;
@@ -0,0 +1,74 @@
<script lang="ts">
import FormStyledButton from '../buttons/FormStyledButton.svelte';
import FormProvider from '../forms/FormProvider.svelte';
import FormSubmit from '../forms/FormSubmit.svelte';
import TemplatedCheckboxField from '../forms/TemplatedCheckboxField.svelte';
import FontIcon from '../icons/FontIcon.svelte';
import ModalBase from './ModalBase.svelte';
import { closeCurrentModal } from './modalTools';
import { apiCall } from '../utility/api';
import { _t } from '../translations';
export let onConfirm;
const SKIP_SETTING_KEY = 'dataGrid.skipFetchAllConfirm';
let dontAskAgain = false;
</script>
<FormProvider>
<ModalBase {...$$restProps} data-testid="FetchAllConfirmModal">
<svelte:fragment slot="header">
{_t('datagrid.fetchAll.title', { defaultMessage: 'Fetch All Rows' })}
</svelte:fragment>
<div class="message">
<FontIcon icon="img warn" />
<span>
{_t('datagrid.fetchAll.warning', {
defaultMessage:
'This will load all remaining rows into memory. For large tables, this may consume a significant amount of memory and could affect application performance.',
})}
</span>
</div>
<div class="mt-2">
<TemplatedCheckboxField
label={_t('common.dontAskAgain', { defaultMessage: "Don't ask again" })}
templateProps={{ noMargin: true }}
checked={dontAskAgain}
on:change={e => {
dontAskAgain = e.detail;
apiCall('config/update-settings', { [SKIP_SETTING_KEY]: e.detail });
}}
data-testid="FetchAllConfirmModal_dontAskAgain"
/>
</div>
<svelte:fragment slot="footer">
<FormSubmit
value={_t('datagrid.fetchAll.confirm', { defaultMessage: 'Fetch All' })}
on:click={() => {
closeCurrentModal();
onConfirm();
}}
data-testid="FetchAllConfirmModal_okButton"
/>
<FormStyledButton
type="button"
value={_t('common.close', { defaultMessage: 'Close' })}
on:click={closeCurrentModal}
data-testid="FetchAllConfirmModal_closeButton"
/>
</svelte:fragment>
</ModalBase>
</FormProvider>
<style>
.message {
display: flex;
align-items: flex-start;
gap: 8px;
line-height: 1.5;
}
</style>
@@ -144,6 +144,18 @@
}),
testid: 'NewObjectModal_databaseChat',
},
{
icon: 'icon ai',
colorClass: 'color-icon-blue',
title: _t('common.graphqlChat', { defaultMessage: 'GraphQL Chat' }),
description: _t('newObject.graphqlChatDescription', { defaultMessage: 'Chat with your GraphQL API using AI' }),
command: 'graphql.chat',
isProFeature: true,
disabledMessage: _t('newObject.graphqlChatDisabled', {
defaultMessage: 'GraphQL chat is not available for current connection',
}),
testid: 'NewObjectModal_graphqlChat',
},
{
icon: 'icon graphql',
colorClass: 'color-icon-magenta',
File diff suppressed because it is too large Load Diff
@@ -1,246 +1,247 @@
<script lang="ts" context="module">
const getCurrentEditor = () => getActiveComponent('PerspectiveView');
registerCommand({
id: 'perspective.customJoin',
category: __t('perspective.category', { defaultMessage: 'Perspective' }),
name: __t('perspective.customJoin', { defaultMessage: 'Custom join' }),
keyText: 'CtrlOrCommand+J',
isRelatedToTab: true,
icon: 'icon custom-join',
testEnabled: () => getCurrentEditor() != null,
onClick: () => getCurrentEditor().defineCustomJoin(),
});
// registerCommand({
// id: 'perspective.arrange',
// category: 'Perspective',
// icon: 'icon arrange',
// name: 'Arrange',
// toolbar: true,
// isRelatedToTab: true,
// testEnabled: () => getCurrentEditor()?.canArrange(),
// onClick: () => getCurrentEditor().arrange(),
// });
</script>
<script lang="ts">
import {
extractPerspectiveDatabases,
PerspectiveDataProvider,
PerspectiveTableNode,
PerspectiveTreeNode,
processPerspectiveDefaultColunns,
shouldProcessPerspectiveDefaultColunns,
} from 'dbgate-datalib';
import type { ChangePerspectiveConfigFunc, PerspectiveConfig } from 'dbgate-datalib';
import _ from 'lodash';
import HorizontalSplitter from '../elements/HorizontalSplitter.svelte';
import debug from 'debug';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import WidgetColumnBar from '../widgets/WidgetColumnBar.svelte';
import WidgetColumnBarItem from '../widgets/WidgetColumnBarItem.svelte';
import PerspectiveTree from './PerspectiveTree.svelte';
import PerspectiveTable from './PerspectiveTable.svelte';
import { apiCall } from '../utility/api';
import ManagerInnerContainer from '../elements/ManagerInnerContainer.svelte';
import { PerspectiveDataLoader } from 'dbgate-datalib';
import stableStringify from 'json-stable-stringify';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import { showModal } from '../modals/modalTools';
import CustomJoinModal from './CustomJoinModal.svelte';
import PerspectiveFilters from './PerspectiveFilters.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import { useMultipleDatabaseInfo } from '../utility/useMultipleDatabaseInfo';
import VerticalSplitter from '../elements/VerticalSplitter.svelte';
import PerspectiveDesigner from './PerspectiveDesigner.svelte';
import { tick } from 'svelte';
import { sleep } from '../utility/common';
import FontIcon from '../icons/FontIcon.svelte';
import InlineButton from '../buttons/InlineButton.svelte';
import { usePerspectiveDataPatterns } from '../utility/usePerspectiveDataPatterns';
import { _t, __t } from '../translations';
const dbg = debug('dbgate:PerspectiveView');
export let conid;
export let database;
export let driver;
export let config: PerspectiveConfig;
export let setConfig: ChangePerspectiveConfigFunc;
let tempRootDesignerId: string = null;
export let loadedCounts;
export let cache;
let managerSize;
let filter;
export const activator = createActivator('PerspectiveView', true);
$: if (managerSize) setLocalStorage('perspectiveManagerWidth', managerSize);
function getInitialManagerSize() {
const width = getLocalStorage('perspectiveManagerWidth');
if (_.isNumber(width) && width > 30 && width < 500) {
return `${width}px`;
}
return '300px';
}
export function defineCustomJoin() {
if (!root) return;
showModal(CustomJoinModal, {
config,
setConfig,
conid,
database,
root,
});
}
// export function canArrange() {
// return !config.isArranged;
// }
// export function arrange() {
// // setConfig(cfg => ({
// // ...cfg,
// // isArranged: true,
// // }));
// runCommand('designer.arrange');
// }
let perspectiveDatabases = extractPerspectiveDatabases({ conid, database }, config);
$: {
const newDatabases = extractPerspectiveDatabases({ conid, database }, config);
if (stableStringify(newDatabases) != stableStringify(perspectiveDatabases)) {
perspectiveDatabases = newDatabases;
}
}
$: dbInfos = useMultipleDatabaseInfo(perspectiveDatabases);
$: loader = new PerspectiveDataLoader(apiCall);
$: dataPatterns = usePerspectiveDataPatterns({ conid, database }, config, cache, $dbInfos, loader);
$: rootObject = config?.nodes?.find(x => x.designerId == config?.rootDesignerId);
$: rootDb = rootObject ? $dbInfos?.[rootObject.conid || conid]?.[rootObject.database || database] : null;
$: tableInfo = rootDb?.tables.find(x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName);
$: viewInfo = rootDb?.views.find(x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName);
$: collectionInfo = rootDb?.collections.find(
x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName
);
$: dataProvider = new PerspectiveDataProvider(cache, loader, $dataPatterns);
$: root =
tableInfo || viewInfo || collectionInfo
? new PerspectiveTableNode(
tableInfo || viewInfo || collectionInfo,
$dbInfos,
config,
setConfig,
dataProvider,
{ conid, database },
null,
config.rootDesignerId
)
: null;
$: tempRoot = root?.findNodeByDesignerId(tempRootDesignerId);
$: {
if (shouldProcessPerspectiveDefaultColunns(config, $dbInfos, $dataPatterns, conid, database)) {
setConfig(cfg => processPerspectiveDefaultColunns(cfg, $dbInfos, $dataPatterns, conid, database));
}
}
// $: console.log('PERSPECTIVE', config);
// $: console.log('VIEW ROOT', root);
// $: console.log('dataPatterns', $dataPatterns);
</script>
<HorizontalSplitter initialValue={getInitialManagerSize()} bind:size={managerSize} allowCollapseChild1>
<div class="left" slot="1">
<WidgetColumnBar>
<WidgetColumnBarItem title={_t('perspective.chooseData', { defaultMessage: "Choose data" })} name="perspectiveTree" height={'70%'}>
{#if tempRoot && tempRoot != root}
<div class="temp-root">
<div>
<FontIcon icon="img table" />
{tempRoot.title}
</div>
<InlineButton
on:click={() => {
tempRootDesignerId = tempRoot?.parentNode?.designerId;
}}>Go up</InlineButton
>
</div>
{/if}
<SearchBoxWrapper {filter}>
<SearchInput placeholder={_t('perspective.searchColumnOrTable', { defaultMessage: "Search column or table" })} bind:value={filter} />
<CloseSearchButton bind:filter />
</SearchBoxWrapper>
<ManagerInnerContainer width={managerSize}>
{#if root}
<PerspectiveTree {root} {tempRoot} {config} {setConfig} {conid} {database} {filter} />
{/if}
</ManagerInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem title={_t('perspective.filters', { defaultMessage: "Filters" })} name="tableFilters">
<PerspectiveFilters {managerSize} {config} {setConfig} {conid} {database} {driver} {root} />
</WidgetColumnBarItem>
</WidgetColumnBar>
</div>
<svelte:fragment slot="2">
<VerticalSplitter allowCollapseChild1 allowCollapseChild2>
<svelte:fragment slot="1">
<PerspectiveDesigner
{config}
{conid}
{database}
{setConfig}
dbInfos={$dbInfos}
dataPatterns={$dataPatterns}
{root}
onClickTableHeader={designerId => {
sleep(100).then(() => {
tempRootDesignerId = designerId;
});
}}
/>
</svelte:fragment>
<svelte:fragment slot="2">
<PerspectiveTable {root} {loadedCounts} {config} {setConfig} {conid} {database} />
</svelte:fragment>
</VerticalSplitter>
</svelte:fragment>
</HorizontalSplitter>
<style>
.left {
display: flex;
flex: 1;
background: var(--theme-altsidebar-background);
border-right: var(--theme-altsidebar-border);
}
.temp-root {
border: var(--theme-altsearchbox-border);
background: var(--theme-altsearchbox-background);
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 2px;
}
</style>
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('PerspectiveView');
registerCommand({
id: 'perspective.customJoin',
category: __t('perspective.category', { defaultMessage: 'Perspective' }),
name: __t('perspective.customJoin', { defaultMessage: 'Custom join' }),
keyText: 'CtrlOrCommand+J',
isRelatedToTab: true,
icon: 'icon custom-join',
testEnabled: () => getCurrentEditor() != null,
onClick: () => getCurrentEditor().defineCustomJoin(),
});
// registerCommand({
// id: 'perspective.arrange',
// category: 'Perspective',
// icon: 'icon arrange',
// name: 'Arrange',
// toolbar: true,
// isRelatedToTab: true,
// testEnabled: () => getCurrentEditor()?.canArrange(),
// onClick: () => getCurrentEditor().arrange(),
// });
</script>
<script lang="ts">
import {
extractPerspectiveDatabases,
PerspectiveDataProvider,
PerspectiveTableNode,
PerspectiveTreeNode,
processPerspectiveDefaultColunns,
shouldProcessPerspectiveDefaultColunns,
} from 'dbgate-datalib';
import type { ChangePerspectiveConfigFunc, PerspectiveConfig } from 'dbgate-datalib';
import _ from 'lodash';
import HorizontalSplitter from '../elements/HorizontalSplitter.svelte';
import debug from 'debug';
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
import WidgetColumnBar from '../widgets/WidgetColumnBar.svelte';
import WidgetColumnBarItem from '../widgets/WidgetColumnBarItem.svelte';
import PerspectiveTree from './PerspectiveTree.svelte';
import PerspectiveTable from './PerspectiveTable.svelte';
import { apiCall } from '../utility/api';
import ManagerInnerContainer from '../elements/ManagerInnerContainer.svelte';
import { PerspectiveDataLoader } from 'dbgate-datalib';
import stableStringify from 'json-stable-stringify';
import createActivator from '../utility/createActivator'; import { showModal } from '../modals/modalTools';
import CustomJoinModal from './CustomJoinModal.svelte';
import PerspectiveFilters from './PerspectiveFilters.svelte';
import SearchBoxWrapper from '../elements/SearchBoxWrapper.svelte';
import SearchInput from '../elements/SearchInput.svelte';
import CloseSearchButton from '../buttons/CloseSearchButton.svelte';
import { useMultipleDatabaseInfo } from '../utility/useMultipleDatabaseInfo';
import VerticalSplitter from '../elements/VerticalSplitter.svelte';
import PerspectiveDesigner from './PerspectiveDesigner.svelte';
import { tick } from 'svelte';
import { sleep } from '../utility/common';
import FontIcon from '../icons/FontIcon.svelte';
import InlineButton from '../buttons/InlineButton.svelte';
import { usePerspectiveDataPatterns } from '../utility/usePerspectiveDataPatterns';
import { _t } from '../translations';
const dbg = debug('dbgate:PerspectiveView');
export let conid;
export let database;
export let driver;
export let config: PerspectiveConfig;
export let setConfig: ChangePerspectiveConfigFunc;
let tempRootDesignerId: string = null;
export let loadedCounts;
export let cache;
let managerSize;
let filter;
export const activator = createActivator('PerspectiveView', true);
$: if (managerSize) setLocalStorage('perspectiveManagerWidth', managerSize);
function getInitialManagerSize() {
const width = getLocalStorage('perspectiveManagerWidth');
if (_.isNumber(width) && width > 30 && width < 500) {
return `${width}px`;
}
return '300px';
}
export function defineCustomJoin() {
if (!root) return;
showModal(CustomJoinModal, {
config,
setConfig,
conid,
database,
root,
});
}
// export function canArrange() {
// return !config.isArranged;
// }
// export function arrange() {
// // setConfig(cfg => ({
// // ...cfg,
// // isArranged: true,
// // }));
// runCommand('designer.arrange');
// }
let perspectiveDatabases = extractPerspectiveDatabases({ conid, database }, config);
$: {
const newDatabases = extractPerspectiveDatabases({ conid, database }, config);
if (stableStringify(newDatabases) != stableStringify(perspectiveDatabases)) {
perspectiveDatabases = newDatabases;
}
}
$: dbInfos = useMultipleDatabaseInfo(perspectiveDatabases);
$: loader = new PerspectiveDataLoader(apiCall);
$: dataPatterns = usePerspectiveDataPatterns({ conid, database }, config, cache, $dbInfos, loader);
$: rootObject = config?.nodes?.find(x => x.designerId == config?.rootDesignerId);
$: rootDb = rootObject ? $dbInfos?.[rootObject.conid || conid]?.[rootObject.database || database] : null;
$: tableInfo = rootDb?.tables.find(x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName);
$: viewInfo = rootDb?.views.find(x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName);
$: collectionInfo = rootDb?.collections.find(
x => x.pureName == rootObject?.pureName && x.schemaName == rootObject?.schemaName
);
$: dataProvider = new PerspectiveDataProvider(cache, loader, $dataPatterns);
$: root =
tableInfo || viewInfo || collectionInfo
? new PerspectiveTableNode(
tableInfo || viewInfo || collectionInfo,
$dbInfos,
config,
setConfig,
dataProvider,
{ conid, database },
null,
config.rootDesignerId
)
: null;
$: tempRoot = root?.findNodeByDesignerId(tempRootDesignerId);
$: {
if (shouldProcessPerspectiveDefaultColunns(config, $dbInfos, $dataPatterns, conid, database)) {
setConfig(cfg => processPerspectiveDefaultColunns(cfg, $dbInfos, $dataPatterns, conid, database));
}
}
// $: console.log('PERSPECTIVE', config);
// $: console.log('VIEW ROOT', root);
// $: console.log('dataPatterns', $dataPatterns);
</script>
<HorizontalSplitter initialValue={getInitialManagerSize()} bind:size={managerSize} allowCollapseChild1>
<div class="left" slot="1">
<WidgetColumnBar>
<WidgetColumnBarItem title={_t('perspective.chooseData', { defaultMessage: "Choose data" })} name="perspectiveTree" height={'70%'}>
{#if tempRoot && tempRoot != root}
<div class="temp-root">
<div>
<FontIcon icon="img table" />
{tempRoot.title}
</div>
<InlineButton
on:click={() => {
tempRootDesignerId = tempRoot?.parentNode?.designerId;
}}>Go up</InlineButton
>
</div>
{/if}
<SearchBoxWrapper {filter}>
<SearchInput placeholder={_t('perspective.searchColumnOrTable', { defaultMessage: "Search column or table" })} bind:value={filter} />
<CloseSearchButton bind:filter />
</SearchBoxWrapper>
<ManagerInnerContainer width={managerSize}>
{#if root}
<PerspectiveTree {root} {tempRoot} {config} {setConfig} {conid} {database} {filter} />
{/if}
</ManagerInnerContainer>
</WidgetColumnBarItem>
<WidgetColumnBarItem title={_t('perspective.filters', { defaultMessage: "Filters" })} name="tableFilters">
<PerspectiveFilters {managerSize} {config} {setConfig} {conid} {database} {driver} {root} />
</WidgetColumnBarItem>
</WidgetColumnBar>
</div>
<svelte:fragment slot="2">
<VerticalSplitter allowCollapseChild1 allowCollapseChild2>
<svelte:fragment slot="1">
<PerspectiveDesigner
{config}
{conid}
{database}
{setConfig}
dbInfos={$dbInfos}
dataPatterns={$dataPatterns}
{root}
onClickTableHeader={designerId => {
sleep(100).then(() => {
tempRootDesignerId = designerId;
});
}}
/>
</svelte:fragment>
<svelte:fragment slot="2">
<PerspectiveTable {root} {loadedCounts} {config} {setConfig} {conid} {database} />
</svelte:fragment>
</VerticalSplitter>
</svelte:fragment>
</HorizontalSplitter>
<style>
.left {
display: flex;
flex: 1;
background: var(--theme-altsidebar-background);
border-right: var(--theme-altsidebar-border);
}
.temp-root {
border: var(--theme-altsearchbox-border);
background: var(--theme-altsearchbox-background);
display: flex;
justify-content: space-between;
align-items: center;
padding-left: 2px;
}
</style>
+85 -88
View File
@@ -1,93 +1,90 @@
<script lang="ts" context="module">
async function loadPlugins(pluginsDict, installedPlugins) {
window['DBGATE_PACKAGES'] = {
'dbgate-tools': dbgateTools,
'dbgate-sqltree': sqlTree,
'dbgate-datalib': dataLib,
};
// neccessary for older plugins
window['DBGATE_TOOLS'] = dbgateTools;
const newPlugins = {};
for (const installed of installedPlugins || []) {
if (!_.keys(pluginsDict).includes(installed.name)) {
console.log('Loading module', installed.name);
loadingPluginStore.set({
loaded: false,
loadingPackageName: installed.name,
});
const resp = await apiCall('plugins/script', {
packageName: installed.name,
});
const module = eval(`${resp}; plugin`);
console.log('Loaded plugin', module);
const moduleContent = module.__esModule ? module.default : module;
newPlugins[installed.name] = moduleContent;
}
}
if (installedPlugins) {
loadingPluginStore.set({
loaded: true,
loadingPackageName: null,
});
}
return newPlugins;
}
function buildDrivers(plugins) {
const res = isProApp() ? [openApiDriver, oDataDriver, graphQlDriver] : [];
for (const { content } of plugins) {
if (content.drivers) res.push(...content.drivers);
}
return res;
}
function filterByEdition(arr) {
return arr.filter(x => !x.premiumOnly || isProApp());
}
export function buildExtensions(plugins) {
const extensions = {
plugins,
fileFormats: filterByEdition(buildFileFormats(plugins)),
drivers: filterByEdition(buildDrivers(plugins)),
quickExports: filterByEdition(buildQuickExports(plugins)),
};
return extensions;
}
</script>
<script lang="ts">
import _ from 'lodash';
import { extensions, loadingPluginStore } from '../stores';
import { useInstalledPlugins } from '../utility/metadataLoaders';
import { buildFileFormats, buildQuickExports } from './fileformats';
import * as dbgateTools from 'dbgate-tools';
import * as sqlTree from 'dbgate-sqltree';
import * as dataLib from 'dbgate-datalib';
import { loadingPluginStore } from '../stores';
import { apiCall } from '../utility/api';
import { isProApp } from '../utility/proTools';
import { openApiDriver, oDataDriver, graphQlDriver } from 'dbgate-rest';
let pluginsDict = {};
const installedPlugins = useInstalledPlugins();
$: loadPlugins(pluginsDict, $installedPlugins).then(newPlugins => {
if (_.isEmpty(newPlugins)) return;
pluginsDict = _.pick(
{ ...pluginsDict, ...newPlugins },
$installedPlugins.map(y => y.name)
);
});
$: plugins = ($installedPlugins || [])
.map(manifest => ({
packageName: manifest.name,
manifest,
content: pluginsDict[manifest.name],
}))
.filter(x => x.content);
$: $extensions = buildExtensions(plugins);
</script>
import { buildFileFormats, buildQuickExports } from './fileformats';
async function loadPlugins(pluginsDict, installedPlugins) {
window['DBGATE_PACKAGES'] = {
'dbgate-tools': dbgateTools,
'dbgate-sqltree': sqlTree,
'dbgate-datalib': dataLib,
};
// neccessary for older plugins
window['DBGATE_TOOLS'] = dbgateTools;
const newPlugins = {};
for (const installed of installedPlugins || []) {
if (!_.keys(pluginsDict).includes(installed.name)) {
console.log('Loading module', installed.name);
loadingPluginStore.set({
loaded: false,
loadingPackageName: installed.name,
});
const resp = await apiCall('plugins/script', {
packageName: installed.name,
});
const module = eval(`${resp}; plugin`);
console.log('Loaded plugin', module);
const moduleContent = module.__esModule ? module.default : module;
newPlugins[installed.name] = moduleContent;
}
}
if (installedPlugins) {
loadingPluginStore.set({
loaded: true,
loadingPackageName: null,
});
}
return newPlugins;
}
function buildDrivers(plugins) {
const res = isProApp() ? [openApiDriver, oDataDriver, graphQlDriver] : [];
for (const { content } of plugins) {
if (content.drivers) res.push(...content.drivers);
}
return res;
}
function filterByEdition(arr) {
return arr.filter(x => !x.premiumOnly || isProApp());
}
export function buildExtensions(plugins) {
const extensions = {
plugins,
fileFormats: filterByEdition(buildFileFormats(plugins)),
drivers: filterByEdition(buildDrivers(plugins)),
quickExports: filterByEdition(buildQuickExports(plugins)),
};
return extensions;
}
</script>
<script lang="ts"> import { extensions } from '../stores';
import { useInstalledPlugins } from '../utility/metadataLoaders'; import * as dbgateTools from 'dbgate-tools';
import * as sqlTree from 'dbgate-sqltree';
import * as dataLib from 'dbgate-datalib'; let pluginsDict = {};
const installedPlugins = useInstalledPlugins();
$: loadPlugins(pluginsDict, $installedPlugins).then(newPlugins => {
if (_.isEmpty(newPlugins)) return;
pluginsDict = _.pick(
{ ...pluginsDict, ...newPlugins },
$installedPlugins.map(y => y.name)
);
});
$: plugins = ($installedPlugins || [])
.map(manifest => ({
packageName: manifest.name,
manifest,
content: pluginsDict[manifest.name],
}))
.filter(x => x.content);
$: $extensions = buildExtensions(plugins);
</script>
+4 -1
View File
@@ -2,6 +2,9 @@
import JslDataGrid from '../datagrid/JslDataGrid.svelte';
export let resultInfos = [];
export let exportConid = null;
export let exportDatabase = null;
export let exportQuery = null;
</script>
<div
@@ -12,7 +15,7 @@
>
{#each resultInfos as info}
<div class="wrapper">
<JslDataGrid jslid={info.jslid} multipleGridsOnTab={resultInfos.length >= 2} />
<JslDataGrid jslid={info.jslid} multipleGridsOnTab={resultInfos.length >= 2} {exportConid} {exportDatabase} {exportQuery} />
</div>
{/each}
</div>
+26 -4
View File
@@ -21,6 +21,9 @@
export let resultCount;
export let onSetFrontMatterField;
export let onGetFrontMatter;
export let exportConid = null;
export let exportDatabase = null;
export let exportQuery = null;
onMount(() => {
allResultsInOneTab = $allResultsInOneTabDefault;
@@ -74,6 +77,9 @@
component: AllResultsTab,
props: {
resultInfos,
exportConid: resultInfos.length === 1 ? exportConid : null,
exportDatabase: resultInfos.length === 1 ? exportDatabase : null,
exportQuery: resultInfos.length === 1 ? exportQuery : null,
},
},
]
@@ -82,10 +88,20 @@
isResult: true,
component: JslDataGrid,
resultIndex: info.resultIndex,
props: { jslid: info.jslid, driver, onOpenChart: () => handleOpenChart(info.resultIndex) },
props: {
jslid: info.jslid,
driver,
onOpenChart: () => handleOpenChart(info.resultIndex),
exportConid: resultInfos.length === 1 ? exportConid : null,
exportDatabase: resultInfos.length === 1 ? exportDatabase : null,
exportQuery: resultInfos.length === 1 ? exportQuery : null,
},
}))),
...charts.map((info, index) => ({
label: _t('resultTabs.chartNumber', { defaultMessage: 'Chart {number}', values: { number: info.resultIndex + 1 } }),
label: _t('resultTabs.chartNumber', {
defaultMessage: 'Chart {number}',
values: { number: info.resultIndex + 1 },
}),
isChart: true,
resultIndex: info.resultIndex,
component: JslChart,
@@ -175,8 +191,14 @@
tabs={allTabs}
menu={resultInfos.length > 0 && [
oneTab
? { text: _t('resultTabs.everyResultInSingleTab', { defaultMessage: 'Every result in single tab' }), onClick: () => setOneTabValue(false) }
: { text: _t('resultTabs.allResultsInOneTab', { defaultMessage: 'All results in one tab' }), onClick: () => setOneTabValue(true) },
? {
text: _t('resultTabs.everyResultInSingleTab', { defaultMessage: 'Every result in single tab' }),
onClick: () => setOneTabValue(false),
}
: {
text: _t('resultTabs.allResultsInOneTab', { defaultMessage: 'All results in one tab' }),
onClick: () => setOneTabValue(true),
},
]}
onUserChange={value => {
if (allTabs[value].isChart) {
@@ -55,6 +55,12 @@
defaultMessage: 'Skip confirmation when saving collection data (NoSQL)',
})}
/>
<FormCheckboxField
name="dataGrid.skipFetchAllConfirm"
label={_t('settings.confirmations.skipFetchAllConfirm', {
defaultMessage: 'Skip confirmation when fetching all rows',
})}
/>
</FormValues>
</div>
@@ -1,10 +1,13 @@
<script lang="ts">
import FormTextField from '../forms/FormTextField.svelte';
import FormSelectField from '../forms/FormSelectField.svelte';
import FormPasswordField from '../forms/FormPasswordField.svelte';
import { extensions, openedConnections, openedSingleDatabaseConnections } from '../stores';
import { getFormContext } from '../forms/FormProviderCore.svelte';
import FormTextAreaField from '../forms/FormTextAreaField.svelte';
import FormArgumentList from '../forms/FormArgumentList.svelte';
import { _t } from '../translations';
import { useConfig } from '../utility/metadataLoaders';
export let isFormReadOnly;
@@ -17,20 +20,81 @@
$: isConnected = $openedConnections.includes($values._id) || $openedSingleDatabaseConnections.includes($values._id);
$: advancedFields = driver?.getAdvancedConnectionFields ? driver?.getAdvancedConnectionFields() : null;
$: config = useConfig();
$: showConnectionFieldArgs = { config: $config };
$: showAllowedDatabases =
driver?.showConnectionField?.('allowedDatabases', $values, showConnectionFieldArgs) === true;
$: showProxy = driver?.showConnectionField?.('httpProxyUrl', $values, showConnectionFieldArgs) === true;
</script>
<FormTextAreaField
label={_t('connection.allowedDatabases', { defaultMessage: 'Allowed databases, one per line' })}
name="allowedDatabases"
disabled={isConnected || isFormReadOnly}
rows={8}
/>
<FormTextField
label={_t('connection.allowedDatabasesRegex', { defaultMessage: 'Allowed databases regular expression' })}
name="allowedDatabasesRegex"
disabled={isConnected || isFormReadOnly}
/>
{#if showAllowedDatabases}
<FormTextAreaField
label={_t('connection.allowedDatabases', { defaultMessage: 'Allowed databases, one per line' })}
name="allowedDatabases"
disabled={isConnected || isFormReadOnly}
rows={8}
/>
<FormTextField
label={_t('connection.allowedDatabasesRegex', { defaultMessage: 'Allowed databases regular expression' })}
name="allowedDatabasesRegex"
disabled={isConnected || isFormReadOnly}
/>
{/if}
{#if showProxy}
<FormTextField
label={_t('connection.httpProxyUrl', { defaultMessage: 'HTTP Proxy URL' })}
name="httpProxyUrl"
data-testid="ConnectionDriverFields_httpProxyUrl"
placeholder="http://proxy.example.com:8080"
disabled={isConnected || isFormReadOnly}
/>
<div class="row">
<div class="col-6 mr-1">
<FormTextField
label={_t('connection.httpProxyUser', { defaultMessage: 'HTTP Proxy User' })}
name="httpProxyUser"
data-testid="ConnectionDriverFields_httpProxyUser"
disabled={isConnected || isFormReadOnly}
templateProps={{ noMargin: true }}
/>
</div>
<div class="col-6 mr-1">
<FormPasswordField
label={_t('connection.httpProxyPassword', { defaultMessage: 'HTTP Proxy Password' })}
name="httpProxyPassword"
data-testid="ConnectionDriverFields_httpProxyPassword"
disabled={isConnected || isFormReadOnly}
templateProps={{ noMargin: true }}
/>
</div>
</div>
{/if}
{#if driver?.showConnectionField('defaultIsolationLevel', $values, showConnectionFieldArgs) && driver?.isolationLevels}
<FormSelectField
label={_t('connection.defaultIsolationLevel', { defaultMessage: 'Default isolation level' })}
isNative
name="defaultIsolationLevel"
defaultValue={driver.defaultIsolationLevel}
options={driver.isolationLevels.map(level => ({ label: level, value: level }))}
disabled={isConnected || isFormReadOnly}
data-testid="ConnectionAdvancedDriverFields_defaultIsolationLevel"
/>
{/if}
{#if advancedFields}
<FormArgumentList args={advancedFields} isReadOnly={isFormReadOnly} />
{/if}
<style>
.row {
margin: var(--dim-large-form-margin);
display: flex;
}
.col-6 {
flex: 1;
}
</style>
@@ -11,7 +11,13 @@
import FormSelectField from '../forms/FormSelectField.svelte';
import FormTextField from '../forms/FormTextField.svelte';
import { extensions, getCurrentConfig, openedConnections, openedSingleDatabaseConnections, toggledDatabases } from '../stores';
import {
extensions,
getCurrentConfig,
openedConnections,
openedSingleDatabaseConnections,
toggledDatabases,
} from '../stores';
import getElectron from '../utility/getElectron';
import { useAuthTypes, useConfig } from '../utility/metadataLoaders';
import FormColorField from '../forms/FormColorField.svelte';
@@ -100,7 +106,7 @@
$extensions.drivers
// .filter(driver => !driver.isElectronOnly || electron)
.filter(driver => $toggledDatabases.get(driver.title))
.map((driver) => ({
.map(driver => ({
value: driver.engine,
label: driver.title,
})),
@@ -36,6 +36,10 @@ export function getObjectSettingsValue(name, defaultValue) {
return res;
}
export function isAiDisabled(): boolean {
return getBoolSettingsValue('storage.disableAiFeatures', false);
}
export function getConnectionClickActionSetting(): 'connect' | 'openDetails' | 'none' {
return getStringSettingsValue('defaultAction.connectionClick', 'connect');
}
+464 -464
View File
@@ -1,466 +1,466 @@
<script lang="ts" context="module">
const getCurrentEditor = () => getActiveComponent('TableEditor');
registerCommand({
id: 'tableEditor.addColumn',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addColumn', { defaultMessage: 'Add column' }),
icon: 'icon add-column',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable(),
onClick: () => getCurrentEditor().addColumn(),
});
registerCommand({
id: 'tableEditor.addPrimaryKey',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addPrimaryKey', { defaultMessage: 'Add primary key' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.allowAddPrimaryKey(),
onClick: () => getCurrentEditor().addPrimaryKey(),
});
registerCommand({
id: 'tableEditor.addForeignKey',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addForeignKey', { defaultMessage: 'Add foreign key' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitForeignKeys,
onClick: () => getCurrentEditor().addForeignKey(),
});
registerCommand({
id: 'tableEditor.addIndex',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addIndex', { defaultMessage: 'Add index' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitIndexes,
onClick: () => getCurrentEditor().addIndex(),
});
registerCommand({
id: 'tableEditor.addUnique',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addUnique', { defaultMessage: 'Add unique' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitUniqueConstraints,
onClick: () => getCurrentEditor().addUnique(),
});
</script>
<script lang="ts">
import { editorDeleteColumn, editorDeleteConstraint } from 'dbgate-tools';
import _ from 'lodash';
import { onMount, tick } from 'svelte';
import invalidateCommands from '../commands/invalidateCommands';
import { getActiveComponent } from '../utility/createActivator';
import registerCommand from '../commands/registerCommand';
import ColumnLabel from '../elements/ColumnLabel.svelte';
import ConstraintLabel from '../elements/ConstraintLabel.svelte';
import ForeignKeyObjectListControl from '../elements/ForeignKeyObjectListControl.svelte';
import Link from '../elements/Link.svelte';
import ObjectListControl from '../elements/ObjectListControl.svelte';
import { showModal } from '../modals/modalTools';
import useEditorData from '../query/useEditorData';
import createActivator, { getActiveComponent } from '../utility/createActivator';
import { useDbCore } from '../utility/metadataLoaders';
import ColumnEditorModal from './ColumnEditorModal.svelte';
import ForeignKeyEditorModal from './ForeignKeyEditorModal.svelte';
import IndexEditorModal from './IndexEditorModal.svelte';
import PrimaryKeyEditorModal from './PrimaryKeyEditorModal.svelte';
import UniqueEditorModal from './UniqueEditorModal.svelte';
import ObjectFieldsEditor from '../elements/ObjectFieldsEditor.svelte';
import PrimaryKeyLikeListControl from './PrimaryKeyLikeListControl.svelte';
import { __t, _t } from '../translations';
export const activator = createActivator('TableEditor', true);
export let tableInfo;
export let setTableInfo;
export let dbInfo;
export let driver;
export let resetCounter;
export let isCreateTable;
export let schemaList;
$: isWritable = !!setTableInfo;
export function getIsWritable() {
return isWritable;
}
export function getDialect() {
return driver?.dialect;
}
export function addColumn() {
showModal(ColumnEditorModal, {
setTableInfo,
tableInfo,
driver,
onAddNext: async () => {
await tick();
addColumn();
},
});
}
export function allowAddPrimaryKey() {
return isWritable && !tableInfo?.primaryKey;
}
export function addPrimaryKey() {
showModal(PrimaryKeyEditorModal, {
setTableInfo,
tableInfo,
driver,
});
}
export function addForeignKey() {
showModal(ForeignKeyEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
});
}
export function addIndex() {
showModal(IndexEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
driver,
});
}
export function addUnique() {
showModal(UniqueEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
});
}
function getIndexTypeLabel(row) {
const indexType = row?.indexType?.toString()?.toUpperCase();
if (indexType === 'FULLTEXT') return 'FULLTEXT';
if (row?.isUnique) return 'UNIQUE';
if (indexType) return indexType;
return 'INDEX';
}
$: columns = tableInfo?.columns;
$: foreignKeys = tableInfo?.foreignKeys;
$: dependencies = tableInfo?.dependencies;
$: indexes = tableInfo?.indexes;
$: uniques = tableInfo?.uniques;
$: {
tableInfo;
invalidateCommands();
}
$: tableFormOptions = driver?.dialect?.getTableFormOptions?.(tableInfo?.objectId ? 'editTableForm' : 'newTableForm');
</script>
<div class="wrapper">
{#if tableInfo && (tableFormOptions || isCreateTable)}
{#key resetCounter}
<ObjectFieldsEditor
title={_t('tableEditor.tableproperties', { defaultMessage: 'Table properties' })}
fieldDefinitions={tableFormOptions ?? []}
pureNameTitle={isCreateTable ? _t('tableEditor.tablename', { defaultMessage: 'Table name' }) : null}
schemaList={isCreateTable && schemaList?.length >= 0 ? schemaList : null}
values={_.pick(tableInfo, ['schemaName', 'pureName', ...(tableFormOptions ?? []).map(x => x.name)])}
onChangeValues={vals => {
if (!_.isEmpty(vals) && setTableInfo) {
setTableInfo(tbl => ({ ...tbl, ...vals }));
}
}}
/>
{/key}
{/if}
<ObjectListControl
collection={columns?.map((x, index) => ({ ...x, ordinal: index + 1 }))}
title={_t('tableEditor.columnsCount', {
defaultMessage: 'Columns ({columnCount})',
values: { columnCount: columns?.length || 0 },
})}
emptyMessage={_t('tableEditor.nocolumnsdefined', { defaultMessage: 'No columns defined' })}
clickable
on:clickrow={e => showModal(ColumnEditorModal, { columnInfo: e.detail, tableInfo, setTableInfo, driver })}
onAddNew={isWritable ? addColumn : null}
displayNameFieldName="columnName"
multipleItemsActions={[
{
text: _t('tableEditor.remove', { defaultMessage: 'Remove' }),
icon: 'icon delete',
onClick: selected => {
setTableInfo(tbl => {
const newColumns = tbl.columns.filter(x => !selected.find(y => y.columnName === x.columnName));
return { ...tbl, columns: newColumns };
});
},
},
{
text: _t('tableEditor.copynames', { defaultMessage: 'Copy names' }),
icon: 'icon copy',
onClick: selected => {
const names = selected.map(x => x.columnName).join('\n');
navigator.clipboard.writeText(names);
},
},
{
text: _t('tableEditor.copydefinitions', { defaultMessage: 'Copy definitions' }),
icon: 'icon copy',
onClick: selected => {
const names = selected.map(x => `${x.columnName} ${x.dataType}${x.notNull ? ' NOT NULL' : ''}`).join(',\n');
navigator.clipboard.writeText(names);
},
},
]}
columns={[
!driver?.dialect?.specificNullabilityImplementation && {
fieldName: 'notNull',
header: _t('tableEditor.nullability', { defaultMessage: 'Nullability' }),
sortable: true,
slot: 0,
},
{
fieldName: 'dataType',
header: _t('tableEditor.dataType', { defaultMessage: 'Data type' }),
sortable: true,
filterable: true,
},
{
fieldName: 'defaultValue',
header: _t('tableEditor.defaultValue', { defaultMessage: 'Default value' }),
sortable: true,
filterable: true,
},
driver?.dialect?.columnProperties?.isSparse && {
fieldName: 'isSparse',
header: _t('tableEditor.isSparse', { defaultMessage: 'Is Sparse' }),
sortable: true,
slot: 1,
},
{
fieldName: 'computedExpression',
header: _t('tableEditor.computedExpression', { defaultMessage: 'Computed Expression' }),
sortable: true,
filterable: true,
},
driver?.dialect?.columnProperties?.isPersisted && {
fieldName: 'isPersisted',
header: _t('tableEditor.isPersisted', { defaultMessage: 'Is Persisted' }),
sortable: true,
slot: 2,
},
driver?.dialect?.columnProperties?.isUnsigned && {
fieldName: 'isUnsigned',
header: _t('tableEditor.isUnsigned', { defaultMessage: 'Unsigned' }),
sortable: true,
slot: 4,
},
driver?.dialect?.columnProperties?.isZerofill && {
fieldName: 'isZerofill',
header: _t('tableEditor.isZeroFill', { defaultMessage: 'Zero fill' }),
sortable: true,
slot: 5,
},
driver?.dialect?.columnProperties?.columnComment && {
fieldName: 'columnComment',
header: _t('tableEditor.columnComment', { defaultMessage: 'Comment' }),
sortable: true,
filterable: true,
},
isWritable
? {
fieldName: 'actions',
filterable: false,
slot: 3,
}
: null,
]}
>
<svelte:fragment slot="0" let:row
>{row?.notNull
? _t('tableEditor.notnull', { defaultMessage: 'NOT NULL' })
: _t('tableEditor.null', { defaultMessage: 'NULL' })}</svelte:fragment
>
<svelte:fragment slot="1" let:row
>{row?.isSparse
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="2" let:row
>{row?.isPersisted
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="3" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteColumn(tbl, row));
}}>{_t('tableEditor.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
<svelte:fragment slot="4" let:row
>{row?.isUnsigned
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="5" let:row
>{row?.isZerofill
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="name" let:row><ColumnLabel {...row} forceIcon /></svelte:fragment>
</ObjectListControl>
<PrimaryKeyLikeListControl {tableInfo} {setTableInfo} {isWritable} {driver} />
{#if driver?.dialect?.sortingKeys}
<PrimaryKeyLikeListControl
{tableInfo}
{setTableInfo}
{isWritable}
{driver}
constraintLabel="sorting key"
constraintType="sortingKey"
/>
{/if}
{#if !driver?.dialect?.omitIndexes}
<ObjectListControl
collection={indexes}
onAddNew={isWritable && columns?.length > 0 ? addIndex : null}
title={_t('tableEditor.indexes', {
defaultMessage: 'Indexes ({indexCount})',
values: { indexCount: indexes?.length || 0 },
})}
emptyMessage={isWritable ? _t('tableEditor.noindexdefined', { defaultMessage: 'No index defined' }) : null}
clickable
on:clickrow={e => showModal(IndexEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo, driver })}
columns={[
{
fieldName: 'columns',
header: _t('tableEditor.columns', { defaultMessage: 'Columns' }),
slot: 0,
sortable: true,
},
{
fieldName: 'indexType',
header: _t('tableEditor.indexType', { defaultMessage: 'Type' }),
slot: 1,
},
isWritable
? {
fieldName: 'actions',
slot: 2,
}
: null,
]}
>
<svelte:fragment slot="name" let:row><ConstraintLabel {...row} /></svelte:fragment>
<svelte:fragment slot="0" let:row>{row?.columns.map(x => x.columnName).join(', ')}</svelte:fragment>
<svelte:fragment slot="1" let:row>{getIndexTypeLabel(row)}</svelte:fragment>
<svelte:fragment slot="2" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteConstraint(tbl, row));
}}>{_t('common.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
</ObjectListControl>
{/if}
{#if !driver?.dialect?.omitUniqueConstraints}
<ObjectListControl
collection={uniques}
onAddNew={isWritable && columns?.length > 0 ? addUnique : null}
title={_t('tableEditor.uniqueConstraints', {
defaultMessage: 'Unique constraints ({constraintCount})',
values: { constraintCount: uniques?.length || 0 },
})}
emptyMessage={isWritable ? _t('tableEditor.nouniquedefined', { defaultMessage: 'No unique defined' }) : null}
clickable
on:clickrow={e => showModal(UniqueEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo })}
columns={[
{
fieldName: 'columns',
header: _t('tableEditor.columns', { defaultMessage: 'Columns' }),
slot: 0,
sortable: true,
},
isWritable
? {
fieldName: 'actions',
sortable: true,
slot: 1,
}
: null,
]}
>
<svelte:fragment slot="name" let:row><ConstraintLabel {...row} /></svelte:fragment>
<svelte:fragment slot="0" let:row>{row?.columns.map(x => x.columnName).join(', ')}</svelte:fragment>
<svelte:fragment slot="1" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteConstraint(tbl, row));
}}>{_t('common.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
</ObjectListControl>
{/if}
{#if !driver?.dialect?.omitForeignKeys}
<ForeignKeyObjectListControl
collection={foreignKeys}
onAddNew={isWritable && columns?.length > 0 ? addForeignKey : null}
title={_t('tableEditor.foreignKeys', {
defaultMessage: 'Foreign keys ({foreignKeyCount})',
values: { foreignKeyCount: foreignKeys?.length || 0 },
})}
emptyMessage={isWritable
? _t('tableEditor.noforeignkeydefined', { defaultMessage: 'No foreign key defined' })
: null}
clickable
onRemove={row => setTableInfo(tbl => editorDeleteConstraint(tbl, row))}
on:clickrow={e => showModal(ForeignKeyEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo, dbInfo })}
/>
<ForeignKeyObjectListControl
collection={dependencies}
title={_t('tableEditor.dependencies', { defaultMessage: 'Dependencies' })}
/>
{/if}
</div>
<style>
.wrapper {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: var(--theme-content-background);
overflow: auto;
}
</style>
import { __t } from '../translations';
const getCurrentEditor = () => getActiveComponent('TableEditor');
registerCommand({
id: 'tableEditor.addColumn',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addColumn', { defaultMessage: 'Add column' }),
icon: 'icon add-column',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable(),
onClick: () => getCurrentEditor().addColumn(),
});
registerCommand({
id: 'tableEditor.addPrimaryKey',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addPrimaryKey', { defaultMessage: 'Add primary key' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.allowAddPrimaryKey(),
onClick: () => getCurrentEditor().addPrimaryKey(),
});
registerCommand({
id: 'tableEditor.addForeignKey',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addForeignKey', { defaultMessage: 'Add foreign key' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitForeignKeys,
onClick: () => getCurrentEditor().addForeignKey(),
});
registerCommand({
id: 'tableEditor.addIndex',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addIndex', { defaultMessage: 'Add index' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitIndexes,
onClick: () => getCurrentEditor().addIndex(),
});
registerCommand({
id: 'tableEditor.addUnique',
category: __t('tableEditor', { defaultMessage: 'Table editor' }),
name: __t('tableEditor.addUnique', { defaultMessage: 'Add unique' }),
icon: 'icon add-key',
toolbar: true,
isRelatedToTab: true,
testEnabled: () => getCurrentEditor()?.getIsWritable() && !getCurrentEditor()?.getDialect()?.omitUniqueConstraints,
onClick: () => getCurrentEditor().addUnique(),
});
</script>
<script lang="ts">
import { editorDeleteColumn, editorDeleteConstraint } from 'dbgate-tools';
import _ from 'lodash';
import { onMount, tick } from 'svelte';
import invalidateCommands from '../commands/invalidateCommands'; import ColumnLabel from '../elements/ColumnLabel.svelte';
import ConstraintLabel from '../elements/ConstraintLabel.svelte';
import ForeignKeyObjectListControl from '../elements/ForeignKeyObjectListControl.svelte';
import Link from '../elements/Link.svelte';
import ObjectListControl from '../elements/ObjectListControl.svelte';
import { showModal } from '../modals/modalTools';
import useEditorData from '../query/useEditorData';
import createActivator from '../utility/createActivator';
import { useDbCore } from '../utility/metadataLoaders';
import ColumnEditorModal from './ColumnEditorModal.svelte';
import ForeignKeyEditorModal from './ForeignKeyEditorModal.svelte';
import IndexEditorModal from './IndexEditorModal.svelte';
import PrimaryKeyEditorModal from './PrimaryKeyEditorModal.svelte';
import UniqueEditorModal from './UniqueEditorModal.svelte';
import ObjectFieldsEditor from '../elements/ObjectFieldsEditor.svelte';
import PrimaryKeyLikeListControl from './PrimaryKeyLikeListControl.svelte';
import { _t } from '../translations';
export const activator = createActivator('TableEditor', true);
export let tableInfo;
export let setTableInfo;
export let dbInfo;
export let driver;
export let resetCounter;
export let isCreateTable;
export let schemaList;
$: isWritable = !!setTableInfo;
export function getIsWritable() {
return isWritable;
}
export function getDialect() {
return driver?.dialect;
}
export function addColumn() {
showModal(ColumnEditorModal, {
setTableInfo,
tableInfo,
driver,
onAddNext: async () => {
await tick();
addColumn();
},
});
}
export function allowAddPrimaryKey() {
return isWritable && !tableInfo?.primaryKey;
}
export function addPrimaryKey() {
showModal(PrimaryKeyEditorModal, {
setTableInfo,
tableInfo,
driver,
});
}
export function addForeignKey() {
showModal(ForeignKeyEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
});
}
export function addIndex() {
showModal(IndexEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
driver,
});
}
export function addUnique() {
showModal(UniqueEditorModal, {
setTableInfo,
tableInfo,
dbInfo,
});
}
function getIndexTypeLabel(row) {
const indexType = row?.indexType?.toString()?.toUpperCase();
if (indexType === 'FULLTEXT') return 'FULLTEXT';
if (row?.isUnique) return 'UNIQUE';
if (indexType) return indexType;
return 'INDEX';
}
$: columns = tableInfo?.columns;
$: foreignKeys = tableInfo?.foreignKeys;
$: dependencies = tableInfo?.dependencies;
$: indexes = tableInfo?.indexes;
$: uniques = tableInfo?.uniques;
$: {
tableInfo;
invalidateCommands();
}
$: tableFormOptions = driver?.dialect?.getTableFormOptions?.(tableInfo?.objectId ? 'editTableForm' : 'newTableForm');
</script>
<div class="wrapper">
{#if tableInfo && (tableFormOptions || isCreateTable)}
{#key resetCounter}
<ObjectFieldsEditor
title={_t('tableEditor.tableproperties', { defaultMessage: 'Table properties' })}
fieldDefinitions={tableFormOptions ?? []}
pureNameTitle={isCreateTable ? _t('tableEditor.tablename', { defaultMessage: 'Table name' }) : null}
schemaList={isCreateTable && schemaList?.length >= 0 ? schemaList : null}
values={_.pick(tableInfo, ['schemaName', 'pureName', ...(tableFormOptions ?? []).map(x => x.name)])}
onChangeValues={vals => {
if (!_.isEmpty(vals) && setTableInfo) {
setTableInfo(tbl => ({ ...tbl, ...vals }));
}
}}
/>
{/key}
{/if}
<ObjectListControl
collection={columns?.map((x, index) => ({ ...x, ordinal: index + 1 }))}
title={_t('tableEditor.columnsCount', {
defaultMessage: 'Columns ({columnCount})',
values: { columnCount: columns?.length || 0 },
})}
emptyMessage={_t('tableEditor.nocolumnsdefined', { defaultMessage: 'No columns defined' })}
clickable
on:clickrow={e => showModal(ColumnEditorModal, { columnInfo: e.detail, tableInfo, setTableInfo, driver })}
onAddNew={isWritable ? addColumn : null}
displayNameFieldName="columnName"
multipleItemsActions={[
{
text: _t('tableEditor.remove', { defaultMessage: 'Remove' }),
icon: 'icon delete',
onClick: selected => {
setTableInfo(tbl => {
const newColumns = tbl.columns.filter(x => !selected.find(y => y.columnName === x.columnName));
return { ...tbl, columns: newColumns };
});
},
},
{
text: _t('tableEditor.copynames', { defaultMessage: 'Copy names' }),
icon: 'icon copy',
onClick: selected => {
const names = selected.map(x => x.columnName).join('\n');
navigator.clipboard.writeText(names);
},
},
{
text: _t('tableEditor.copydefinitions', { defaultMessage: 'Copy definitions' }),
icon: 'icon copy',
onClick: selected => {
const names = selected.map(x => `${x.columnName} ${x.dataType}${x.notNull ? ' NOT NULL' : ''}`).join(',\n');
navigator.clipboard.writeText(names);
},
},
]}
columns={[
!driver?.dialect?.specificNullabilityImplementation && {
fieldName: 'notNull',
header: _t('tableEditor.nullability', { defaultMessage: 'Nullability' }),
sortable: true,
slot: 0,
},
{
fieldName: 'dataType',
header: _t('tableEditor.dataType', { defaultMessage: 'Data type' }),
sortable: true,
filterable: true,
},
{
fieldName: 'defaultValue',
header: _t('tableEditor.defaultValue', { defaultMessage: 'Default value' }),
sortable: true,
filterable: true,
},
driver?.dialect?.columnProperties?.isSparse && {
fieldName: 'isSparse',
header: _t('tableEditor.isSparse', { defaultMessage: 'Is Sparse' }),
sortable: true,
slot: 1,
},
{
fieldName: 'computedExpression',
header: _t('tableEditor.computedExpression', { defaultMessage: 'Computed Expression' }),
sortable: true,
filterable: true,
},
driver?.dialect?.columnProperties?.isPersisted && {
fieldName: 'isPersisted',
header: _t('tableEditor.isPersisted', { defaultMessage: 'Is Persisted' }),
sortable: true,
slot: 2,
},
driver?.dialect?.columnProperties?.isUnsigned && {
fieldName: 'isUnsigned',
header: _t('tableEditor.isUnsigned', { defaultMessage: 'Unsigned' }),
sortable: true,
slot: 4,
},
driver?.dialect?.columnProperties?.isZerofill && {
fieldName: 'isZerofill',
header: _t('tableEditor.isZeroFill', { defaultMessage: 'Zero fill' }),
sortable: true,
slot: 5,
},
driver?.dialect?.columnProperties?.columnComment && {
fieldName: 'columnComment',
header: _t('tableEditor.columnComment', { defaultMessage: 'Comment' }),
sortable: true,
filterable: true,
},
isWritable
? {
fieldName: 'actions',
filterable: false,
slot: 3,
}
: null,
]}
>
<svelte:fragment slot="0" let:row
>{row?.notNull
? _t('tableEditor.notnull', { defaultMessage: 'NOT NULL' })
: _t('tableEditor.null', { defaultMessage: 'NULL' })}</svelte:fragment
>
<svelte:fragment slot="1" let:row
>{row?.isSparse
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="2" let:row
>{row?.isPersisted
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="3" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteColumn(tbl, row));
}}>{_t('tableEditor.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
<svelte:fragment slot="4" let:row
>{row?.isUnsigned
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="5" let:row
>{row?.isZerofill
? _t('tableEditor.yes', { defaultMessage: 'YES' })
: _t('tableEditor.no', { defaultMessage: 'NO' })}</svelte:fragment
>
<svelte:fragment slot="name" let:row><ColumnLabel {...row} forceIcon /></svelte:fragment>
</ObjectListControl>
<PrimaryKeyLikeListControl {tableInfo} {setTableInfo} {isWritable} {driver} />
{#if driver?.dialect?.sortingKeys}
<PrimaryKeyLikeListControl
{tableInfo}
{setTableInfo}
{isWritable}
{driver}
constraintLabel="sorting key"
constraintType="sortingKey"
/>
{/if}
{#if !driver?.dialect?.omitIndexes}
<ObjectListControl
collection={indexes}
onAddNew={isWritable && columns?.length > 0 ? addIndex : null}
title={_t('tableEditor.indexes', {
defaultMessage: 'Indexes ({indexCount})',
values: { indexCount: indexes?.length || 0 },
})}
emptyMessage={isWritable ? _t('tableEditor.noindexdefined', { defaultMessage: 'No index defined' }) : null}
clickable
on:clickrow={e => showModal(IndexEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo, driver })}
columns={[
{
fieldName: 'columns',
header: _t('tableEditor.columns', { defaultMessage: 'Columns' }),
slot: 0,
sortable: true,
},
{
fieldName: 'indexType',
header: _t('tableEditor.indexType', { defaultMessage: 'Type' }),
slot: 1,
},
isWritable
? {
fieldName: 'actions',
slot: 2,
}
: null,
]}
>
<svelte:fragment slot="name" let:row><ConstraintLabel {...row} /></svelte:fragment>
<svelte:fragment slot="0" let:row>{row?.columns.map(x => x.columnName).join(', ')}</svelte:fragment>
<svelte:fragment slot="1" let:row>{getIndexTypeLabel(row)}</svelte:fragment>
<svelte:fragment slot="2" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteConstraint(tbl, row));
}}>{_t('common.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
</ObjectListControl>
{/if}
{#if !driver?.dialect?.omitUniqueConstraints}
<ObjectListControl
collection={uniques}
onAddNew={isWritable && columns?.length > 0 ? addUnique : null}
title={_t('tableEditor.uniqueConstraints', {
defaultMessage: 'Unique constraints ({constraintCount})',
values: { constraintCount: uniques?.length || 0 },
})}
emptyMessage={isWritable ? _t('tableEditor.nouniquedefined', { defaultMessage: 'No unique defined' }) : null}
clickable
on:clickrow={e => showModal(UniqueEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo })}
columns={[
{
fieldName: 'columns',
header: _t('tableEditor.columns', { defaultMessage: 'Columns' }),
slot: 0,
sortable: true,
},
isWritable
? {
fieldName: 'actions',
sortable: true,
slot: 1,
}
: null,
]}
>
<svelte:fragment slot="name" let:row><ConstraintLabel {...row} /></svelte:fragment>
<svelte:fragment slot="0" let:row>{row?.columns.map(x => x.columnName).join(', ')}</svelte:fragment>
<svelte:fragment slot="1" let:row
><Link
onClick={e => {
e.stopPropagation();
setTableInfo(tbl => editorDeleteConstraint(tbl, row));
}}>{_t('common.remove', { defaultMessage: 'Remove' })}</Link
></svelte:fragment
>
</ObjectListControl>
{/if}
{#if !driver?.dialect?.omitForeignKeys}
<ForeignKeyObjectListControl
collection={foreignKeys}
onAddNew={isWritable && columns?.length > 0 ? addForeignKey : null}
title={_t('tableEditor.foreignKeys', {
defaultMessage: 'Foreign keys ({foreignKeyCount})',
values: { foreignKeyCount: foreignKeys?.length || 0 },
})}
emptyMessage={isWritable
? _t('tableEditor.noforeignkeydefined', { defaultMessage: 'No foreign key defined' })
: null}
clickable
onRemove={row => setTableInfo(tbl => editorDeleteConstraint(tbl, row))}
on:clickrow={e => showModal(ForeignKeyEditorModal, { constraintInfo: e.detail, tableInfo, setTableInfo, dbInfo })}
/>
<ForeignKeyObjectListControl
collection={dependencies}
title={_t('tableEditor.dependencies', { defaultMessage: 'Dependencies' })}
/>
{/if}
</div>
<style>
.wrapper {
position: absolute;
left: 0;
top: 0;
right: 0;
bottom: 0;
background-color: var(--theme-content-background);
overflow: auto;
}
</style>

Some files were not shown because too many files have changed in this diff Show More