Compare commits

..

302 Commits

Author SHA1 Message Date
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
SPRINX0\prochazka 0ab8afb838 v7.1.1-packer-beta.3 2026-02-27 13:36:37 +01:00
SPRINX0\prochazka d50999547f v7.1.1-premium-beta.2 2026-02-27 13:36:14 +01:00
CI workflows 04741b0eba chore: auto-update github workflows 2026-02-27 12:35:44 +00:00
SPRINX0\prochazka ba86fe32e7 comment out azure build 2026-02-27 13:35:24 +01:00
CI workflows 9deb7d7fdc chore: auto-update github workflows 2026-02-27 12:34:08 +00:00
CI workflows 55eb64e5ca Update pro ref 2026-02-27 12:33:52 +00:00
Jan Prochazka a5f50f3f2b SYNC: Merge pull request #68 from dbgate/feature/dynamodb-plugin 2026-02-27 12:33:39 +00:00
Jan Prochazka 47214eb5b3 SYNC: Merge pull request #72 from dbgate/feature-firebird-fixes 2026-02-27 12:24:38 +00:00
CI workflows 599509d417 chore: auto-update github workflows 2026-02-27 08:20:08 +00:00
CI workflows 9d366fc359 Update pro ref 2026-02-27 08:19:53 +00:00
SPRINX0\prochazka 0e1ed0bde6 SYNC: upgraded dbgate-query-splitter 2026-02-27 08:19:41 +00:00
CI workflows 6ad7824bf2 chore: auto-update github workflows 2026-02-27 08:06:47 +00:00
CI workflows 1174f51c07 Update pro ref 2026-02-27 08:06:31 +00:00
Jan Prochazka 1950dda1ab SYNC: Merge pull request #70 from dbgate/feature/new-gql-query 2026-02-27 08:06:19 +00:00
Jan Prochazka 8231b6d5be SYNC: Merge pull request #71 from dbgate/feature/reset-virtual-scroll 2026-02-27 08:03:36 +00:00
Jan Prochazka 0feacbe6eb Merge pull request #1368 from dbgate/feature/driver-selection
Set default selected item to 'general' in SettingsTab and WidgetIconP…
2026-02-27 08:21:16 +01:00
Jan Prochazka 80b5f5adca SYNC: Merge pull request #65 from dbgate/feature/filter-bigint 2026-02-26 08:48:23 +00:00
CI workflows 13650f36e6 chore: auto-update github workflows 2026-02-26 08:47:23 +00:00
CI workflows 3f58d99069 Update pro ref 2026-02-26 08:47:03 +00:00
CI workflows 0c8a025cf6 chore: auto-update github workflows 2026-02-26 08:33:59 +00:00
CI workflows 5014df4859 Update pro ref 2026-02-26 08:33:42 +00:00
SPRINX0\prochazka 34a491e2ef v7.1.1-premium-beta.1 2026-02-25 14:07:03 +01:00
Jan Prochazka 884e4ca88e SYNC: Merge pull request #67 from dbgate/feature/connfix 2026-02-25 13:01:09 +00:00
CI workflows a670c5e86c chore: auto-update github workflows 2026-02-25 12:54:58 +00:00
CI workflows af1fba79be Update pro ref 2026-02-25 12:54:40 +00:00
Jan Prochazka ac44de0bf4 SYNC: Merge pull request #66 from dbgate/team-premium-permis-fix-2 2026-02-25 12:54:28 +00:00
Stela Augustinova f013a241ce Merge pull request #1367 from dbgate/feature/disable-cell-data-view
Add setting to disable automatic Cell Data View opening
2026-02-25 10:31:42 +01:00
Stela Augustinova 0e29a7206d Prevent unnecessary updates in handleUserChange when the selected item remains unchanged 2026-02-25 09:27:50 +01:00
Stela Augustinova 689b3f299c Prevent unnecessary updates in handleUserChange when the selected item remains unchanged 2026-02-25 09:20:08 +01:00
Stela Augustinova 02ccb990bd Remove default selected item from SettingsTab in stdCommands and WidgetIconPanel 2026-02-25 09:18:51 +01:00
Stela Augustinova 61fe4f0d57 Set default selected item to 'general' in SettingsTab and WidgetIconPanel 2026-02-25 09:09:17 +01:00
Stela Augustinova 0a920195d5 Add setting to disable automatic Cell Data View opening 2026-02-25 07:14:31 +01:00
SPRINX0\prochazka 18896bf56d v7.1.0 2026-02-24 15:18:24 +01:00
SPRINX0\prochazka 098c9041a0 changelog 2026-02-24 15:15:05 +01:00
CI workflows 61a41d8eb2 chore: auto-update github workflows 2026-02-24 13:40:14 +00:00
CI workflows e76073d5c8 Update pro ref 2026-02-24 13:39:55 +00:00
Jan Prochazka 8c34added7 SYNC: Merge pull request #63 from dbgate/feature/test-api-e2e 2026-02-24 13:39:42 +00:00
SPRINX0\prochazka 66fc6b93ae v7.0.7-premium-beta.13 2026-02-24 13:17:35 +01:00
SPRINX0\prochazka 881d5a8008 v7.0.7-beta.12 2026-02-24 12:51:25 +01:00
Jan Prochazka 5d263de954 SYNC: Merge pull request #62 from dbgate/feature/refactor-rolldown 2026-02-24 11:50:42 +00:00
SPRINX0\prochazka c8d0494000 v7.0.7-beta.11 2026-02-24 12:29:12 +01:00
SPRINX0\prochazka a9b48b5aa5 v7.0.7-premium-beta.10 2026-02-24 12:25:37 +01:00
SPRINX0\prochazka f08a951eef SYNC: Refactor DriverSettings and stores to manage hidden database engines 2026-02-24 11:23:14 +00:00
SPRINX0\prochazka 8758a4bc86 SYNC: filter extensions in active drivers 2026-02-24 10:13:49 +00:00
Jan Prochazka aae328f8c8 Merge pull request #1365 from dbgate/feature/driver-selection
Feature/driver selection
2026-02-24 11:07:43 +01:00
Stela Augustinova 1953578a33 Fix driver reference checks in DriverSettings and stores for improved stability 2026-02-24 11:02:38 +01:00
Stela Augustinova 543bdd79d9 Fix filter syntax in ConnectionDriverFields to improve driver selection logic 2026-02-24 10:50:11 +01:00
Stela Augustinova e0e1a3c8e4 Enhance DriverSettings component to handle undefined drivers and improve check-all functionality 2026-02-24 10:33:30 +01:00
Stela Augustinova f1d84f448e Refactor FormConnectionTypeSelector to improve layout and integrate FontIcon in driver settings button 2026-02-24 10:14:32 +01:00
Jan Prochazka 7c5c21f15d SYNC: Merge pull request #56 from dbgate/feature/flipping-tabs-crash 2026-02-24 09:05:43 +00:00
Jan Prochazka 41ffaeebe3 Merge pull request #1362 from david-pivonka/fix/clickhouse-cte-results
fix(clickhouse): show query results for CTE (WITH) queries
2026-02-24 09:59:48 +01:00
SPRINX0\prochazka 5d9b44b647 SYNC: removed experimental flags 2026-02-24 08:26:44 +00:00
CI workflows a18d2c5650 chore: auto-update github workflows 2026-02-24 08:21:49 +00:00
CI workflows e0379bcf12 Update pro ref 2026-02-24 08:21:35 +00:00
SPRINX0\prochazka e91242d5a2 SYNC: use string instead of datatime in password_reset_token (for compatibility) 2026-02-24 08:21:23 +00:00
SPRINX0\prochazka 8177187b3a v7.0.7-premium-beta.9 2026-02-24 08:57:50 +01:00
CI workflows 6b3e1144bc chore: auto-update github workflows 2026-02-24 07:56:50 +00:00
CI workflows dfec88f52d Update pro ref 2026-02-24 07:56:34 +00:00
SPRINX0\prochazka b8df67659a v7.0.7-premium-beta.8 2026-02-24 08:25:26 +01:00
SPRINX0\prochazka 861da64581 fix 2026-02-24 08:24:14 +01:00
CI workflows ab147a2cc9 chore: auto-update github workflows 2026-02-24 07:20:26 +00:00
CI workflows e13191e894 Update pro ref 2026-02-24 07:20:09 +00:00
SPRINX0\prochazka 7f69ea8dc0 SYNC: fixed links (dbgate.org => dbgate.io) 2026-02-24 07:19:58 +00:00
SPRINX0\prochazka ef2140696b publish NPM plugins 2026-02-24 08:13:05 +01:00
SPRINX0\prochazka 4607900c3b SYNC: yarn.lock 2026-02-24 07:05:31 +00:00
CI workflows 3258d55796 chore: auto-update github workflows 2026-02-24 06:58:55 +00:00
CI workflows 35e6966c39 Update pro ref 2026-02-24 06:58:38 +00:00
SPRINX0\prochazka 885756b259 removed ADD plugin 2026-02-24 07:57:10 +01:00
Jan Prochazka 5fbc1b937c SYNC: Merge pull request #55 from dbgate/feature/dynamodb-plugin 2026-02-24 06:51:31 +00:00
CI workflows 7e444e9fc2 chore: auto-update github workflows 2026-02-24 06:35:37 +00:00
CI workflows c051237914 Update pro ref 2026-02-24 06:35:20 +00:00
Jan Prochazka 3855b0dd28 SYNC: Merge pull request #61 from dbgate/feature/gql-variables-mutation 2026-02-24 06:35:09 +00:00
Stela Augustinova afcc9e096a Add FormConnectionTypeSelector component and integrate into ConnectionDriverFields 2026-02-23 15:48:46 +01:00
Stela Augustinova f4df1fbff4 Add separator line in DriverSettings for improved UI clarity 2026-02-23 15:33:12 +01:00
Jan Prochazka 45b3a5af91 SYNC: Merge pull request #60 from dbgate/feature/redis-key-loading 2026-02-23 12:43:04 +00:00
Stela Augustinova f54b18e652 Refactor DriverSettings component to enhance check-all functionality and improve UI layout 2026-02-23 07:52:42 +01:00
Stela Augustinova b1210d19ad Add DriverSettings component and integrate into SettingsTab 2026-02-20 18:13:56 +01:00
CI workflows 21cbcc79c6 chore: auto-update github workflows 2026-02-20 14:33:44 +00:00
CI workflows a7d0c8fb0f Update pro ref 2026-02-20 14:33:26 +00:00
SPRINX0\prochazka 1e3dc54d81 v7.0.7-premium-beta.7 2026-02-20 13:25:12 +01:00
CI workflows 48f294fd83 chore: auto-update github workflows 2026-02-20 12:22:39 +00:00
CI workflows 298ad0de4b Update pro ref 2026-02-20 12:22:17 +00:00
Jan Prochazka c7953f9231 SYNC: Merge pull request #59 from dbgate/feature/graphql-connection-view 2026-02-20 12:22:05 +00:00
SPRINX0\prochazka afd97eae7d v7.0.7-premium-beta.6 2026-02-19 11:00:36 +01:00
Jan Prochazka f4e558b7e8 SYNC: Merge pull request #58 from dbgate/feature/array-grid-improvements 2026-02-19 09:58:30 +00:00
SPRINX0\prochazka 12c99c646e v7.0.7-premium-beta.5 2026-02-18 19:11:40 +01:00
CI workflows 6c1a2eedbe chore: auto-update github workflows 2026-02-18 17:52:44 +00:00
CI workflows 8a73216035 Update pro ref 2026-02-18 17:52:25 +00:00
Jan Prochazka c6a93f12f7 SYNC: Merge pull request #57 from dbgate/feature/improve-api-capabilities 2026-02-18 17:52:13 +00:00
CI workflows 09f44d94b3 chore: auto-update github workflows 2026-02-18 15:32:46 +00:00
CI workflows c26748154a Update pro ref 2026-02-18 15:32:30 +00:00
Jan Prochazka 2474f915d4 SYNC: Merge pull request #54 from dbgate/feature/odata-api 2026-02-18 15:32:17 +00:00
Jan Prochazka 53f940cd23 SYNC: Merge pull request #52 from dbgate/feature/group-by-timestamp 2026-02-18 14:45:25 +00:00
CI workflows 991b648854 chore: auto-update github workflows 2026-02-18 07:23:25 +00:00
CI workflows 663f057a9a Update pro ref 2026-02-18 07:23:08 +00:00
Jan Prochazka 61963fb824 SYNC: Merge pull request #53 from dbgate/feature/graphql-connection-display 2026-02-18 07:22:55 +00:00
SPRINX0\prochazka bdf3cf5b36 SYNC: Enhance GraphQL query parsing to include argument values and update related components to handle new structure 2026-02-17 13:46:14 +00:00
CI workflows 5cc459594b chore: auto-update github workflows 2026-02-17 13:22:07 +00:00
CI workflows 8d315e52df Update pro ref 2026-02-17 13:21:50 +00:00
SPRINX0\prochazka 48a24a8704 SYNC: Add support for GraphQL connection queries and enhance API type handling 2026-02-17 13:21:38 +00:00
SPRINX0\prochazka cdce52f0e5 v7.0.7-premium-beta.4 2026-02-17 10:46:42 +01:00
SPRINX0\prochazka d12ccbeac4 SYNC: Reduce default maximum depth for GraphQL explorer options from 6 to 2 2026-02-17 09:46:01 +00:00
David Pivoňka 0b1620105a fix(clickhouse): show query results for CTE (WITH) queries
The stream() method used a regex that only matched queries starting
with SELECT. Queries using CTEs (WITH ... SELECT) were incorrectly
sent through client.command() which discards results, causing the
query console to show "Query execution finished" with no data grid.

Update the regex to also match queries starting with WITH so they
flow through client.query() and display results correctly.

Fixes #1138

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-02-17 08:37:32 +01:00
CI workflows 2ae9c98acb chore: auto-update github workflows 2026-02-17 07:04:41 +00:00
CI workflows ed00848a1e Update pro ref 2026-02-17 07:04:24 +00:00
SPRINX0\prochazka 06f7741dbf SYNC: Refactor REST authentication handling and improve connection utilities 2026-02-17 07:04:12 +00:00
CI workflows 8d3b7cace8 chore: auto-update github workflows 2026-02-16 16:03:32 +00:00
CI workflows 8f0775e337 Update pro ref 2026-02-16 16:03:16 +00:00
SPRINX0\prochazka 444cb6aa0c SYNC: errors assign 2026-02-16 16:03:05 +00:00
SPRINX0\prochazka b4acc19ea2 v7.0.7-premium-beta.3 2026-02-16 16:56:20 +01:00
SPRINX0\prochazka 1ef17cd861 SYNC: Masking connections improved #1357 2026-02-16 15:48:21 +00:00
SPRINX0\prochazka e564e930e5 env config 2026-02-16 16:23:07 +01:00
SPRINX0\prochazka a30badbbe0 enhance connection masking #1357 2026-02-16 16:23:02 +01:00
SPRINX0\prochazka b33d21fdb3 v7.0.7-beta.2 2026-02-16 16:11:56 +01:00
SPRINX0\prochazka 78da83f7db fix: optimize query string handling in executeRestApiEndpoint 2026-02-16 15:09:30 +01:00
SPRINX0\prochazka 8f6313d4ec fix: update build process to include dbgate-rest and adjust dependencies 2026-02-16 14:55:46 +01:00
Jan Prochazka 14962a5622 Merge pull request #1360 from dbgate/feature/numeric-sum
Numeric handling in DataGridCore
2026-02-16 14:32:41 +01:00
Jan Prochazka b8048e7592 Merge pull request #1361 from dbgate/feature/mssql-fk-duplicate
Feature/mssql fk duplicate
2026-02-16 14:25:33 +01:00
SPRINX0\prochazka cf9823e123 v7.0.7-beta.1 2026-02-16 14:17:16 +01:00
SPRINX0\prochazka 1667dbfde0 added REST fake methods/files 2026-02-16 14:16:41 +01:00
CI workflows 416436a612 chore: auto-update github workflows 2026-02-16 13:01:33 +00:00
CI workflows dc1b724d8d Update pro ref 2026-02-16 13:01:17 +00:00
Jan Prochazka 080dc44175 SYNC: Merge pull request #51 from dbgate/feature/rest-poc 2026-02-16 13:00:56 +00:00
Stela Augustinova be148297a2 Fix numeric precision in sum calculation 2026-02-16 13:26:47 +01:00
Stela Augustinova 6cf6d8c876 Use isPlainObject instead of isObject 2026-02-16 13:18:11 +01:00
Stela Augustinova 3921f50feb Removed unnecessary endCommand call in tableOptions method 2026-02-16 07:45:20 +01:00
Stela Augustinova 6fc63be56a fixed duplicate FK 2026-02-16 07:39:18 +01:00
Stela Augustinova 6a03f9a6fe Numeric handling in DataGridCore 2026-02-16 07:22:46 +01:00
00adrn 721fdf09b3 Added option to toggle database formats on and off in Settings->Connection menu. Now, when creating a new connection, only enabled database formats will appear. 2026-02-15 21:01:23 -05:00
SPRINX0\prochazka bd4a52318b changelog 2026-02-13 09:58:44 +01:00
SPRINX0\prochazka 3978865902 v7.0.6 2026-02-13 09:55:26 +01:00
SPRINX0\prochazka c1228ee426 v7.0.5 2026-02-13 09:55:08 +01:00
CI workflows d0c39dc932 chore: auto-update github workflows 2026-02-13 08:50:50 +00:00
CI workflows 63a8586d7c Update pro ref 2026-02-13 08:50:33 +00:00
SPRINX0\prochazka e0a79c033e SYNC: fix 2026-02-13 08:10:27 +00:00
SPRINX0\prochazka fa12f127ce v7.0.5-premium-beta.2 2026-02-13 09:04:54 +01:00
SPRINX0\prochazka 10916eadd5 v7.0.5-beta.1 2026-02-13 08:50:20 +01:00
SPRINX0\prochazka 7f4e8e9c8f Crypting passwords when using SHELL_SCRIPTING=1 and env variables #1357 2026-02-13 08:31:14 +01:00
CI workflows d06840f934 chore: auto-update github workflows 2026-02-10 16:10:26 +00:00
CI workflows 75f4df8b51 Update pro ref 2026-02-10 16:10:08 +00:00
Jan Prochazka e9ea6d27ae SYNC: Merge pull request #49 from dbgate/feature/reset-password 2026-02-10 16:09:53 +00:00
SPRINX0\prochazka 48019d43c3 v7.0.4 2026-02-09 15:39:46 +01:00
SPRINX0\prochazka 04dbeb633d changelog 2026-02-09 15:39:34 +01:00
SPRINX0\prochazka 71631865c4 v7.0.4-premium-beta.3 2026-02-09 14:50:52 +01:00
Jan Prochazka d4a39cf481 Merge pull request #1349 from dbgate/feature/mysql-indexes
MySQL FULLTEXT support #1305
2026-02-09 13:16:37 +01:00
SPRINX0\prochazka 3a71dfff64 removed sort by index type 2026-02-09 13:14:52 +01:00
Jan Prochazka d8c865b3ce Update packages/tools/src/SqlDumper.ts
Co-authored-by: Copilot <175728472+Copilot@users.noreply.github.com>
2026-02-09 13:11:01 +01:00
SPRINX0\prochazka 71356b798c fixed Error messages in Chinese will display garbled characters. #1321 2026-02-09 13:03:49 +01:00
SPRINX0\prochazka 66ddd1741f MySQL FULLTEXT support #1305 2026-02-09 12:50:18 +01:00
SPRINX0\prochazka 8a60f3c8a7 SYNC: fix: correct typo in numeric_scale variable in getColumnInfo function #1325 2026-02-09 11:07:46 +00:00
Jan Prochazka 25d2a40c50 SYNC: Merge pull request #48 from dbgate/feature/skip-group-by 2026-02-09 08:55:45 +00:00
SPRINX0\prochazka 17b389146c v7.0.4-premium-beta.2 2026-02-06 15:31:39 +01:00
CI workflows bd1f609b39 chore: auto-update github workflows 2026-02-06 14:10:09 +00:00
CI workflows 01e1831d57 Update pro ref 2026-02-06 14:09:54 +00:00
Jan Prochazka c341baa781 SYNC: Merge pull request #47 from dbgate/feature/color-refactor-2 2026-02-06 14:09:41 +00:00
Jan Prochazka 995a0c33c3 Merge pull request #1347 from dbgate/feature/color-refactor
Feature/color refactor
2026-02-06 13:42:33 +01:00
SPRINX0\prochazka 75845cb42d fix 2026-02-06 13:41:25 +01:00
SPRINX0\prochazka 822d6acfb0 dark usercolors changed 2026-02-06 13:05:05 +01:00
SPRINX0\prochazka 2c4510a717 color fixes 2026-02-06 13:01:58 +01:00
SPRINX0\prochazka ac2391c91a connection color in statusbar refactor 2026-02-06 11:13:11 +01:00
SPRINX0\prochazka e805563ce5 connection color refactor 2026-02-06 10:58:30 +01:00
Jan Prochazka 88fb1d920e Merge pull request #1345 from dbgate/feature/form-view-edit
Feature/form view edit
2026-02-06 09:51:13 +01:00
SPRINX0\prochazka dca60fad7a Merge branch 'master' into feature/form-view-edit 2026-02-05 16:23:49 +01:00
SPRINX0\prochazka 8226b05e7e SYNC: fixed CSV export 2026-02-05 15:18:16 +00:00
Stela Augustinova 0106331978 Fix loop termination condition in cell selection logic for DataGrid 2026-02-05 15:54:56 +01:00
Stela Augustinova f5c0e7d2e9 Refactor startEditing function to streamline editable and grider checks 2026-02-05 15:50:21 +01:00
Stela Augustinova 4568b24351 Enhance row selection logic to support primary key filtering in DataGrid 2026-02-05 15:44:46 +01:00
Stela Augustinova 042502f41f Refactor row selection handling to support multiple selected rows in DataGrid 2026-02-05 14:39:56 +01:00
Stela Augustinova d342d73818 Refactor selection handling in DataGrid to improve row selection logic and UI responsiveness 2026-02-05 14:30:07 +01:00
SPRINX0\prochazka 3b922216c1 changelog 2026-02-05 13:32:12 +01:00
SPRINX0\prochazka 10fa9b6812 v7.0.3 2026-02-05 13:29:47 +01:00
SPRINX0\prochazka 33ccbf790b dark theme fix 2026-02-05 13:29:28 +01:00
SPRINX0\prochazka 50b4baee4b v7.0.2 2026-02-05 13:03:41 +01:00
CI workflows be57a56095 chore: auto-update github workflows 2026-02-04 15:49:11 +00:00
SPRINX0\prochazka f351453b9c uncommented azre build 2026-02-04 16:41:01 +01:00
SPRINX0\prochazka dda67d3351 v7.0.2-premium-beta.3 2026-02-04 16:39:45 +01:00
SPRINX0\prochazka 23e1e744e8 changelog 2026-02-04 16:33:11 +01:00
Jan Prochazka 4b0affe182 SYNC: Merge pull request #46 from dbgate/feature/widget-panel 2026-02-04 14:57:25 +00:00
SPRINX0\prochazka ab836bc747 v7.0.2-packer-beta.2 2026-02-04 15:24:55 +01:00
SPRINX0\prochazka cf86d7e352 v7.0.2-packer-beta.1 2026-02-04 15:24:15 +01:00
CI workflows 05a36d3878 chore: auto-update github workflows 2026-02-04 14:23:09 +00:00
CI workflows 0417084a39 Update pro ref 2026-02-04 14:22:51 +00:00
SPRINX0\prochazka cdfe39f226 SYNC: temporatrily switched off Axure build 2026-02-04 14:22:41 +00:00
SPRINX0\prochazka b73dde3a48 code format 2026-02-04 13:40:46 +01:00
Jan Prochazka 0dea597226 Merge pull request #1342 from dbgate/feature/mssql-filter-text-empty
Feature/mssql filter text empty
2026-02-04 09:17:34 +01:00
SPRINX0\prochazka 2f38928c89 Refactor DATALENGTH handling for MSSQL empty string checks and update dialect interface 2026-02-04 09:08:31 +01:00
SPRINX0\prochazka 35c7b5e952 Add support for DATALENGTH in MSSQL for empty string checks 2026-02-04 08:56:19 +01:00
Stela Augustinova ba28b17263 Add shortcut to clear cell value with Ctrl+0 in FormCellView 2026-02-03 15:52:05 +01:00
Stela Augustinova 51b0e004fa Update cell selection to handle bigint and decimal values in DataGrid 2026-02-03 10:50:14 +01:00
Stela Augustinova 8cb59b02a8 Enhance editing behavior to refocus editor if already editing the same column 2026-02-03 10:19:10 +01:00
Stela Augustinova 38bfd130a3 Reset current cell and selection when row count is zero after loading all data 2026-02-02 16:00:53 +01:00
Stela Augustinova 369d90e057 Clear cell selection after applying filter in datagrid 2026-02-02 14:36:32 +01:00
Jan Prochazka c27cdd1734 Merge pull request #1340 from dbgate/feature/editor-icon-height
Adjust background size for ace gutter SQL run icon
2026-02-02 14:03:58 +01:00
Jan Prochazka e7e4f39311 Merge pull request #1339 from dbgate/feature/csv-export
Feature/csv export
2026-02-02 14:03:38 +01:00
Stela Augustinova 0443a21e05 Add support for uppercase boolean format in CSV export 2026-02-02 10:41:36 +01:00
Stela Augustinova 50c01886ec Fixed text wrap in cell view 2026-02-02 09:05:37 +01:00
Stela Augustinova a9e1219f6c Adjust background size for ace gutter SQL run icon 2026-01-30 17:59:37 +01:00
Stela Augustinova 7bc31dde70 Add boolean format option to CSV export configuration 2026-01-30 17:42:07 +01:00
Stela Augustinova 65f2f1d08f Add support for bigint and binary values in CSV export 2026-01-30 15:39:57 +01:00
Stela Augustinova 684027eaab Handle decimal and boolean values in CSV export 2026-01-30 14:59:08 +01:00
Jan Prochazka 1c3ec9c3bb SYNC: Merge pull request #45 from dbgate/feature/statusbar-colors 2026-01-30 06:33:36 +00:00
SPRINX0\prochazka 3a8ff2c05d SYNC: FIXED: Search for database in cloud connection #1329 2026-01-29 14:25:33 +00:00
SPRINX0\prochazka d5bd179c68 SYNC: configurable toolbar position #1326 2026-01-29 09:54:01 +00:00
SPRINX0\prochazka 8b938a39cf SYNC: changed: prioritize map selection in CellDataWidget autodetection #1330 2026-01-29 08:09:41 +00:00
Jan Prochazka c9610cbc39 SYNC: Merge pull request #44 from dbgate/feature/test-connection-msentra 2026-01-28 13:52:06 +00:00
SPRINX0\prochazka 931733d605 SYNC: Optimalized loading MySQL primary keys #1261 2026-01-28 07:29:54 +00:00
SPRINX0\prochazka 44e5d0e195 v7.0.1 2026-01-28 08:09:58 +01:00
SPRINX0\prochazka 08b83dc3fd changelog 2026-01-28 07:43:44 +01:00
SPRINX0\prochazka b7f261a836 v7.0.1-premium-beta.5 2026-01-28 07:39:56 +01:00
SPRINX0\prochazka d0b4ca33c2 changelog 2026-01-28 07:39:45 +01:00
Jan Prochazka 160391f5a9 SYNC: Merge pull request #43 from dbgate/feature/editor-theme 2026-01-27 15:10:02 +00:00
SPRINX0\prochazka dfe4a96b02 SYNC: added missing test run 2026-01-27 14:11:15 +00:00
SPRINX0\prochazka a3f67eb519 SYNC: fixed test 2026-01-27 13:36:07 +00:00
Jan Prochazka 0f9d52552b SYNC: Merge pull request #42 from dbgate/feature/redis-test 2026-01-27 11:59:53 +00:00
SPRINX0\prochazka a217de4c39 english in templates 2026-01-27 10:47:19 +01:00
SPRINX0\prochazka d2d85e63f6 english in issue templates 2026-01-27 10:45:51 +01:00
SPRINX0\prochazka 7a6077b5ff SYNC: Ability to skip computed columns is SQL generator SQL Generator #1319 2026-01-26 15:06:56 +00:00
SPRINX0\prochazka d48c4d9729 SYNC: fixed: The JsonB field in the cell data view always displays as null. #1320 2026-01-26 14:17:27 +00:00
SPRINX0\prochazka 6d677401bf SYNC: FIXED: Foreign key actions not detected on PostgreSQL #1323 2026-01-26 13:56:12 +00:00
SPRINX0\prochazka a3d4fa2f86 SYNC: fix 2026-01-26 13:04:56 +00:00
SPRINX0\prochazka 59e19b6a22 SYNC: translations 2026-01-26 12:58:25 +00:00
SPRINX0\prochazka 1a76da40d1 SYNC: datagrid translations 2026-01-26 12:58:24 +00:00
SPRINX0\prochazka cb15ba01f0 SYNC: solarized theme screenshot 2026-01-26 12:20:15 +00:00
SPRINX0\prochazka 78af7f136e SYNC: korean localization 2026-01-26 11:24:02 +00:00
SPRINX0\prochazka cc6a95b579 SYNC: korean translation 2026-01-26 11:24:00 +00:00
SPRINX0\prochazka 4b3f723bdc SYNC: missing translations 2026-01-26 11:23:58 +00:00
SPRINX0\prochazka d372e2ff76 SYNC: translations 2026-01-26 11:23:56 +00:00
SPRINX0\prochazka 4201d1cb1e SYNC: translation 2026-01-26 11:09:08 +00:00
SPRINX0\prochazka afed70ba63 v7.0.1-premium-beta.4 2026-01-26 11:09:06 +01:00
SPRINX0\prochazka be488346c5 v7.0.1-premium-beta.3 2026-01-26 11:08:04 +01:00
CI workflows eeeb688439 chore: auto-update github workflows 2026-01-26 10:08:00 +00:00
CI workflows b84ce77326 Update pro ref 2026-01-26 10:07:42 +00:00
CI workflows 30fca423dc chore: auto-update github workflows 2026-01-26 10:01:44 +00:00
CI workflows fabbb31572 Update pro ref 2026-01-26 10:01:27 +00:00
SPRINX0\prochazka ac76ac004e SYNC: admin file - change content 2026-01-26 10:01:17 +00:00
SPRINX0\prochazka 9d2051183a v7.0.1-premium-beta.2 2026-01-26 10:24:19 +01:00
SPRINX0\prochazka 942fdb51d5 v7.0.1-premium.beta.2 2026-01-26 10:20:44 +01:00
CI workflows d2600a3168 chore: auto-update github workflows 2026-01-26 09:19:22 +00:00
CI workflows c4248cce22 Update pro ref 2026-01-26 09:19:07 +00:00
SPRINX0\prochazka 16f16f9fed theme docs 2026-01-23 14:51:55 +01:00
SPRINX0\prochazka d49cb976bc v7.0.1-premium-beta.1 2026-01-23 14:47:45 +01:00
SPRINX0\prochazka 6fae6a9865 changelog 2026-01-23 14:47:23 +01:00
SPRINX0\prochazka 06f3730756 SYNC: upgraded cross-spawn #1322 2026-01-23 13:25:50 +00:00
SPRINX0\prochazka 30e1333f75 SYNC: axios upgrade #1322 2026-01-23 13:18:56 +00:00
Jan Prochazka 0d6fa98767 SYNC: redis test changed 2026-01-22 14:17:52 +00:00
SPRINX0\prochazka ae6c9edd0d SYNC: green theme screenshot 2026-01-22 12:07:55 +00:00
SPRINX0\prochazka 35de1f1c4e v7.0.0 2026-01-22 10:07:46 +01:00
SPRINX0\prochazka 57142f4afb v7.0.0-alpha.12 2026-01-22 09:47:33 +01:00
CI workflows cd72d65b89 chore: auto-update github workflows 2026-01-22 08:46:26 +00:00
CI workflows 2199ab0513 Update pro ref 2026-01-22 08:46:10 +00:00
SPRINX0\prochazka e93f058109 Revert "downgraded NPM refs"
This reverts commit 3f05934b6b.
2026-01-22 09:43:27 +01:00
SPRINX0\prochazka b68de49cbd v7.0.0-alpha.11 2026-01-22 09:41:58 +01:00
SPRINX0\prochazka 3f05934b6b downgraded NPM refs 2026-01-22 09:41:45 +01:00
SPRINX0\prochazka 3a5713dbb7 v7.0.0-alpha.10 2026-01-22 09:37:36 +01:00
SPRINX0\prochazka 4c43158285 v7.0.0-beta.9 2026-01-22 09:37:24 +01:00
SPRINX0\prochazka daa743b3b3 upgraded NPM version 2026-01-22 09:37:13 +01:00
SPRINX0\prochazka 41f0ae18c4 changelog - 7.0.0 2026-01-22 09:37:13 +01:00
CI workflows e6b8aefe5b chore: auto-update github workflows 2026-01-22 07:09:52 +00:00
CI workflows 8b2437cb16 Update pro ref 2026-01-22 07:09:37 +00:00
SPRINX0\prochazka 292495ab0d SYNC: redis CSS variables renamed 2026-01-22 07:09:25 +00:00
CI workflows 017b137d7f chore: auto-update github workflows 2026-01-22 07:02:40 +00:00
CI workflows 7969030313 Update pro ref 2026-01-22 07:02:25 +00:00
SPRINX0\prochazka c8efad4c3f SYNC: fix 2026-01-22 07:02:13 +00:00
SPRINX0\prochazka 7bf9d8f675 SYNC: redis screenshot 2026-01-22 06:47:32 +00:00
SPRINX0\prochazka e275f15f00 SYNC: redis hash screenshot 2026-01-21 18:10:20 +00:00
SPRINX0\prochazka 30017a5217 v7.0.0-premium-beta.8 2026-01-21 18:56:13 +01:00
Jan Prochazka 64c5cbe8c3 SYNC: Merge pull request #41 from dbgate/feature/widget-panel 2026-01-21 17:53:24 +00:00
Jan Prochazka b2b226573c SYNC: Merge pull request #40 from dbgate/feature/redis-refactor-2 2026-01-21 17:44:43 +00:00
CI workflows 69f796998f chore: auto-update github workflows 2026-01-21 10:59:33 +00:00
CI workflows 4d64be3ac7 Update pro ref 2026-01-21 10:59:16 +00:00
Stela Augustinova 4408b794d6 v7.0.0-premium-beta.7 2026-01-21 11:27:06 +01:00
SPRINX0\prochazka 666da8a879 v7.0.0-premium-beta.5 2026-01-21 10:47:10 +01:00
SPRINX0\prochazka b166342579 v7.0.0-premium-beta.4 2026-01-21 10:47:10 +01:00
SPRINX0\prochazka 433f5bf7d2 compiled workflows 2026-01-21 10:47:10 +01:00
SPRINX0\prochazka b8ae153ef5 set python version 2026-01-21 10:47:10 +01:00
Jan Prochazka bb59c2bab7 SYNC: Merge pull request #38 from dbgate/feature/db-icons-light-dark 2026-01-21 08:36:42 +00:00
CI workflows ab7c6c5118 chore: auto-update github workflows 2026-01-20 09:16:40 +00:00
CI workflows 85c1ea449e Update pro ref 2026-01-20 09:16:26 +00:00
Jan Prochazka b51d679b78 SYNC: Merge pull request #37 from dbgate/feature/theme-refactor 2026-01-20 09:16:15 +00:00
Jan Prochazka 2a2bc9e625 SYNC: Merge pull request #36 from dbgate/feature/redis-refactor 2026-01-19 15:44:14 +00:00
SPRINX0\prochazka d00f059567 SYNC: redis styling 2026-01-19 14:27:00 +00:00
SPRINX0\prochazka 81a840347c SYNC: fixed for system dark mode 2026-01-19 14:11:39 +00:00
SPRINX0\prochazka e691675bf9 SYNC: better mysql icon 2026-01-19 13:43:37 +00:00
SPRINX0\prochazka 9cd57c3ae1 SYNC: removed console.log 2026-01-19 13:31:26 +00:00
CI workflows 0e3310a39b chore: auto-update github workflows 2026-01-19 13:20:59 +00:00
CI workflows 447818ac2a Update pro ref 2026-01-19 13:20:44 +00:00
Jan Prochazka dd0eb846b0 SYNC: Merge pull request #35 from dbgate/feature/theme-refactor 2026-01-19 13:20:34 +00:00
Jan Prochazka 1b62ca4b21 SYNC: Merge pull request #34 from dbgate/feature/refresh-keys 2026-01-19 13:20:33 +00:00
357 changed files with 27630 additions and 14354 deletions
+3 -1
View File
@@ -1,12 +1,14 @@
---
name: Bug report
about: Create a report to help us improve DbGate
about: Create a report to help us improve DbGate (in ENGLISH)
title: 'BUG: Say something here'
labels: ''
assignees: ''
---
Please keep communication in ENGLISH to reach more contributors.
**Describe the bug**
A clear and concise description of what the bug is.
+3 -1
View File
@@ -1,12 +1,14 @@
---
name: Feature request
about: Suggest an idea for DbGate
about: Suggest an idea for DbGate (in ENGLISH)
title: 'FEAT: '
labels: ''
assignees: ''
---
Please keep communication in ENGLISH to reach more contributors.
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
+3 -1
View File
@@ -1,12 +1,14 @@
---
name: Question
about: Ask a question about how to do something
about: Ask a question about how to do something (in ENGLISH)
title: 'QUESTION: Summary of your question'
labels: ''
assignees: ''
---
Please keep communication in ENGLISH to reach more contributors.
**Details:**
Details about your question
+1 -1
View File
@@ -47,7 +47,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- 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: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -19
View File
@@ -39,7 +39,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
@@ -90,14 +90,6 @@ jobs:
prerelease: true
env:
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Run `packer init` for Azure
run: |
cd ../dbgate-merged/packer
packer init ./azure-ubuntu.pkr.hcl
- name: Run `packer build` for Azure
run: |
cd ../dbgate-merged/packer
packer build ./azure-ubuntu.pkr.hcl
- name: Run `packer init` for AWS
run: |
cd ../dbgate-merged/packer
@@ -114,16 +106,6 @@ jobs:
AWS_ACCESS_KEY_ID: ${{secrets.AWS_ACCESS_KEY_ID}}
AWS_SECRET_ACCESS_KEY: ${{secrets.AWS_SECRET_ACCESS_KEY}}
AWS_DEFAULT_REGION: ${{secrets.AWS_DEFAULT_REGION}}
- name: Delete old Azure VMs
run: |
cd ../dbgate-merged/packer
chmod +x delete-old-azure-images.sh
./delete-old-azure-images.sh
env:
AZURE_CLIENT_ID: ${{secrets.AZURE_CLIENT_ID}}
AZURE_CLIENT_SECRET: ${{secrets.AZURE_CLIENT_SECRET}}
AZURE_TENANT_ID: ${{secrets.AZURE_TENANT_ID}}
AZURE_SUBSCRIPTION_ID: ${{secrets.AZURE_SUBSCRIPTION_ID}}
- name: Delete old AMIs (AWS)
run: |
cd ../dbgate-merged/packer
+1 -1
View File
@@ -44,7 +44,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -1
View File
@@ -35,7 +35,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+1 -1
View File
@@ -30,7 +30,7 @@ jobs:
repository: dbgate/dbgate-pro
token: ${{ secrets.GH_TOKEN }}
path: dbgate-pro
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
ref: c33e71a6ddc30d8ce59cf0351e04e08f6be272a3
- name: Merge dbgate/dbgate-pro
run: |
mkdir ../dbgate-pro
+6 -1
View File
@@ -2,5 +2,10 @@
"jestrunner.jestCommand": "node_modules/.bin/cross-env DEVMODE=1 LOCALTEST=1 node_modules/.bin/jest",
"cSpell.words": [
"dbgate"
]
],
"chat.tools.terminal.autoApprove": {
"yarn workspace": true,
"yarn --cwd packages/rest": true,
"yarn --cwd packages/web": true
}
}
+8
View File
@@ -0,0 +1,8 @@
# AGENTS
## Rules
- In newly added code, always use `DBGM-00000` for message/error codes; do not introduce new numbered DBGM codes such as `DBGM-00316`.
- 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
+80
View File
@@ -8,6 +8,86 @@ Builds:
- linux - application for linux
- win - application for Windows
## 7.1.2
- ADDED: GraphQL chat - AI chat with GraphQL endpoint (Premium)
- FIXED: Error "400 Provider returned error" in Database Chat (Premium)
- CHANGED: Upgraded AI components to latest versions, improved stability and performance of AI features (Premium)
- ADDED: New LLM models available (GPT-5.1 Codex Mini - now default), Claude Haiku 4.5
- CHANGED: Upgraded some internal building components (svelte-preprocess, typescript)
## 7.1.1
- CHANGED: Fixed some DynamoDB issues, improved filtering performance
- FIXED: Afilter filter scroll issue #1370
- FIXED: Team Premium - filtering by connection in database and table permissions
- FIXED: Team Premium - Creating role and user in PostgreSQL - settings is remembered without reopening new role/user
- FIXED: Team Premium - don't show errors "Connection permission not granted" when no connection is selected
- FIXED: Firebird - improved connectivity & table loading #1324
- ADDED: New GraphQL query option, changed GraphQL query icon (Premium)
## 7.1.0
- ADDED: Support for Amazon DynamoDB (Premium)
- ADDED: Connect to API endpoints - OpenAPI (Swagger), GraphQL and oData (Premium)
- FIXED: Redis key list infinite loading when first key hierarchy segment is numeric (e.g. "0:profile:1234") #1363
- FIXED: Sum of PostgreSQL numeric values always 0 #1354
- FIXED: SQL SERVER Table structure key duplication #1351
- FIXED: SQL Server - Incorrect SQL generated for 'Group by Year/Month/Day' #1350
- ADDED: Choose drivers available in connection dialog
- FIXED: Show query results for CTE (WITH) queries
- CHANGED: Used rolldown bundler instead of legacy rollup
## 7.0.6
- ADDED: Reset password for Team Premium edition
- ADDED: Encrypting passwords sent to frontend when using SHELL_CONNECTION=1 in Docker Community edition #1357
## 7.0.4
- FIXED: MS SQL server export to CSV does not convert bit FALSE to 0 #1276
- ADDED: MySQL FULLTEXT support #1305
- FIXED: Error messages in Chinese will display garbled characters(MS SQL over ODBC) #1321
- FIXED: Table's Show SQL fails to display precision and scale for NUMERIC/DECIMAL types in PostgreSQL #1325
- FIXED: Export to Excel/CSV is broken for certain data types in v7.0.0 #1327
- ADDED: Null value with keyboard shortcut in form view #1332
- FIXED: Clicking into active form cell discards changes #1334
- FIXED: Remember selection after filtering #1335
- FIXED: Unable to use 'Group By' or one of the aggregate functions on tables containing text columns #1348
- CHANGED: Improved custom connection color palette
## 7.0.3
- FIXED: Optimalized loading MySQL primary keys #1261
- FIXED: Test connection now works for MS Entra authentication #1315
- FIXED: SQL Server - Unable to use 'Is Empty or Null' or 'Has Not Empty Value' filters on a field with data type TEXT #1338
- FIXED: Play triangle too large for text-wrapped queries #1337
- FIXED: Text wraps mid-word in form view, making it illegible #1333
- FIXED: Cell View autodetects Form instead of Map for geometry/geography #1330
- FIXED: Search for database in cloud connection #1329
- ADDED: Toolstrip could be configured to the bottom of the tab #1326
- CHANGED: Upgraded node for DbGate AWS distribution
## 7.0.1
- FIXED: Foreign key actions not detected on PostgreSQL #1323
- FIXED: Vulnerabilities in bundled dependencies: axios, cross-spawn, glob #1322
- FIXED: The JsonB field in the cell data view always displays as null. #1320
- ADDED: Possibility to skip computed coumn in SQL generator
- ADDED: Improved team file editing, move between team folders
- ADDED: Korean localization
- FIXED: Added missing localization strings
- ADDED: Default editor theme is part of application theme now
## 7.0.0
- CHANGED: New design of application, new theme system
- ADDED: Theme AI assistant - create custom themes using AI (Premium)
- CHANGED: Themes are now defined in JSON files, custom themes could be shared via DbGate Cloud
- REMOVED: Custom themes are no longer part of plugins
- CHANGED: Huge improvements of Redis support
- ADDED: Support for Redis JSON and Stream types
- ADDED: Editing Redis values (Strings, Hashes, Lists, Sets, Sorted Sets, JSON, Streams)
- ADDED: Support for Team Folders (Team Premium)
- CHANGED: Upgraded Svelte to version 4
- ADDED: Differentiate pinned database with same name #1306
- ADDED: Database icons/logos for faster visual recognition #1222
- CHANGED: Reorganized left sidebar widgets
- ADDED: Widget for currently opened tabs
## 6.8.2
- FIXED: Initialize storage database from envoronment variables failed with PostgreSQL
+3 -2
View File
@@ -61,7 +61,7 @@ DbGate is licensed under GPL-3.0 license and is free to use for any purpose.
* Edit table schema, indexes, primary and foreign keys
* Compare and synchronize database structure
* ER diagram
* Light and dark theme, next themes available as plugins from github community
* Light and dark theme, next themes available from DbGate Cloud
* Huge support for work with related data - master/detail views, foreign key lookups, expanding columns from related tables in flat data view
* Query designer - visual SQL query builder without writing SQL code. Complex conditions like WHERE NOT EXISTS.
* Query perspectives innovative nested table view over complex relational data, something like query designer on MongoDB databases
@@ -94,7 +94,8 @@ Any contributions are welcome. If you want to contribute without coding, conside
* Create some tutorial video on [youtube](https://www.youtube.com/playlist?list=PLCo7KjCVXhr0RfUSjM9wJMsp_ShL1q61A)
* Become a backer on [GitHub sponsors](https://github.com/sponsors/dbgate) or [Open collective](https://opencollective.com/dbgate)
* Add a SQL script to [Public Knowledge Base](https://github.com/dbgate/dbgate-knowledge-base)
* Where a small coding is acceptable for you, you could [create plugin](https://docs.dbgate.io/plugin-development). Plugins for new themes can be created actually without JS coding
* Where a small coding is acceptable for you, you could [create plugin](https://docs.dbgate.io/plugin-development)
* Create a new custom theme and share it on [DbGate Cloud](https://github.com/dbgate/dbgate-knowledge-base/tree/master/folder-Themes)
Thank you!
+2 -2
View File
@@ -13,9 +13,9 @@
<p>DbGate is cross-platform database manager. It's designed to be simple to use and effective, when working with more databases simultaneously. But there are also many advanced features like schema compare, visual query designer, chart visualisation or batch export and import.</p>
</description>
<url type="homepage">https://dbgate.org/</url>
<url type="homepage">https://www.dbgate.io/</url>
<url type="vcs-browser">https://github.com/dbgate/dbgate</url>
<url type="contact">https://dbgate.org/about/</url>
<url type="contact">https://www.dbgate.io/contact/</url>
<url type="donation">https://github.com/sponsors/dbgate</url>
<url type="bugtracker">https://github.com/dbgate/dbgate/issues</url>
+1 -1
View File
@@ -1,6 +1,6 @@
{
"name": "dbgate",
"version": "6.0.0-alpha.1",
"version": "7.0.0-alpha.1",
"private": true,
"author": "Jan Prochazka <jenasoft.database@gmail.com>",
"description": "Opensource database administration tool",
+1
View File
@@ -15,6 +15,7 @@ const languageNames = {
'fr.json': 'French',
'it.json': 'Italian',
'ja.json': 'Japanese',
'ko.json': 'Korean',
'pt.json': 'Portuguese',
'sk.json': 'Slovak',
'zh.json': 'Chinese'
+50
View File
@@ -3,8 +3,58 @@ const os = require('os');
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 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 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'));
}
+9
View File
@@ -37,6 +37,9 @@ module.exports = defineConfig({
case 'browse-data':
serverProcess = exec('yarn start:browse-data');
break;
case 'rest':
serverProcess = exec('yarn start:rest');
break;
case 'team':
serverProcess = exec('yarn start:team');
break;
@@ -49,6 +52,12 @@ module.exports = defineConfig({
case 'charts':
serverProcess = exec('yarn start:charts');
break;
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');
});
});
+18 -51
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();
@@ -225,7 +176,6 @@ describe('Charts', () => {
cy.contains('Default Actions').click();
cy.get('[data-testid=DefaultActionsSettings_useLastUsedAction]').uncheck();
// Themes
cy.contains('Themes').click();
cy.themeshot('app-settings-themes');
@@ -256,7 +206,6 @@ describe('Charts', () => {
cy.contains('OK').click();
cy.contains('Ctrl+G');
cy.contains('AI').click();
cy.themeshot('app-settings-ai');
cy.get('[data-testid=AISettings_addProviderButton]').click();
@@ -266,4 +215,22 @@ describe('Charts', () => {
cy.contains('OK').click();
cy.contains('Provider 1').should('not.exist');
});
it('Custom theme', () => {
cy.testid('WidgetIconPanel_settings').click();
cy.contains('Themes').click();
cy.testid('ThemeSettings-themeList').contains('Green-Sample').click();
cy.testid('WidgetIconPanel_file').click();
cy.themeshot('green-theme', { keepTheme: true });
cy.testid('ThemeSettings-themeList').contains('Solarized-light').click();
cy.testid('WidgetIconPanel_database').click();
cy.contains('MySql-connection').click();
cy.contains('MyChinook').click();
cy.contains('Customer').click();
cy.contains('Leonie');
cy.testid('WidgetIconPanel_file').click();
cy.themeshot('solarized-theme', { keepTheme: true });
});
});
+120
View File
@@ -0,0 +1,120 @@
Cypress.on('uncaught:exception', (err, runnable) => {
// if the error message matches the one about WorkerGlobalScope importScripts
if (err.message.includes("Failed to execute 'importScripts' on 'WorkerGlobalScope'")) {
// return false to let Cypress know we intentionally want to ignore this error
return false;
}
// otherwise let Cypress throw the error
});
beforeEach(() => {
cy.visit('http://localhost:3000');
cy.viewport(1250, 900);
});
describe('Redis data', () => {
it('String test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('app').click();
cy.contains('version').click();
cy.testid('RedisValueDetail_AceEditor').click().realPress('Backspace').realType('1');
cy.contains('Save').click();
cy.contains('OK').click();
});
it('Hash test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('user').click();
cy.contains('alice').click();
cy.testid('RedisKeyDetailTab_RenameKeyButton').click();
cy.themeshot('redis-rename-key');
cy.realType('3');
cy.contains('OK').click();
cy.contains('age').click();
cy.testid('RedisValueHashDetail_ValueSection').click().realPress('Backspace').realType('8');
cy.contains('Add field').click();
cy.testid('RedisValueListLikeEdit_key').click().realType('phone');
cy.testid('RedisValueListLikeEdit_value').click().realType('123-456-7890');
cy.contains('Refresh').click();
cy.themeshot('redis-hash-edit');
cy.contains('Save').click();
cy.themeshot('redis-hash-script-edit');
cy.contains('OK').click();
});
it('List test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('queue').click();
cy.contains('emails').click();
cy.contains('Add field').click();
cy.testid('RedisValueListLikeEdit_value').click().realType('reset');
cy.contains('Save').click();
cy.contains('OK').click();
});
it('Set test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('tags').click();
cy.contains('Add field').click();
cy.testid('RedisValueListLikeEdit_value').click().realType('newtag');
cy.contains('Save').click();
cy.contains('OK').click();
});
it('ZSet test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('leaderboard').click();
cy.contains('alice').click();
cy.testid('RedisValueZSetDetail_score')
.click()
.realPress('Backspace')
.realPress('Backspace')
.realPress('Backspace')
.realType('35');
cy.contains('Save').click();
cy.contains('OK').click();
cy.contains('35').should('exist');
});
it('JSON test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('user').click();
cy.contains('1:*').click();
cy.contains('json').click();
cy.testid('RedisValueDetail_displaySelect').select('JSON view');
cy.themeshot('redis-json-detail');
});
it('Stream test', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.contains('events').click();
cy.contains('Add field').click();
cy.testid('RedisValueListLikeEdit_field').click().realType('message');
cy.testid('RedisValueListLikeEdit_value').click().realType('Hello, World!');
cy.contains('Save').click();
cy.contains('OK').click();
cy.themeshot('redis-stream');
});
it('Add key', () => {
cy.contains('Redis-connection').click();
cy.contains('db1').click();
cy.testid('RedisKeysTree_addKeyDropdown').click();
cy.contains('String').click();
cy.testid('NewRedisKeyTab_keyName').click().realType('newstringkey');
cy.testid('RedisValueDetail_AceEditor').click().realType('This is a new string key.');
cy.contains('Save').click();
cy.contains('OK').click();
cy.contains('newstringkey').should('exist');
cy.testid('RedisKeysTree_addKeyDropdown').click();
cy.contains('Hash').click();
cy.themeshot('redis-add-hash-key');
});
});
+39
View File
@@ -0,0 +1,39 @@
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('REST API connections', () => {
it('GraphQL test', () => {
cy.contains('REST GraphQL').click();
cy.contains('products').click();
cy.testid('GraphQlExplorerNode_toggle_products').click();
cy.testid('GraphQlExplorerNode_checkbox_products.name').click();
cy.testid('GraphQlExplorerNode_checkbox_products.price').click();
cy.testid('GraphQlExplorerNode_checkbox_products.description').click();
cy.testid('GraphQlExplorerNode_checkbox_products.category').click();
cy.testid('GraphQlQueryTab_execute').click();
cy.contains('Electronics');
cy.themeshot('rest-graphql-query');
});
it('REST OpenAPI test', () => {
cy.contains('REST OpenAPI').click();
cy.contains('/api/categories').click();
cy.testid('RestApiEndpointTab_execute').click();
cy.contains('Electronics');
cy.themeshot('rest-openapi-query');
});
it('REST OData test', () => {
cy.contains('REST OData').click();
cy.contains('/Users').click();
cy.testid('ODataEndpointTab_execute').click();
cy.contains('Henry');
cy.themeshot('rest-odata-query');
});
});
+10 -6
View File
@@ -36,9 +36,11 @@ Cypress.Commands.add(
prevSubject: 'optional',
},
(subject, file, options) => {
cy.window().then(win => {
win.__changeCurrentTheme('dark');
});
if (!options?.keepTheme) {
cy.window().then(win => {
win.__changeCurrentTheme('dark');
});
}
// cy.screenshot(`${file}-dark`, {
// onAfterScreenshot: (doc, props) => {
@@ -63,9 +65,11 @@ Cypress.Commands.add(
// });
// });
cy.window().then(win => {
win.__changeCurrentTheme('light');
});
if (!options?.keepTheme) {
cy.window().then(win => {
win.__changeCurrentTheme('light');
});
}
if (subject) {
cy.wrap(subject).screenshot(`${file}-light`, options);
+253
View File
@@ -0,0 +1,253 @@
{
"themeName": "Green-Sample",
"themeType": "light",
"themeVariables": {
"--theme-generic-font": "oklch(27% 0.07 130)",
"--theme-generic-font-hover": "oklch(40% 0.15 130)",
"--theme-generic-font-grayed": "oklch(65% 0.05 130)",
"--theme-link-foreground": "oklch(40% 0.25 130)",
"--theme-content-background": "oklch(95% 0.05 130)",
"--theme-widget-panel-background": "oklch(80% 0.1 130)",
"--theme-widget-panel-foreground": "oklch(27% 0.07 130)",
"--theme-widget-icon-background-active": "oklch(50% 0.12 130)",
"--theme-widget-icon-foreground-active": "white",
"--theme-widget-icon-foreground-hover": "white",
"--theme-widget-icon-border-active": "1px solid white",
"--theme-scrollbar-background": "oklch(90% 0.08 130)",
"--theme-scrollbar-thumb-background": "oklch(70% 0.12 130)",
"--theme-scrollbar-thumb-background-hover": "oklch(40% 0.15 130)",
"--theme-scrollbar-corner-background": "oklch(85% 0.1 130)",
"--theme-tabs-panel-border": "1px solid oklch(95% 0.05 130)",
"--theme-tabs-panel-foreground": "oklch(20% 0.06 130)",
"--theme-tabs-panel-active-foreground": "oklch(10% 0.06 130)",
"--theme-tabs-panel-background": "oklch(95.5% 0.04 130)",
"--theme-tabs-panel-active-background": "oklch(80% 0.12 130)",
"--theme-tabs-panel-item-background": "oklch(90% 0.1 130)",
"--theme-tabs-panel-active-border": "1px solid oklch(50% 0.2 130)",
"--theme-splitter-active": "oklch(50% 0.2 130)",
"--theme-splitter-button-background": "oklch(90% 0.1 130)",
"--theme-splitter-button-background-active": "oklch(85% 0.15 130)",
"--theme-splitter-button-foreground": "oklch(10% 0.06 130)",
"--theme-sidebar-background": "oklch(90% 0.1 130)",
"--theme-sidebar-background-hover": "oklch(80% 0.12 130)",
"--theme-sidebar-background-active": "oklch(75% 0.14 130)",
"--theme-sidebar-background-focused": "oklch(70% 0.18 130)",
"--theme-sidebar-foreground": "oklch(20% 0.06 130)",
"--theme-sidebar-foreground-button": "oklch(40% 0.12 130)",
"--theme-sidebar-foreground-grayed": "oklch(65% 0.05 130)",
"--theme-sidebar-foreground-hover": "oklch(50% 0.25 130)",
"--theme-sidebar-section-background": "oklch(65% 0.05 130)",
"--theme-sidebar-section-border": "none",
"--theme-sidebar-section-border-top": "1px solid oklch(80% 0.1 130)",
"--theme-sidebar-section-foreground": "oklch(10% 0.06 130)",
"--theme-sidebar-border": "none",
"--theme-altsidebar-background": "oklch(95% 0.05 130)",
"--theme-altsidebar-background-grayed": "oklch(97% 0.02 130)",
"--theme-altsidebar-background-hover": "oklch(85% 0.1 130)",
"--theme-altsidebar-background-active": "oklch(80% 0.12 130)",
"--theme-altsidebar-background-focused": "oklch(75% 0.15 130)",
"--theme-altsidebar-foreground": "oklch(20% 0.06 130)",
"--theme-altsidebar-foreground-button": "oklch(40% 0.12 130)",
"--theme-altsidebar-foreground-grayed": "oklch(65% 0.05 130)",
"--theme-altsidebar-foreground-hover": "oklch(50% 0.25 130)",
"--theme-altsidebar-section-background": "oklch(97% 0.02 130)",
"--theme-altsidebar-section-border": "none",
"--theme-altsidebar-section-border-top": "1px solid oklch(85% 0.1 130)",
"--theme-altsidebar-section-foreground": "oklch(10% 0.06 130)",
"--theme-altsidebar-border": "1px solid oklch(90% 0.1 130)",
"--theme-searchbox-background": "oklch(80% 0.12 130)",
"--theme-searchbox-placeholder": "oklch(65% 0.05 130)",
"--theme-searchbox-border": "1px solid oklch(70% 0.15 130)",
"--theme-searchbox-background-filtered": "oklch(95% 0.04 110)",
"--theme-altsearchbox-background": "oklch(90% 0.1 130)",
"--theme-altsearchbox-placeholder": "oklch(65% 0.05 130)",
"--theme-altsearchbox-border": "1px solid oklch(80% 0.1 130)",
"--theme-inlinebutton-foreground": "oklch(40% 0.12 130)",
"--theme-inlinebutton-foreground-disabled": "oklch(65% 0.05 130)",
"--theme-inlinebutton-foreground-hover": "black",
"--theme-inlinebutton-circle-hover-background": "oklch(85% 0.1 130)",
"--theme-inlinebutton-bordered-border": "1px solid oklch(85% 0.1 130)",
"--theme-inlinebutton-bordered-hover-border": "1px solid oklch(70% 0.15 130)",
"--theme-inlinebutton-bordered-background": "linear-gradient(to bottom, oklch(95% 0.04 130) 5%, oklch(90% 0.1 130) 100%)",
"--theme-inlinebutton-bordered-hover-background": "linear-gradient(to bottom, oklch(90% 0.1 130) 5%, oklch(95% 0.04 130) 100%)",
"--theme-datagrid-background": "oklch(95% 0.04 130)",
"--theme-datagrid-foreground": "oklch(20% 0.06 130)",
"--theme-datagrid-foreground-grayed": "oklch(65% 0.05 130)",
"--theme-datagrid-border-horizontal": "1px solid oklch(90% 0.1 130)",
"--theme-datagrid-border-vertical": "1px solid oklch(95% 0.04 130)",
"--theme-datagrid-cell-background": "oklch(97% 0.02 130)",
"--theme-datagrid-headercell-background": "oklch(95% 0.04 130)",
"--theme-datagrid-cell-background-alt": "oklch(95% 0.04 130)",
"--theme-datagrid-cell-background-alt2": "oklch(90% 0.1 130)",
"--theme-datagrid-filter-background": "oklch(90% 0.1 130)",
"--theme-datagrid-filter-border": "1px solid oklch(85% 0.1 130)",
"--theme-datagrid-filter-ok-background": "oklch(95% 0.1 135)",
"--theme-datagrid-filter-error-background": "oklch(95% 0.12 30)",
"--theme-datagrid-modified-row-background": "oklch(95% 0.1 135)",
"--theme-datagrid-modified-cell-background": "oklch(90% 0.15 135)",
"--theme-datagrid-inserted-row-background": "oklch(95% 0.1 110)",
"--theme-datagrid-deleted-row-background": "oklch(95% 0.1 25)",
"--theme-datagrid-selected-cell-background": "oklch(80% 0.1 130)",
"--theme-datagrid-focused-cell-background": "oklch(75% 0.15 130)",
"--theme-datagrid-focused-cell-border-horizontal": "1px solid oklch(70% 0.2 130)",
"--theme-datagrid-focused-cell-border-vertical": "1px solid oklch(70% 0.2 130)",
"--theme-datagrid-selected-point-marker": "oklch(50% 0.25 130)",
"--theme-datagrid-corner-label-background": "oklch(75% 0.15 130)",
"--theme-datagrid-corner-label-border": "1px solid oklch(70% 0.2 130)",
"--theme-datagrid-detail-header-background": "oklch(85% 0.05 130)",
"--theme-datagrid-detail-header-border": "1px solid oklch(80% 0.1 130)",
"--theme-datagrid-cell-foreground-value-green": "oklch(45% 0.2 140)",
"--theme-checkbox-check": "oklch(90% 0.1 130)",
"--theme-checkbox-background": "oklch(40% 0.25 130)",
"--theme-checkbox-border": "1px solid oklch(70% 0.15 130)",
"--theme-checkbox-mark": "white",
"--theme-checkbox-background-disabled": "oklch(95% 0.04 130)",
"--theme-checkbox-background-disabled-before": "oklch(70% 0.15 130)",
"--theme-checkbox-hover-not-disabled": "oklch(65% 0.05 130)",
"--theme-checkbox-background-inherited": "oklch(85% 0.1 130)",
"--theme-table-border": "1px solid oklch(85% 0.1 130)",
"--theme-table-cell-background": "oklch(97% 0.02 130)",
"--theme-table-cell-empty-background": "oklch(95% 0.04 130)",
"--theme-table-cell-empty-foreground": "oklch(65% 0.05 130)",
"--theme-table-header-background": "oklch(95% 0.04 130)",
"--theme-table-selected-background": "oklch(75% 0.15 130)",
"--theme-table-active-background": "oklch(80% 0.1 130)",
"--theme-table-hover-background": "oklch(95% 0.04 130)",
"--theme-table-added-background": "oklch(95% 0.1 110)",
"--theme-table-changed-background": "oklch(95% 0.1 135)",
"--theme-table-deleted-background": "oklch(95% 0.1 25)",
"--theme-cell-active-border": "2px solid oklch(50% 0.25 130)",
"--theme-object-header-background": "oklch(95% 0.04 130)",
"--theme-modal-background": "oklch(97% 0.02 130)",
"--theme-modal-header-background": "oklch(85% 0.1 130)",
"--theme-modal-footer-background": "oklch(97% 0.02 130)",
"--theme-modal-border": "1px solid oklch(85% 0.1 130)",
"--theme-modal-overlay-background": "color-mix(in srgb, #124012 40%, transparent)",
"--theme-modal-shadow": "0 20px 25px -5px color-mix(in srgb, #124012 10%, transparent)",
"--theme-modal-close-hover-background": "oklch(70% 0.15 130)",
"--theme-formbutton-foreground": "white",
"--theme-formbutton-border": "1px solid oklch(40% 0.25 130)",
"--theme-formbutton-border-hover": "1px solid oklch(50% 0.3 130)",
"--theme-formbutton-border-active": "2px solid oklch(55% 0.35 130)",
"--theme-formbutton-background": "oklch(40% 0.25 130)",
"--theme-formbutton-background-disabled": "oklch(85% 0.1 130)",
"--theme-formbutton-border-disabled": "1px solid oklch(85% 0.1 130)",
"--theme-formbutton-foreground-disabled": "oklch(65% 0.05 130)",
"--theme-formbutton-background-hover": "oklch(35% 0.3 130)",
"--theme-formbutton-background-active": "oklch(35% 0.3 130)",
"--theme-outlinebutton-foreground": "oklch(10% 0.06 130)",
"--theme-outlinebutton-border": "1px solid oklch(40% 0.25 130)",
"--theme-outlinebutton-hover-foreground": "oklch(40% 0.25 130)",
"--theme-outlinebutton-hover-border": "2px solid oklch(50% 0.3 130)",
"--theme-tabs-control-background": "oklch(95% 0.04 130)",
"--theme-tabs-control-border": "1px solid oklch(90% 0.1 130)",
"--theme-tabs-control-selected-background": "oklch(98% 0.01 130)",
"--theme-tabs-control-selected-border": "2px solid oklch(50% 0.25 130)",
"--theme-inline-tabs-border": "1px solid oklch(90% 0.1 130)",
"--theme-inline-tabs-border-active": "2px solid oklch(50% 0.25 130)",
"--theme-toolstrip-background": "oklch(97% 0.02 130)",
"--theme-toolstrip-border": "1px solid oklch(90% 0.1 130)",
"--theme-toolstrip-button-foreground": "oklch(27% 0.07 130)",
"--theme-panel-border-subtle": "1px solid color-mix(in srgb, oklch(20% 0.06 130) 5%, transparent)",
"--theme-panel-type-label-color": "oklch(65% 0.05 130)",
"--theme-toolstrip-button-foreground-disabled": "oklch(65% 0.05 130)",
"--theme-toolstrip-button-foreground-icon": "oklch(40% 0.12 130)",
"--theme-toolstrip-button-background": "oklch(97% 0.02 130)",
"--theme-toolstrip-button-background-hover": "oklch(95% 0.04 130)",
"--theme-toolstrip-button-background-active": "oklch(90% 0.1 130)",
"--theme-toolstrip-button-border": "1px solid oklch(90% 0.1 130)",
"--theme-toolstrip-button-border-hover": "1px solid oklch(85% 0.1 130)",
"--theme-toolstrip-button-border-disabled": "1px solid oklch(90% 0.1 130)",
"--theme-toolstrip-button-split-separator-border": "1px solid oklch(85% 0.1 130)",
"--theme-designer-background": "oklch(97% 0.02 130)",
"--theme-designer-item-background": "oklch(95% 0.04 130)",
"--theme-designer-selection-marker": "oklch(35% 0.3 130)",
"--theme-designer-item-border": "1px solid oklch(90% 0.1 130)",
"--theme-designer-stroke-color": "oklch(65% 0.05 130)",
"--theme-designer-arrow-color": "oklch(27% 0.07 130)",
"--theme-designer-select-reactangle-foreground": "oklch(50% 0.25 130)",
"--theme-designer-header-background-1": "oklch(70% 0.15 130)",
"--theme-designer-header-background-2": "oklch(70% 0.18 180)",
"--theme-designer-header-background-3": "oklch(68% 0.15 100)",
"--theme-designer-header-background-grayed": "oklch(85% 0.1 130)",
"--theme-designer-close-background": "oklch(90% 0.1 130)",
"--theme-designer-close-background-hover": "oklch(85% 0.1 130)",
"--theme-designer-close-background-active": "oklch(70% 0.15 130)",
"--theme-designer-drag-column-background": "oklch(90% 0.2 110)",
"--theme-designer-select-column-background": "oklch(90% 0.1 130)",
"--theme-statusbar-background": "oklch(40% 0.25 130)",
"--theme-statusbar-foreground": "oklch(95% 0.04 130)",
"--theme-statusbar-background-hover": "oklch(35% 0.3 130)",
"--theme-statusbar-button-background": "oklch(85% 0.1 130)",
"--theme-statusbar-button-foreground": "oklch(27% 0.07 130)",
"--theme-statusbar-icon-error": "oklch(80% 0.1 25)",
"--theme-statusbar-icon-ok": "oklch(85% 0.2 130)",
"--theme-aichat-user-background": "oklch(93% 0.06 130)",
"--theme-aichat-assistant-background": "oklch(95% 0.04 130)",
"--theme-applog-details-background": "oklch(98% 0.01 130)",
"--theme-input-border": "1px solid oklch(85% 0.1 130)",
"--theme-input-border-hover": "1px solid oklch(70% 0.15 130)",
"--theme-input-border-hover-color": "oklch(70% 0.15 130)",
"--theme-input-border-focus": "1px solid oklch(50% 0.25 130)",
"--theme-input-border-focus-color": "oklch(50% 0.25 130)",
"--theme-input-border-disabled": "1px solid oklch(90% 0.1 130)",
"--theme-input-background": "white",
"--theme-input-foreground": "oklch(20% 0.06 130)",
"--theme-input-placeholder": "oklch(65% 0.05 130)",
"--theme-input-background-disabled": "oklch(95% 0.04 130)",
"--theme-input-foreground-disabled": "oklch(65% 0.05 130)",
"--theme-input-focus-ring": "0 0 0 3px color-mix(in srgb, oklch(50% 0.25 130) 10%, transparent)",
"--theme-input-multi-clear-background": "oklch(90% 0.1 130)",
"--theme-input-multi-clear-foreground": "oklch(40% 0.12 130)",
"--theme-input-multi-clear-hover": "oklch(85% 0.1 130)",
"--theme-input-shadow": "0 1px 2px 0 color-mix(in srgb, oklch(20% 0.06 130) 5%, transparent)",
"--theme-input-shadow-hover": "0 4px 6px -2px color-mix(in srgb, oklch(20% 0.06 130) 8%, transparent)",
"--theme-input-shadow-focus": "0 1px 2px 0 color-mix(in srgb, oklch(20% 0.06 130) 5%, transparent)",
"--theme-input-inplace-select-shadow": "0 1px 10px 1px oklch(40% 0.12 130)",
"--theme-color-selected-border": "2px solid oklch(27% 0.07 130)",
"--theme-new-object-button-background": "oklch(90% 0.1 130)",
"--theme-new-object-button-background-hover": "oklch(85% 0.1 130)",
"--theme-status-valid-background": "oklch(95% 0.1 110)",
"--theme-status-testing-background": "oklch(95% 0.1 135)",
"--theme-status-error-background": "oklch(95% 0.1 25)",
"--theme-status-unconfigured-background": "oklch(95% 0.04 130)",
"--theme-status-untested-background": "oklch(94% 0.1 65)",
"--theme-dropdown-icon-hover": "oklch(45% 0.3 130)",
"--theme-icon-picker-background": "oklch(90% 0.1 130)",
"--theme-icon-picker-border": "1px solid oklch(85% 0.1 130)",
"--theme-icon-picker-hover": "oklch(85% 0.1 130)",
"--theme-icon-picker-selected": "oklch(80% 0.15 130)",
"--theme-dbkey-background": "oklch(98% 0.01 130)",
"--theme-dbkey-border": "1px solid oklch(90% 0.1 130)",
"--theme-dbkey-icon-hover": "oklch(70% 0.15 130)",
"--theme-chip-background": "oklch(85% 0.1 130)",
"--theme-titlebar-background": "oklch(85% 0.1 130)",
"--theme-titlebar-button-hover": "oklch(70% 0.15 130)",
"--theme-card-background": "oklch(90% 0.1 130)",
"--theme-card-border": "1px solid oklch(85% 0.1 130)",
"--theme-content-background-hover": "oklch(95% 0.04 130)",
"--theme-admin-menu-item-hover": "oklch(95% 0.04 130)",
"--theme-admin-menu-item-active": "oklch(85% 0.1 130)",
"--theme-admin-menu-background": "oklch(90% 0.1 130)",
"--theme-admin-menu-border": "1px solid oklch(90% 0.1 130)",
"--theme-json-tree-string-color": "oklch(45% 0.3 110)",
"--theme-json-tree-symbol-color": "oklch(45% 0.3 110)",
"--theme-json-tree-boolean-color": "oklch(40% 0.25 130)",
"--theme-json-tree-function-color": "oklch(40% 0.25 130)",
"--theme-json-tree-number-color": "oklch(50% 0.3 130)",
"--theme-json-tree-label-color": "oklch(55% 0.3 140)",
"--theme-json-tree-arrow-color": "oklch(65% 0.05 130)",
"--theme-json-tree-null-color": "oklch(65% 0.05 130)",
"--theme-json-tree-undefined-color": "oklch(65% 0.05 130)",
"--theme-json-tree-date-color": "oklch(65% 0.05 130)",
"--theme-json-tree-deleted-background": "oklch(95% 0.1 25)",
"--theme-json-tree-modified-background": "oklch(95% 0.1 135)",
"--theme-json-tree-inserted-background": "oklch(95% 0.1 110)",
"--theme-icon-blue": "oklch(40% 0.25 130)",
"--theme-icon-green": "oklch(45% 0.2 140)",
"--theme-icon-red": "oklch(40% 0.3 25)",
"--theme-icon-gold": "oklch(50% 0.2 60)",
"--theme-icon-yellow": "oklch(50% 0.15 80)",
"--theme-icon-magenta": "oklch(45% 0.3 135)"
}
}
+15
View File
@@ -0,0 +1,15 @@
HSET "actor:1000" "first_name" "Sandra"
HSET "actor:1000" "last_name" "Bullock"
HSET "actor:1000" "date_of_birth" "1964"
HSET "actor:1001" "first_name" "Jon"
HSET "actor:1001" "last_name" "Hamm"
HSET "actor:1001" "date_of_birth" "1971"
HSET "actor:1002" "first_name" "Allison"
HSET "actor:1002" "last_name" "Janney"
HSET "actor:1002" "date_of_birth" "1959"
HSET "actor:1003" "first_name" "Steve"
HSET "actor:1003" "last_name" "Coogan"
HSET "actor:1003" "date_of_birth" "1965"
+14
View File
@@ -0,0 +1,14 @@
SET app:name "App"
SET app:version "1.0.0"
SET app:env "test"
SET user:1:json "{\"id\":1,\"name\":\"Alice\",\"email\":\"alice@app.test\",\"roles\":[\"admin\",\"user\"],\"settings\":{\"theme\":\"dark\",\"language\":\"sk\"}}"
SET user:2:json "{\"id\":2,\"name\":\"Bob\",\"email\":\"bob@app.test\",\"roles\":[\"user\"],\"settings\":{\"theme\":\"light\",\"language\":\"en\"}}"
RPUSH queue:emails "welcome" "reset-password" "newsletter" "promotion" "weekly-digest"
HSET user:alice name "Alice" email "alice@app.test" active "true" age "29" country "SK"
HSET user:bob name "Bob" email "bob@app.test" active "false" age "34" country "CZ"
SADD tags "app" "backend" "database" "redis" "test" "production"
ZADD leaderboard 100 "alice" 250 "bob" 180 "carol" 90 "dave" 300 "eve"
XADD events * type "login" userId "1" ip "127.0.0.1" device "web"
XADD events * type "update-profile" userId "1" field "email" old "alice@app.test" new "alice@new.app"
XADD events * type "login" userId "2" ip "10.0.0.5" device "mobile"
XADD events * type "logout" userId "1" reason "manual"
+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
+1 -6
View File
@@ -1,4 +1,4 @@
CONNECTIONS=mysql,postgres,mongo,redis
CONNECTIONS=mysql,postgres,mongo
LABEL_mysql=MySql-connection
SERVER_mysql=localhost
@@ -22,8 +22,3 @@ USER_mongo=root
PASSWORD_mongo=Pwd2020Db
PORT_mongo=16010
ENGINE_mongo=mongo@dbgate-plugin-mongo
LABEL_redis=Redis-connection
SERVER_redis=localhost
ENGINE_redis=redis@dbgate-plugin-redis
PORT_redis=16011
+6
View File
@@ -0,0 +1,6 @@
CONNECTIONS=redis
LABEL_redis=Redis-connection
SERVER_redis=localhost
ENGINE_redis=redis@dbgate-plugin-redis
PORT_redis=16011
+14
View File
@@ -0,0 +1,14 @@
CONNECTIONS=odata,openapi,graphql
LABEL_odata=REST OData
ENGINE_odata=odata@rest
APISERVERURL1_odata=http://localhost:4444/odata/noauth
LABEL_openapi=REST OpenAPI
ENGINE_openapi=openapi@rest
APISERVERURL1_openapi=http://localhost:4444/openapi.json
APISERVERURL2_openapi=http://localhost:4444/openapi/noauth
LABEL_graphql=REST GraphQL
ENGINE_graphql=graphql@rest
APISERVERURL1_graphql=http://localhost:4444/graphql/noauth
+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-00000 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-00000 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);
});
-42
View File
@@ -125,46 +125,6 @@ async function initMongoDatabase(dbname, inputDirectory) {
// });
}
async function initRedisDatabase(inputDirectory) {
await dbgateApi.executeQuery({
connection: {
server: process.env.SERVER_redis,
user: process.env.USER_redis,
password: process.env.PASSWORD_redis,
port: process.env.PORT_redis,
engine: 'redis@dbgate-plugin-redis',
},
sql: 'FLUSHALL',
});
for (const file of fs.readdirSync(inputDirectory)) {
await dbgateApi.executeQuery({
connection: {
server: process.env.SERVER_redis,
user: process.env.USER_redis,
password: process.env.PASSWORD_redis,
port: process.env.PORT_redis,
engine: 'redis@dbgate-plugin-redis',
database: 0,
},
sqlFile: path.join(inputDirectory, file),
// logScriptItems: true,
});
}
// await dbgateApi.importDatabase({
// connection: {
// server: process.env.SERVER_postgres,
// user: process.env.USER_postgres,
// password: process.env.PASSWORD_postgres,
// port: process.env.PORT_postgres,
// database: dbname,
// engine: 'postgres@dbgate-plugin-postgres',
// },
// inputFile,
// });
}
const baseDir = path.join(os.homedir(), '.dbgate');
async function copyFolder(source, target) {
@@ -188,8 +148,6 @@ 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 initRedisDatabase(path.resolve(path.join(__dirname, '../data/redis')));
await copyFolder(
path.resolve(path.join(__dirname, '../data/chinook-jsonl')),
path.join(baseDir, 'archive-e2etests', 'default')
+5
View File
@@ -90,6 +90,11 @@ async function run() {
path.join(baseDir, 'files-e2etests', 'sql')
);
await copyFolder(
path.resolve(path.join(__dirname, '../data/files/themes')),
path.join(baseDir, 'files-e2etests', 'themes')
);
await initMySqlDatabase('MyChinook', path.resolve(path.join(__dirname, '../data/chinook-mysql.sql')));
}
+55
View File
@@ -0,0 +1,55 @@
const path = require('path');
const fs = require('fs');
const dbgateApi = require('dbgate-api');
dbgateApi.initializeApiEnvironment();
const dbgatePluginRedis = require('dbgate-plugin-redis');
dbgateApi.registerPlugins(dbgatePluginRedis);
async function initRedisDatabase() {
await dbgateApi.executeQuery({
connection: {
server: process.env.SERVER_redis,
user: process.env.USER_redis,
password: process.env.PASSWORD_redis,
port: process.env.PORT_redis,
engine: 'redis@dbgate-plugin-redis',
},
sql: 'FLUSHALL',
});
const files = [
{
file: path.resolve(__dirname, '../data/redis-db1.redis'),
database: 0,
},
{
file: path.resolve(__dirname, '../data/redis-db2.redis'),
database: 1,
},
];
for (const { file, database } of files) {
await dbgateApi.executeQuery({
connection: {
server: process.env.SERVER_redis,
user: process.env.USER_redis,
password: process.env.PASSWORD_redis,
port: process.env.PORT_redis,
engine: 'redis@dbgate-plugin-redis',
database,
},
sqlFile: file,
});
}
}
async function run() {
await initRedisDatabase();
}
dbgateApi.runScript(run);
module.exports = {
initRedisDatabase,
};
+133
View File
@@ -0,0 +1,133 @@
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 pidFile = path.resolve(__dirname, '..', 'tmpdata', 'test-api.pid');
const isWindows = process.platform === 'win32';
function delay(ms) {
return new Promise(resolve => setTimeout(resolve, ms));
}
async function waitForApiReady(timeoutMs = 30000) {
const startedAt = Date.now();
while (Date.now() - startedAt < timeoutMs) {
try {
const response = await fetch('http://localhost:4444/openapi.json');
if (response.ok) {
return;
}
} catch (err) {
// continue waiting
}
await delay(500);
}
throw new Error('DBGM-00000 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() {
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 file or already terminated process
}
try {
fs.unlinkSync(pidFile);
} catch (err) {
// ignore
}
}
function startTestApi() {
const command = isWindows ? 'cmd.exe' : 'yarn';
const args = isWindows ? ['/c', 'yarn start'] : ['start'];
const child = spawn(command, args, {
cwd: testApiDir,
env: {
...process.env,
PORT: '4444',
},
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));
}
function ensureTestApiDependencies() {
const dependencyCheckFile = path.join(testApiDir, 'node_modules', 'swagger-jsdoc', 'package.json');
if (fs.existsSync(dependencyCheckFile)) {
return;
}
const installCommand = isWindows ? 'cmd.exe' : 'yarn';
const installArgs = isWindows ? ['/c', 'yarn install --silent'] : ['install', '--silent'];
const result = spawnSync(installCommand, installArgs, {
cwd: testApiDir,
stdio: 'inherit',
env: process.env,
});
if (result.status !== 0) {
throw new Error('DBGM-00000 Failed to install test-api dependencies');
}
}
async function run() {
stopPreviousTestApi();
ensureTestApiDependencies();
startTestApi();
await waitForApiReady();
}
run().catch(err => {
console.error(err);
process.exit(1);
});
+10 -1
View File
@@ -19,27 +19,36 @@
"cy:run:portal": "cypress run --spec cypress/e2e/portal.cy.js",
"cy:run:oauth": "cypress run --spec cypress/e2e/oauth.cy.js",
"cy:run:browse-data": "cypress run --spec cypress/e2e/browse-data.cy.js",
"cy:run:rest": "cypress run --spec cypress/e2e/rest.cy.js",
"cy:run:team": "cypress run --spec cypress/e2e/team.cy.js",
"cy:run:multi-sql": "cypress run --spec cypress/e2e/multi-sql.cy.js",
"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",
"start:browse-data": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/browse-data/.env node e2e-tests/init/browse-data.js && env-cmd -f e2e-tests/env/browse-data/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:rest": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/rest/.env node e2e-tests/init/rest.js && env-cmd -f e2e-tests/env/rest/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:team": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/team/.env node e2e-tests/init/team.js && env-cmd -f e2e-tests/env/team/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"start:multi-sql": "node clearTestingData && cd .. && env-cmd -f e2e-tests/env/multi-sql/.env node e2e-tests/init/multi-sql.js && env-cmd -f e2e-tests/env/multi-sql/.env node packer/build/bundle.js --listen-api --run-e2e-tests",
"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",
"test:browse-data": "start-server-and-test start:browse-data http://localhost:3000 cy:run:browse-data",
"test:rest": "start-server-and-test start:rest http://localhost:3000 cy:run:rest",
"test:team": "start-server-and-test start:team http://localhost:3000 cy:run:team",
"test:multi-sql": "start-server-and-test start:multi-sql http://localhost:3000 cy:run:multi-sql",
"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": "yarn test:add-connection && yarn test:portal && yarn test:oauth && yarn test:browse-data && yarn test:team && yarn test:multi-sql && yarn test:cloud && yarn test:charts",
"test:redis": "start-server-and-test start:redis http://localhost:3000 cy:run: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
View File
@@ -0,0 +1,2 @@
test-api.pid
aigwmock.pid
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "dbgate-integration-tests",
"version": "6.0.0-alpha.1",
"homepage": "https://dbgate.org/",
"version": "7.0.0-alpha.1",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
+5 -3
View File
@@ -1,6 +1,6 @@
{
"private": true,
"version": "7.0.0-premium-beta.5",
"version": "7.1.2",
"name": "dbgate-all",
"workspaces": [
"packages/*",
@@ -30,13 +30,15 @@
"start:web": "yarn workspace dbgate-web dev",
"start:sqltree": "yarn workspace dbgate-sqltree start",
"start:tools": "yarn workspace dbgate-tools start",
"start:rest": "yarn workspace dbgate-rest start",
"start:datalib": "yarn workspace dbgate-datalib start",
"start:filterparser": "yarn workspace dbgate-filterparser start",
"build:sqltree": "yarn workspace dbgate-sqltree build",
"build:datalib": "yarn workspace dbgate-datalib build",
"build:filterparser": "yarn workspace dbgate-filterparser build",
"build:tools": "yarn workspace dbgate-tools build",
"build:lib": "yarn build:sqltree && yarn build:tools && yarn build:filterparser && yarn build:datalib",
"build:rest": "yarn workspace dbgate-rest build",
"build:lib": "yarn build:sqltree && yarn build:tools && yarn build:filterparser && yarn build:datalib && yarn build:rest",
"build:app": "yarn plugins:copydist && cd app && yarn install && yarn build",
"build:api": "yarn workspace dbgate-api build",
"build:api:doc": "yarn workspace dbgate-api build:doc",
@@ -63,7 +65,7 @@
"prepare:packer": "yarn plugins:copydist && yarn build:web && yarn build:api && yarn copy:packer:build",
"build:e2e": "yarn build:lib && yarn prepare:packer",
"start": "concurrently --kill-others-on-fail \"yarn start:api\" \"yarn start:web\"",
"lib": "concurrently --kill-others-on-fail \"yarn start:sqltree\" \"yarn start:filterparser\" \"yarn start:datalib\" \"yarn start:tools\" \"yarn build:plugins:frontend:watch\"",
"lib": "concurrently --kill-others-on-fail \"yarn start:sqltree\" \"yarn start:filterparser\" \"yarn start:datalib\" \"yarn start:tools\" \"yarn start:rest\" \"yarn build:plugins:frontend:watch\"",
"ts:api": "yarn workspace dbgate-api ts",
"ts:web": "yarn workspace dbgate-web ts",
"ts": "yarn ts:api && yarn ts:web",
+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."
}
]
}
]
}
+6 -1
View File
@@ -1,6 +1,7 @@
DEVMODE=1
DEVWEB=1
CONNECTIONS=mysql,postgres,mongo,redis,mssql,oracle
CONNECTIONS=mysql,postgres,mongo,redis,mssql,oracle,mongourl
LABEL_mysql=MySql
SERVER_mysql=dbgatedckstage1.sprinx.cz
@@ -43,6 +44,10 @@ PORT_oracle=1521
ENGINE_oracle=oracle@dbgate-plugin-oracle
SERVICE_NAME_oracle=xe
LABEL_mongourl=Mongo URL
URL_mongourl=mongodb://root:Pwd2020Db@dbgatedckstage1.sprinx.cz:27017
ENGINE_mongourl=mongo@dbgate-plugin-mongo
# SETTINGS_dataGrid.showHintColumns=1
# docker run -p 3000:3000 -e CONNECTIONS=mongo -e URL_mongo=mongodb://localhost:27017 -e ENGINE_mongo=mongo@dbgate-plugin-mongo -e LABEL_mongo=mongo dbgate/dbgate:beta
+9 -8
View File
@@ -1,8 +1,8 @@
{
"name": "dbgate-api",
"main": "src/index.js",
"version": "6.0.0-alpha.1",
"homepage": "https://dbgate.org/",
"version": "7.0.0-alpha.1",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
@@ -24,16 +24,17 @@
"activedirectory2": "^2.1.0",
"archiver": "^7.0.1",
"async-lock": "^1.2.6",
"axios": "^0.21.1",
"axios": "^1.13.2",
"body-parser": "^1.19.0",
"byline": "^5.0.0",
"compare-versions": "^3.6.0",
"cors": "^2.8.5",
"cross-env": "^6.0.3",
"dbgate-datalib": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.11.9",
"dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1",
"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",
"debug": "^4.3.4",
"diff": "^5.0.0",
"diff2html": "^3.4.13",
@@ -87,7 +88,7 @@
"devDependencies": {
"@types/fs-extra": "^9.0.11",
"@types/lodash": "^4.14.149",
"dbgate-types": "^6.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"env-cmd": "^10.1.0",
"jsdoc-to-markdown": "^9.0.5",
"node-loader": "^1.0.2",
+2
View File
@@ -55,6 +55,8 @@ function authMiddleware(req, res, next) {
'/stream',
'/storage/get-connections-for-login-page',
'/storage/set-admin-password',
'/storage/request-password-reset',
'/storage/reset-password',
'/auth/get-providers',
'/connections/dblogin-web',
'/connections/dblogin-app',
+89 -6
View File
@@ -24,10 +24,12 @@ const requireEngineDriver = require('../utility/requireEngineDriver');
const { getAuthProviderById } = require('../auth/authProvider');
const { startTokenChecking } = require('../utility/authProxy');
const { extractConnectionsFromEnv } = require('../utility/envtools');
const { MissingCredentialsError } = require('../utility/exceptions');
const logger = getLogger('connections');
let volatileConnections = {};
let pendingTestSubprocesses = {}; // Map of conid -> subprocess for MS Entra auth flows
function getNamedArgs() {
const res = {};
@@ -200,10 +202,10 @@ module.exports = {
const storageConnections = await storage.connections(req);
if (storageConnections) {
return storageConnections;
return storageConnections.map(maskConnection);
}
if (portalConnections) {
if (platformInfo.allowShellConnection) return portalConnections;
if (platformInfo.allowShellConnection) return portalConnections.map(x => encryptConnection(x));
return portalConnections.map(maskConnection).filter(x => connectionHasPermission(x, loadedPermissions));
}
return (await this.datastore.find()).filter(x => connectionHasPermission(x, loadedPermissions));
@@ -239,14 +241,60 @@ module.exports = {
);
pipeForkLogs(subprocess);
subprocess.send({ ...connection, requestDbList });
return new Promise(resolve => {
return new Promise((resolve, reject) => {
let isWaitingForVolatile = false;
const cleanup = () => {
if (connection._id && pendingTestSubprocesses[connection._id]) {
delete pendingTestSubprocesses[connection._id];
}
};
subprocess.on('message', resp => {
if (handleProcessCommunication(resp, subprocess)) return;
// @ts-ignore
const { msgtype } = resp;
const { msgtype, missingCredentialsDetail } = resp;
if (msgtype == 'connected' || msgtype == 'error') {
cleanup();
resolve(resp);
}
if (msgtype == 'missingCredentials') {
if (missingCredentialsDetail?.redirectToDbLogin) {
// Store the subprocess for later when volatile connection is ready
isWaitingForVolatile = true;
pendingTestSubprocesses[connection._id] = {
subprocess,
requestDbList,
};
// Return immediately with redirectToDbLogin status in the old format
resolve({
missingCredentials: true,
detail: {
...missingCredentialsDetail,
keepErrorResponseFromApi: true,
},
});
return;
}
reject(new MissingCredentialsError(missingCredentialsDetail));
}
});
subprocess.on('exit', code => {
// If exit happens while waiting for volatile, that's expected
if (isWaitingForVolatile && code === 0) {
cleanup();
return;
}
cleanup();
if (code !== 0) {
reject(new Error(`Test subprocess exited with code ${code}`));
}
});
subprocess.on('error', err => {
cleanup();
reject(err);
});
});
},
@@ -279,6 +327,38 @@ module.exports = {
return testRes;
} else {
volatileConnections[res._id] = res;
// Check if there's a pending test subprocess waiting for this volatile connection
const pendingTest = pendingTestSubprocesses[conid];
if (pendingTest) {
const { subprocess, requestDbList } = pendingTest;
try {
// Send the volatile connection to the waiting subprocess
subprocess.send({ ...res, requestDbList, isVolatileResolved: true });
// Wait for the test result and emit it as an event
subprocess.once('message', resp => {
if (handleProcessCommunication(resp, subprocess)) return;
const { msgtype } = resp;
if (msgtype == 'connected' || msgtype == 'error') {
// Emit SSE event with test result
socket.emit(`connection-test-result-${conid}`, {
...resp,
volatileConId: res._id,
});
delete pendingTestSubprocesses[conid];
}
});
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00118 Error sending volatile connection to test subprocess');
socket.emit(`connection-test-result-${conid}`, {
msgtype: 'error',
error: err.message,
});
delete pendingTestSubprocesses[conid];
}
}
return res;
}
},
@@ -404,12 +484,12 @@ module.exports = {
const storageConnection = await storage.getConnection({ conid });
if (storageConnection) {
return storageConnection;
return mask ? maskConnection(storageConnection) : storageConnection;
}
if (portalConnections) {
const res = portalConnections.find(x => x._id == conid) || null;
return mask && !platformInfo.allowShellConnection ? maskConnection(res) : res;
return mask && !platformInfo.allowShellConnection ? maskConnection(res) : encryptConnection(res);
}
const res = await this.datastore.get(conid);
return res || null;
@@ -422,6 +502,9 @@ module.exports = {
_id: '__model',
};
}
if (!conid) {
return null;
}
await testConnectionPermission(conid, req);
return this.getCore({ conid, mask: true });
},
@@ -15,6 +15,7 @@ const {
getLogger,
extractErrorLogData,
filterStructureBySchema,
serializeJsTypesForJsonStringify,
} = require('dbgate-tools');
const { html, parse } = require('diff2html');
const { handleProcessCommunication } = require('../utility/processComm');
@@ -165,6 +166,11 @@ module.exports = {
if (!connection) {
throw new Error(`databaseConnections: Connection with conid="${conid}" not found`);
}
if (connection.engine?.endsWith('@rest')) {
return { isApiConnection: true };
}
if (connection.passwordMode == 'askPassword' || connection.passwordMode == 'askUser') {
throw new MissingCredentialsError({ conid, passwordMode: connection.passwordMode });
}
@@ -219,12 +225,13 @@ module.exports = {
this.close(conid, database, false);
});
subprocess.send({
const connectMessage = serializeJsTypesForJsonStringify({
msgtype: 'connect',
connection: { ...connection, database },
structure: lastClosed ? lastClosed.structure : null,
globalSettings: await config.getSettings(),
});
subprocess.send(connectMessage);
return newOpened;
},
@@ -234,7 +241,8 @@ module.exports = {
const promise = new Promise((resolve, reject) => {
this.requests[msgid] = [resolve, reject, additionalData];
try {
conn.subprocess.send({ msgid, ...message });
const serializedMessage = serializeJsTypesForJsonStringify({ msgid, ...message });
conn.subprocess.send(serializedMessage);
} catch (err) {
logger.error(extractErrorLogData(err), 'DBGM-00115 Error sending request do process');
this.close(conn.conid, conn.database);
@@ -393,6 +401,12 @@ module.exports = {
return null;
},
dispatchRedisKeysChanged_meta: true,
dispatchRedisKeysChanged({ conid, database }) {
socket.emit(`redis-keys-changed-${conid}-${database}`);
return null;
},
loadKeys_meta: true,
async loadKeys({ conid, database, root, filter, limit }, req) {
await testConnectionPermission(conid, req);
@@ -462,6 +476,7 @@ module.exports = {
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const tablePermissions = await loadTablePermissionsFromRequest(req);
const databasePermissionRole = getDatabasePermissionRole(conid, database, databasePermissions);
const fieldsAndRoles = [
[changeSet.inserts, 'create_update_delete'],
[changeSet.deletes, 'create_update_delete'],
@@ -476,7 +491,7 @@ module.exports = {
operation.schemaName,
operation.pureName,
tablePermissions,
databasePermissions
databasePermissionRole
);
if (getTablePermissionRoleLevelIndex(role) < getTablePermissionRoleLevelIndex(requiredRole)) {
throw new Error('DBGM-00262 Permission not granted');
@@ -0,0 +1,41 @@
module.exports = {
disconnect_meta: true,
async disconnect({ conid }, req) {
return null;
},
getApiInfo_meta: true,
async getApiInfo({ conid }, req) {
return null;
},
restStatus_meta: true,
async restStatus() {
return {};
},
ping_meta: true,
async ping({ conidArray, strmid }) {
return null;
},
refresh_meta: true,
async refresh({ conid, keepOpen }, req) {
return null;
},
testConnection_meta: true,
async testConnection({ conid }, req) {
return null;
},
execute_meta: true,
async execute({ conid, method, endpoint, parameters, server }, req) {
return null;
},
apiQuery_meta: true,
async apiQuery({ conid, server, query, variables }, req) {
return null;
},
};
+10 -10
View File
@@ -172,7 +172,7 @@ module.exports = {
byline(subprocess.stderr).on('data', pipeDispatcher('error'));
subprocess.on('exit', code => {
// console.log('... EXITED', code);
this.rejectRequest(runid, { message: 'No data returned, maybe input data source is too big' });
this.rejectRequest(runid, { message: 'DBGM-00281 No data returned, maybe input data source is too big' });
logger.info({ code, pid: subprocess.pid }, 'DBGM-00016 Exited process');
socket.emit(`runner-done-${runid}`, code);
this.opened = this.opened.filter(x => x.runid != runid);
@@ -225,7 +225,7 @@ module.exports = {
subprocess.on('exit', code => {
console.log('... EXITED', code);
logger.info({ code, pid: subprocess.pid }, 'DBGM-00017 Exited process');
this.dispatchMessage(runid, `Finished external process with code ${code}`);
this.dispatchMessage(runid, `DBGM-00282 Finished external process with code ${code}`);
socket.emit(`runner-done-${runid}`, code);
if (onFinished) {
onFinished();
@@ -233,7 +233,7 @@ module.exports = {
this.opened = this.opened.filter(x => x.runid != runid);
});
subprocess.on('spawn', () => {
this.dispatchMessage(runid, `Started external process ${command}`);
this.dispatchMessage(runid, `DBGM-00283 Started external process ${command}`);
});
subprocess.on('error', error => {
console.log('... ERROR subprocess', error);
@@ -279,7 +279,7 @@ module.exports = {
if (script.type == 'json') {
if (!platformInfo.isElectron) {
if (!checkSecureDirectoriesInScript(script)) {
return { errorMessage: 'Unallowed directories in script' };
return { errorMessage: 'DBGM-00284 Unallowed directories in script' };
}
}
@@ -299,10 +299,10 @@ module.exports = {
action: 'script',
severity: 'warn',
detail: script,
message: 'Scripts are not allowed',
message: 'DBGM-00285 Scripts are not allowed',
});
return { errorMessage: 'Shell scripting is not allowed' };
return { errorMessage: 'DBGM-00286 Shell scripting is not allowed' };
}
sendToAuditLog(req, {
@@ -312,7 +312,7 @@ module.exports = {
action: 'script',
severity: 'info',
detail: script,
message: 'Running JS script',
message: 'DBGM-00287 Running JS script',
});
return this.startCore(runid, scriptTemplate(script, false));
@@ -327,7 +327,7 @@ module.exports = {
async cancel({ runid }) {
const runner = this.opened.find(x => x.runid == runid);
if (!runner) {
throw new Error('Invalid runner');
throw new Error('DBGM-00288 Invalid runner');
}
runner.subprocess.kill();
return { state: 'ok' };
@@ -353,7 +353,7 @@ module.exports = {
async loadReader({ functionName, props }) {
if (!platformInfo.isElectron) {
if (props?.fileName && !checkSecureDirectories(props.fileName)) {
return { errorMessage: 'Unallowed file' };
return { errorMessage: 'DBGM-00289 Unallowed file' };
}
}
const prefix = extractShellApiPlugins(functionName)
@@ -371,7 +371,7 @@ module.exports = {
scriptResult_meta: true,
async scriptResult({ script }) {
if (script.type != 'json') {
return { errorMessage: 'Only JSON scripts are allowed' };
return { errorMessage: 'DBGM-00290 Only JSON scripts are allowed' };
}
const promise = new Promise(async (resolve, reject) => {
@@ -171,7 +171,7 @@ module.exports = {
const databasePermissions = await loadDatabasePermissionsFromRequest(req);
const res = [];
for (const db of opened?.databases ?? []) {
const databasePermissionRole = getDatabasePermissionRole(db.id, db.name, databasePermissions);
const databasePermissionRole = getDatabasePermissionRole(conid, db.name, databasePermissions);
if (databasePermissionRole != 'deny') {
res.push({
...db,
+1 -1
View File
@@ -1,5 +1,5 @@
module.exports = {
version: '6.0.0-alpha.1',
version: '7.0.0-alpha.1',
buildTime: '2024-12-01T00:00:00Z'
};
+2
View File
@@ -14,6 +14,7 @@ const socket = require('./utility/socket');
const connections = require('./controllers/connections');
const serverConnections = require('./controllers/serverConnections');
const databaseConnections = require('./controllers/databaseConnections');
const restConnections = require('./controllers/restConnections');
const metadata = require('./controllers/metadata');
const sessions = require('./controllers/sessions');
const runners = require('./controllers/runners');
@@ -267,6 +268,7 @@ function useAllControllers(app, electron) {
useController(app, electron, '/auth', auth);
useController(app, electron, '/cloud', cloud);
useController(app, electron, '/team-files', teamFiles);
useController(app, electron, '/rest-connections', restConnections);
}
function setElectronSender(electronSender) {
+39 -3
View File
@@ -1,6 +1,6 @@
const childProcessChecker = require('../utility/childProcessChecker');
const requireEngineDriver = require('../utility/requireEngineDriver');
const { connectUtility } = require('../utility/connectUtility');
const { connectUtility, getRestAuthFromConnection } = require('../utility/connectUtility');
const { handleProcessCommunication } = require('../utility/processComm');
const { pickSafeConnectionInfo } = require('../utility/crypting');
const _ = require('lodash');
@@ -18,13 +18,39 @@ Platform: ${process.platform}
function start() {
childProcessChecker();
process.on('message', async connection => {
let isWaitingForVolatile = false;
const handleConnection = async connection => {
// @ts-ignore
const { requestDbList } = connection;
if (handleProcessCommunication(connection)) return;
try {
const driver = requireEngineDriver(connection);
const dbhan = await connectUtility(driver, connection, 'app');
const connectionChanged = driver?.beforeConnectionSave ? driver.beforeConnectionSave(connection) : connection;
if (driver?.databaseEngineTypes?.includes('rest')) {
connectionChanged.restAuth = getRestAuthFromConnection(connection);
}
if (!connection.isVolatileResolved) {
if (connectionChanged.useRedirectDbLogin) {
process.send({
msgtype: 'missingCredentials',
missingCredentialsDetail: {
// @ts-ignore
conid: connection._id,
redirectToDbLogin: true,
keepErrorResponseFromApi: true,
},
});
// Don't exit - wait for volatile connection to be sent
isWaitingForVolatile = true;
return;
}
}
const dbhan = await connectUtility(driver, connectionChanged, 'app');
let version = {
version: 'Unknown',
};
@@ -45,6 +71,16 @@ function start() {
}
process.exit(0);
};
process.on('message', async connection => {
// If we're waiting for volatile and receive a new connection, use it
if (isWaitingForVolatile) {
isWaitingForVolatile = false;
await handleConnection(connection);
} else {
await handleConnection(connection);
}
});
}
+2
View File
@@ -1,6 +1,7 @@
const connectProcess = require('./connectProcess');
const databaseConnectionProcess = require('./databaseConnectionProcess');
const serverConnectionProcess = require('./serverConnectionProcess');
const restConnectionProcess = require('./restConnectionProcess');
const sessionProcess = require('./sessionProcess');
const jslDatastoreProcess = require('./jslDatastoreProcess');
const sshForwardProcess = require('./sshForwardProcess');
@@ -9,6 +10,7 @@ module.exports = {
connectProcess,
databaseConnectionProcess,
serverConnectionProcess,
restConnectionProcess,
sessionProcess,
jslDatastoreProcess,
sshForwardProcess,
@@ -0,0 +1,7 @@
const childProcessChecker = require('../utility/childProcessChecker');
function start() {
childProcessChecker();
}
module.exports = { start };
+9 -3
View File
@@ -4,7 +4,8 @@ const { pluginsdir, packagedPluginsDir, getPluginBackendPath } = require('../uti
const platformInfo = require('../utility/platformInfo');
const authProxy = require('../utility/authProxy');
const { getLogger } = require('dbgate-tools');
//
const { openApiDriver, graphQlDriver, oDataDriver } = require('dbgate-rest');
//
const logger = getLogger('requirePlugin');
const loadedPlugins = {};
@@ -13,16 +14,21 @@ const dbgateEnv = {
dbgateApi: null,
platformInfo,
authProxy,
isProApp: () =>{
isProApp: () => {
const { isProApp } = require('../utility/checkLicense');
return isProApp();
}
},
};
function requirePlugin(packageName, requiredPlugin = null) {
if (!packageName) throw new Error('Missing packageName in plugin');
if (loadedPlugins[packageName]) return loadedPlugins[packageName];
if (requiredPlugin == null) {
if (packageName.endsWith('@rest') || packageName === 'rest') {
return {
drivers: [openApiDriver, graphQlDriver, oDataDriver],
};
}
let module;
const modulePath = getPluginBackendPath(packageName);
logger.info(`DBGM-00062 Loading module ${packageName} from ${modulePath}`);
+78
View File
@@ -2174,6 +2174,84 @@ module.exports = {
]
}
},
{
"pureName": "user_password_reset_tokens",
"columns": [
{
"pureName": "user_password_reset_tokens",
"columnName": "id",
"dataType": "int",
"autoIncrement": true,
"notNull": true
},
{
"pureName": "user_password_reset_tokens",
"columnName": "user_id",
"dataType": "int",
"notNull": true
},
{
"pureName": "user_password_reset_tokens",
"columnName": "token",
"dataType": "varchar(500)",
"notNull": true
},
{
"pureName": "user_password_reset_tokens",
"columnName": "created_at",
"dataType": "varchar(32)",
"notNull": true
},
{
"pureName": "user_password_reset_tokens",
"columnName": "expires_at",
"dataType": "varchar(32)",
"notNull": true
},
{
"pureName": "user_password_reset_tokens",
"columnName": "used_at",
"dataType": "varchar(32)",
"notNull": false
}
],
"foreignKeys": [
{
"constraintType": "foreignKey",
"constraintName": "FK_user_password_reset_tokens_user_id",
"pureName": "user_password_reset_tokens",
"refTableName": "users",
"columns": [
{
"columnName": "user_id",
"refColumnName": "id"
}
]
}
],
"indexes": [
{
"constraintName": "idx_token",
"pureName": "user_password_reset_tokens",
"constraintType": "index",
"columns": [
{
"columnName": "token"
}
]
}
],
"primaryKey": {
"pureName": "user_password_reset_tokens",
"constraintType": "primaryKey",
"constraintName": "PK_user_password_reset_tokens",
"columns": [
{
"columnName": "id"
}
]
}
},
{
"pureName": "user_permissions",
"columns": [
+29 -1
View File
@@ -1,9 +1,10 @@
const fs = require('fs-extra');
const { decryptConnection } = require('./crypting');
const { decryptConnection, decryptPasswordString } = require('./crypting');
const { getSshTunnelProxy } = require('./sshTunnelProxy');
const platformInfo = require('../utility/platformInfo');
const connections = require('../controllers/connections');
const _ = require('lodash');
const axios = require('axios');
async function loadConnection(driver, storedConnection, connectionMode) {
const { allowShellConnection, allowConnectionFromEnvVariables } = platformInfo;
@@ -131,12 +132,39 @@ async function connectUtility(driver, storedConnection, connectionMode, addition
}
connection.ssl = await extractConnectionSslParams(connection);
connection.axios = axios.default;
const conn = await driver.connect({ conid: connectionLoaded?._id, ...connection, ...additionalOptions });
return conn;
}
function getRestAuthFromConnection(connection) {
if (!connection) return null;
if (connection.authType == 'basic') {
return {
type: 'basic',
user: connection.user,
password: decryptPasswordString(connection.password),
};
}
if (connection.authType == 'bearer') {
return {
type: 'bearer',
token: connection.authToken,
};
}
if (connection.authType == 'apikey') {
return {
type: 'apikey',
header: connection.apiKeyHeader,
value: connection.apiKeyValue,
};
}
return null;
}
module.exports = {
extractConnectionSslParams,
connectUtility,
getRestAuthFromConnection,
};
+21 -1
View File
@@ -102,6 +102,26 @@ function decryptObjectPasswordField(obj, field, encryptor = null) {
}
const fieldsToEncrypt = ['password', 'sshPassword', 'sshKeyfilePassword', 'connectionDefinition'];
const additionalFieldsToMask = [
'databaseUrl',
'server',
'port',
'user',
'sshBastionHost',
'sshHost',
'sshKeyFile',
'sshLogin',
'sshMode',
'sshPort',
'sslCaFile',
'sslCertFilePassword',
'sslKeyFile',
'sslRejectUnauthorized',
'secretAccessKey',
'accessKeyId',
'endpoint',
'endpointKey',
];
function encryptConnection(connection, encryptor = null) {
if (connection.passwordMode != 'saveRaw') {
@@ -114,7 +134,7 @@ function encryptConnection(connection, encryptor = null) {
function maskConnection(connection) {
if (!connection) return connection;
return _.omit(connection, fieldsToEncrypt);
return _.omit(connection, [...fieldsToEncrypt, ...additionalFieldsToMask]);
}
function decryptConnection(connection) {
+13 -2
View File
@@ -25,8 +25,14 @@ function extractConnectionsFromEnv(env) {
socketPath: env[`SOCKET_PATH_${id}`],
serviceName: env[`SERVICE_NAME_${id}`],
authType: env[`AUTH_TYPE_${id}`] || (env[`SOCKET_PATH_${id}`] ? 'socket' : undefined),
defaultDatabase: env[`DATABASE_${id}`] || (env[`FILE_${id}`] ? getDatabaseFileLabel(env[`FILE_${id}`]) : null),
singleDatabase: !!env[`DATABASE_${id}`] || !!env[`FILE_${id}`],
defaultDatabase:
env[`DATABASE_${id}`] ||
(env[`FILE_${id}`]
? getDatabaseFileLabel(env[`FILE_${id}`])
: env[`APISERVERURL1_${id}`]
? '_api_database_'
: null),
singleDatabase: !!env[`DATABASE_${id}`] || !!env[`FILE_${id}`] || !!env[`APISERVERURL1_${id}`],
displayName: env[`LABEL_${id}`],
isReadOnly: env[`READONLY_${id}`],
databases: env[`DBCONFIG_${id}`] ? safeJsonParse(env[`DBCONFIG_${id}`]) : null,
@@ -54,6 +60,11 @@ function extractConnectionsFromEnv(env) {
sslKeyFile: env[`SSL_KEY_FILE_${id}`],
sslRejectUnauthorized: env[`SSL_REJECT_UNAUTHORIZED_${id}`],
trustServerCertificate: env[`SSL_TRUST_CERTIFICATE_${id}`],
apiServerUrl1: env[`APISERVERURL1_${id}`],
apiServerUrl2: env[`APISERVERURL2_${id}`],
apiKeyHeader: env[`APIKEYHEADER_${id}`],
apiKeyValue: env[`APIKEYVALUE_${id}`],
}));
return connections;
+3 -3
View File
@@ -1,7 +1,7 @@
const getDiagramExport = (html, css, themeType, themeVariables, watermark) => {
const watermarkHtml = watermark
? `
<div style="position: fixed; bottom: 0; right: 0; padding: 5px; font-size: 12px; color: var(--theme-font-2); background-color: var(--theme-bg-2); border-top-left-radius: 5px; border: 1px solid var(--theme-border);">
<div style="position: fixed; bottom: 0; right: 0; padding: 5px; font-size: 12px; color: var(--theme-generic-font-grayed); background-color: var(--theme-datagrid-background); border-top-left-radius: 5px; border: var(--theme-card-border);">
${watermark}
</div>
`
@@ -22,8 +22,8 @@ const getDiagramExport = (html, css, themeType, themeVariables, watermark) => {
${css}
body {
background: var(--theme-bg-1);
color: var(--theme-font-1);
background: var(--theme-datagrid-background);
color: var(--theme-generic-font);
}
</style>
+3 -2
View File
@@ -96,8 +96,9 @@ async function loadFilePermissionsFromRequest(req) {
}
function matchDatabasePermissionRow(conid, database, permissionRow) {
if (permissionRow.connection_id) {
if (conid != permissionRow.connection_id) {
const connectionIdentifier = permissionRow.connection_conid ?? permissionRow.connection_id;
if (connectionIdentifier) {
if (conid != connectionIdentifier) {
return false;
}
}
+5 -5
View File
@@ -1,5 +1,5 @@
{
"version": "6.0.0-alpha.1",
"version": "7.0.0-alpha.1",
"name": "dbgate-datalib",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
@@ -19,14 +19,14 @@
],
"dependencies": {
"date-fns": "^4.1.0",
"dbgate-filterparser": "^6.0.0-alpha.1",
"dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-tools": "^6.0.0-alpha.1",
"dbgate-filterparser": "^7.0.0-alpha.1",
"dbgate-sqltree": "^7.0.0-alpha.1",
"dbgate-tools": "^7.0.0-alpha.1",
"uuid": "^3.4.0"
},
"devDependencies": {
"@types/node": "^13.7.0",
"dbgate-types": "^6.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"jest": "^28.1.3",
"ts-jest": "^28.0.7",
"typescript": "^4.4.3"
+30 -8
View File
@@ -15,7 +15,7 @@ export interface ChangeSetRedis_JSON {
export interface ChangeSetRedis_Hash {
key: string;
type: 'hash';
inserts: { key: string; value: string; ttl: number }[];
inserts: { key: string; value: string; ttl: number; editorRowId: string }[];
updates: { key: string; value: string; ttl: number }[];
deletes: string[];
}
@@ -23,7 +23,7 @@ export interface ChangeSetRedis_Hash {
export interface ChangeSetRedis_List {
key: string;
type: 'list';
inserts: { index: number; value: string }[];
inserts: { value: string; editorRowId: string }[];
updates: { index: number; value: string }[];
deletes: number[];
}
@@ -31,25 +31,34 @@ export interface ChangeSetRedis_List {
export interface ChangeSetRedis_Set {
key: string;
type: 'set';
inserts: string[];
inserts: { value: string; editorRowId: string }[];
deletes: string[];
}
export interface ChangeSetRedis_ZSet {
key: string;
type: 'zset';
inserts: { member: string; score: number }[];
inserts: { member: string; score: number; editorRowId: string }[];
updates: { member: string; score: number }[];
deletes: string[];
}
export interface ChangeSetRedis_Stream {
key: string;
type: 'stream';
generatedId?: string;
inserts: { field: string; value: string; editorRowId: string }[];
deletes: string[];
}
export type ChangeSetRedisType =
| ChangeSetRedis_String
| ChangeSetRedis_JSON
| ChangeSetRedis_Hash
| ChangeSetRedis_List
| ChangeSetRedis_Set
| ChangeSetRedis_ZSet;
| ChangeSetRedis_ZSet
| ChangeSetRedis_Stream;
export interface ChangeSetRedis {
changes: ChangeSetRedisType[];
@@ -160,7 +169,7 @@ export function redisChangeSetToRedisCommands(changeSet: ChangeSetRedis): Databa
for (const insert of change.inserts) {
calls.push({
method: 'SADD',
args: [change.key, insert],
args: [change.key, insert.value],
});
}
}
@@ -173,6 +182,19 @@ export function redisChangeSetToRedisCommands(changeSet: ChangeSetRedis): Databa
});
}
}
} else if (change.type === 'stream') {
if (change.inserts.length > 0) {
calls.push({
method: 'XADD',
args: [change.key, change.generatedId || '*', ...change.inserts.flatMap(f => [f.field, f.value])],
});
}
for (const delValue of change.deletes) {
calls.push({
method: 'XDEL',
args: [change.key, delValue],
});
}
}
}
@@ -182,7 +204,7 @@ export function redisChangeSetToRedisCommands(changeSet: ChangeSetRedis): Databa
export function convertRedisCallListToScript(callList: DatabaseMethodCallList): string {
let script = '';
for (const call of callList.calls) {
script += `${call.method} ${call.args.map((arg) => (typeof arg === 'string' ? `"${arg}"` : arg)).join(' ')}\n`;
script += `${call.method} ${call.args.map(arg => (typeof arg === 'string' ? `"${arg}"` : arg)).join(' ')}\n`;
}
return script;
}
}
@@ -84,8 +84,12 @@ export function analyseCollectionDisplayColumns(rows, display) {
if (res.find(x => x.uniqueName == added)) continue;
res.push(getDisplayColumn([], added, display));
}
// Use driver-specific column sorting if available
const sortedColumns = display?.driver?.sortCollectionDisplayColumns ? display.driver.sortCollectionDisplayColumns(res) : res;
return (
res.map(col => ({
sortedColumns.map(col => ({
...col,
isChecked: display.isColumnChecked(col),
})) || []
+5 -2
View File
@@ -1,5 +1,6 @@
import _ from 'lodash';
import type { EngineDriver, ViewInfo, ColumnInfo } from 'dbgate-types';
import { evalFilterBehaviour } from 'dbgate-tools';
import { GridDisplay, ChangeCacheFunc, ChangeConfigFunc } from './GridDisplay';
import { GridConfig, GridCache } from './GridConfig';
import { FreeTableModel } from './FreeTableModel';
@@ -11,13 +12,15 @@ export class FreeTableGridDisplay extends GridDisplay {
config: GridConfig,
setConfig: ChangeConfigFunc,
cache: GridCache,
setCache: ChangeCacheFunc
setCache: ChangeCacheFunc,
options: { filterable?: boolean } = {}
) {
super(config, setConfig, cache, setCache);
this.columns = model?.structure?.__isDynamicStructure
? analyseCollectionDisplayColumns(model?.rows, this)
: this.getDisplayColumns(model);
this.filterable = false;
this.filterable = options.filterable ?? false;
this.filterBehaviourOverride = evalFilterBehaviour;
this.sortable = false;
}
+2 -1
View File
@@ -451,7 +451,7 @@ export abstract class GridDisplay {
...cfg,
filters: _.omit(cfg.filters, [uniqueName]),
formFilterColumns: (cfg.formFilterColumns || []).filter(x => x != uniqueName),
disabledFilterColumns: (cfg.disabledFilterColumns).filter(x => x != uniqueName),
disabledFilterColumns: cfg.disabledFilterColumns.filter(x => x != uniqueName),
}));
this.reload();
}
@@ -541,6 +541,7 @@ export abstract class GridDisplay {
const column = (this.baseTable || this.baseView)?.columns?.find(x => x.columnName == uniqueName);
if (isTypeLogical(column?.dataType)) return 'COUNT DISTINCT';
if (column?.autoIncrement) return 'COUNT';
if (this.driver?.dialect?.disableGroupingForDataType?.(column?.dataType)) return 'NULL';
return 'MAX';
}
return null;
+1 -1
View File
@@ -1,5 +1,5 @@
# dbmodel
Deploy, load or build script from model of SQL database. Can be used as command-line tool. Uses [DbGate](https://dbgate.org) tooling and plugins for connecting many different databases.
Deploy, load or build script from model of SQL database. Can be used as command-line tool. Uses [DbGate](www.dbgate.io) tooling and plugins for connecting many different databases.
If you want to use this tool from JavaScript interface, please use [dbgate-api](https://www.npmjs.com/package/dbgate-api) package.
+12 -12
View File
@@ -1,7 +1,7 @@
{
"name": "dbmodel",
"version": "6.0.0-alpha.1",
"homepage": "https://dbgate.org/",
"version": "7.0.0-alpha.1",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
@@ -30,16 +30,16 @@
],
"dependencies": {
"commander": "^10.0.0",
"dbgate-api": "^6.0.0-alpha.1",
"dbgate-plugin-csv": "^6.0.0-alpha.1",
"dbgate-plugin-excel": "^6.0.0-alpha.1",
"dbgate-plugin-mongo": "^6.0.0-alpha.1",
"dbgate-plugin-mssql": "^6.0.0-alpha.1",
"dbgate-plugin-mysql": "^6.0.0-alpha.1",
"dbgate-plugin-postgres": "^6.0.0-alpha.1",
"dbgate-plugin-xml": "^6.0.0-alpha.1",
"dbgate-plugin-oracle": "^6.0.0-alpha.1",
"dbgate-web": "^6.0.0-alpha.1",
"dbgate-api": "^7.0.0-alpha.1",
"dbgate-plugin-csv": "^7.0.0-alpha.1",
"dbgate-plugin-excel": "^7.0.0-alpha.1",
"dbgate-plugin-mongo": "^7.0.0-alpha.1",
"dbgate-plugin-mssql": "^7.0.0-alpha.1",
"dbgate-plugin-mysql": "^7.0.0-alpha.1",
"dbgate-plugin-postgres": "^7.0.0-alpha.1",
"dbgate-plugin-xml": "^7.0.0-alpha.1",
"dbgate-plugin-oracle": "^7.0.0-alpha.1",
"dbgate-web": "^7.0.0-alpha.1",
"dotenv": "^16.0.0",
"pinomin": "^1.0.5"
}
+3 -3
View File
@@ -1,5 +1,5 @@
{
"version": "6.0.0-alpha.1",
"version": "7.0.0-alpha.1",
"name": "dbgate-filterparser",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
@@ -17,7 +17,7 @@
"lib"
],
"devDependencies": {
"dbgate-types": "^6.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"@types/jest": "^25.1.4",
"@types/node": "^13.7.0",
"jest": "^28.1.3",
@@ -26,7 +26,7 @@
},
"dependencies": {
"@types/parsimmon": "^1.10.1",
"dbgate-tools": "^6.0.0-alpha.1",
"dbgate-tools": "^7.0.0-alpha.1",
"lodash": "^4.17.21",
"date-fns": "^4.1.0",
"moment": "^2.24.0",
+40 -1
View File
@@ -16,7 +16,46 @@ function getDateStringWithoutTimeZone(dateString) {
export function getFilterValueExpression(value, dataType?) {
if (value == null) return 'NULL';
if (isTypeDateTime(dataType)) return format(toDate(getDateStringWithoutTimeZone(value)), 'yyyy-MM-dd HH:mm:ss');
if (isTypeDateTime(dataType)) {
// Check for year as number (GROUP:YEAR)
if (typeof value === 'number' && Number.isInteger(value) && value >= 1000 && value <= 9999) {
return value.toString();
}
if (_isString(value)) {
// Year only
if (/^\d{4}$/.test(value)) {
return value;
}
// Year-month: validate month is in range 01-12
const yearMonthMatch = value.match(/^(\d{4})-(\d{1,2})$/);
if (yearMonthMatch) {
const month = parseInt(yearMonthMatch[2], 10);
if (month >= 1 && month <= 12) {
return value;
}
}
// Year-month-day: validate month and day
const yearMonthDayMatch = value.match(/^(\d{4})-(\d{1,2})-(\d{1,2})$/);
if (yearMonthDayMatch) {
const month = parseInt(yearMonthDayMatch[2], 10);
const day = parseInt(yearMonthDayMatch[3], 10);
// Quick validation: month 1-12, day 1-31
if (month >= 1 && month <= 12 && day >= 1 && day <= 31) {
// Construct a date to verify it's actually valid (e.g., reject 2024-02-30)
const dateStr = `${yearMonthDayMatch[1]}-${String(month).padStart(2, '0')}-${String(day).padStart(2, '0')}`;
const date = toDate(dateStr);
if (!isNaN(date.getTime())) {
return value;
}
}
}
}
return format(toDate(getDateStringWithoutTimeZone(value)), 'yyyy-MM-dd HH:mm:ss');
}
if (value === true) return 'TRUE';
if (value === false) return 'FALSE';
if (value.$oid) return `ObjectId("${value.$oid}")`;
+1
View File
@@ -0,0 +1 @@
lib
+7
View File
@@ -0,0 +1,7 @@
# dbgate-rest
REST API support for DbGate
## Installation
yarn add dbgate-rest
+6
View File
@@ -0,0 +1,6 @@
module.exports = {
preset: 'ts-jest',
testEnvironment: 'node',
moduleFileExtensions: ['ts', 'js'],
reporters: ['default', 'github-actions'],
};
+42
View File
@@ -0,0 +1,42 @@
{
"version": "7.0.0-alpha.1",
"name": "dbgate-rest",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
},
"author": "Jan Prochazka",
"license": "GPL-3.0",
"keywords": [
"sql",
"dbgate"
],
"scripts": {
"build": "tsc",
"start": "tsc --watch",
"prepublishOnly": "yarn build",
"test": "jest",
"test:ci": "jest --json --outputFile=result.json --testLocationInResults"
},
"files": [
"lib"
],
"devDependencies": {
"@types/node": "^13.7.0",
"dbgate-types": "^7.0.0-alpha.1",
"jest": "^28.1.3",
"ts-jest": "^28.0.7",
"typescript": "^4.4.3"
},
"dependencies": {
"dbgate-tools": "^7.0.0-alpha.1",
"lodash": "^4.17.21",
"openapi-types": "^12.1.3",
"pinomin": "^1.0.5",
"uuid": "^3.4.0",
"js-yaml": "^4.1.0"
}
}
+90
View File
@@ -0,0 +1,90 @@
type FlatObject = Record<string, any>;
function isPlainObject(value: any): value is Record<string, any> {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function flattenValue(value: any) {
if (Array.isArray(value)) {
const primitiveArray = value.every(item => item == null || typeof item !== 'object');
if (primitiveArray) {
return value.join(', ');
}
return JSON.stringify(value);
}
return value;
}
function flattenObject(obj: Record<string, any>, prefix = '', out: FlatObject = {}, visited = new WeakSet()): FlatObject {
if (visited.has(obj)) return out;
visited.add(obj);
for (const [key, value] of Object.entries(obj)) {
const nextKey = prefix ? `${prefix}.${key}` : key;
if (isPlainObject(value)) {
flattenObject(value, nextKey, out, visited);
continue;
}
out[nextKey] = flattenValue(value);
}
return out;
}
function unwrapArrayItem(item: any) {
if (isPlainObject(item) && isPlainObject(item.node)) {
return item.node;
}
return item;
}
function collectArrayCandidates(
value: any,
set: Set<any[]>,
visited = new WeakSet(),
depth = 0
): void {
if (depth > 10) return;
if (Array.isArray(value)) {
set.add(value);
return;
}
if (!isPlainObject(value)) return;
if (visited.has(value)) return;
visited.add(value);
if (Array.isArray(value.edges)) set.add(value.edges);
if (Array.isArray(value.nodes)) set.add(value.nodes);
if (Array.isArray(value.items)) set.add(value.items);
for (const nested of Object.values(value)) {
collectArrayCandidates(nested, set, visited, depth + 1);
}
}
function findUniqueArrayCandidate(value: any): any[] | null {
if (Array.isArray(value)) return value;
const candidates = new Set<any[]>();
collectArrayCandidates(value, candidates);
if (candidates.size !== 1) return null;
return candidates.values().next().value ?? null;
}
export function arrayifyToFlatObjects(input: any): FlatObject[] | undefined {
const arrayCandidate = findUniqueArrayCandidate(input);
if (!arrayCandidate) return undefined;
return arrayCandidate.map(item => {
const unwrapped = unwrapArrayItem(item);
if (isPlainObject(unwrapped)) {
return flattenObject(unwrapped);
}
return { value: unwrapped };
});
}
+65
View File
@@ -0,0 +1,65 @@
import type { EngineDriver } from 'dbgate-types';
import { fetchGraphQLSchema, GraphQLIntrospectionResult } from './graphqlIntrospection';
import { apiDriverBase } from './restDriverBase';
import { buildRestAuthHeaders } from './restAuthTools';
async function loadGraphQlSchema(dbhan: any): Promise<GraphQLIntrospectionResult> {
if (!dbhan?.connection?.apiServerUrl1) {
throw new Error('DBGM-00310 GraphQL endpoint URL is not configured');
}
const introspectionResult = await fetchGraphQLSchema(
dbhan.connection.apiServerUrl1,
buildRestAuthHeaders(dbhan.connection.restAuth),
dbhan.axios
);
if (!introspectionResult || typeof introspectionResult !== 'object') {
throw new Error('DBGM-00311 GraphQL schema is empty or could not be loaded');
}
return introspectionResult;
}
// @ts-ignore
export const graphQlDriver: EngineDriver = {
...apiDriverBase,
engine: 'graphql@rest',
title: 'GraphQL',
databaseEngineTypes: ['rest', 'graphql'],
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 (field === 'apiServerUrl1') return true;
return false;
},
apiServerUrl1Label: 'GraphQL Endpoint URL',
beforeConnectionSave: connection => ({
...connection,
singleDatabase: true,
defaultDatabase: '_api_database_',
}),
async connect(connection: any) {
return {
connection,
client: null,
database: '_api_database_',
axios: connection.axios,
};
},
async getVersion(dbhan: any) {
const introspectionResult = await loadGraphQlSchema(dbhan);
const schema = introspectionResult.__schema;
// const version = 'GraphQL';
return {
version: `GraphQL, ${schema.types?.length || 0} types`,
};
},
};
+235
View File
@@ -0,0 +1,235 @@
export function parseGraphQlSelectionPaths(text: string): {
fieldPaths: string[];
argumentPaths: string[];
argumentValues: Record<string, Record<string, string>>;
} {
if (!text) return { fieldPaths: [], argumentPaths: [], argumentValues: {} };
const cleaned = text.replace(/#[^\n]*/g, '');
const tokens: string[] =
cleaned.match(
/\.\.\.|"(?:[^"\\]|\\.)*"|[A-Za-z_][A-Za-z0-9_]*|\$[A-Za-z_][A-Za-z0-9_]*|-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?|[@{}()\[\],!:$]/g
) || [];
const startIndex = tokens.indexOf('{');
if (startIndex === -1) return { fieldPaths: [], argumentPaths: [], argumentValues: {} };
const result = parseSelectionSet(tokens, startIndex, []);
return {
fieldPaths: result.fieldPaths.map(parts => parts.join('.')),
argumentPaths: result.argumentPaths.map(parts => parts.join('.')),
argumentValues: result.argumentValues,
};
}
function parseArgumentValue(tokens: string[], startIndex: number): { value: string; endIndex: number } {
const valueTokens: string[] = [];
let index = startIndex;
let parenthesesDepth = 0;
let bracketDepth = 0;
let braceDepth = 0;
while (index < tokens.length) {
const token = tokens[index];
if (token === '(') {
parenthesesDepth += 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === '[') {
bracketDepth += 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === '{') {
braceDepth += 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === ')') {
if (parenthesesDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
break;
}
parenthesesDepth -= 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === ']') {
if (bracketDepth === 0) break;
bracketDepth -= 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === '}') {
if (braceDepth === 0) break;
braceDepth -= 1;
valueTokens.push(token);
index += 1;
continue;
}
if (token === ',' && parenthesesDepth === 0 && bracketDepth === 0 && braceDepth === 0) {
break;
}
valueTokens.push(token);
index += 1;
}
return {
value: valueTokens.join(''),
endIndex: index,
};
}
function parseArgumentsFromField(
tokens: string[],
startIndex: number
): { arguments: { name: string; value: string }[]; endIndex: number } {
const args: { name: string; value: string }[] = [];
let index = startIndex;
if (tokens[index] !== '(') {
return { arguments: args, endIndex: index };
}
let depth = 1;
index += 1;
while (index < tokens.length && depth > 0) {
if (tokens[index] === '(') depth += 1;
if (tokens[index] === ')') depth -= 1;
// Look for argument names (identifier followed by colon) and their values
if (depth > 0 && /^[A-Za-z_]/.test(tokens[index]) && tokens[index + 1] === ':') {
const argumentName = tokens[index];
const { value, endIndex } = parseArgumentValue(tokens, index + 2);
args.push({ name: argumentName, value });
index = endIndex;
if (tokens[index] === ',') {
index += 1;
}
} else {
index += 1;
}
}
return { arguments: args, endIndex: index };
}
function parseSelectionSet(
tokens: string[],
startIndex: number,
prefix: string[]
): {
fieldPaths: string[][];
argumentPaths: string[][];
argumentValues: Record<string, Record<string, string>>;
index: number;
} {
const fieldPaths: string[][] = [];
const argumentPaths: string[][] = [];
const argumentValues: Record<string, Record<string, string>> = {};
let index = startIndex + 1;
while (index < tokens.length) {
const token = tokens[index];
if (token === '}') {
return { fieldPaths, argumentPaths, argumentValues, index: index + 1 };
}
if (token === '...') {
index += 1;
if (tokens[index] === 'on') {
index += 2;
}
while (index < tokens.length && tokens[index] !== '{' && tokens[index] !== '}') {
index += 1;
}
if (tokens[index] === '{') {
const frag = parseSelectionSet(tokens, index, prefix);
fieldPaths.push(...frag.fieldPaths);
argumentPaths.push(...frag.argumentPaths);
for (const [fieldPath, values] of Object.entries(frag.argumentValues)) {
argumentValues[fieldPath] = {
...(argumentValues[fieldPath] || {}),
...values,
};
}
index = frag.index;
continue;
}
continue;
}
if (/^[A-Za-z_]/.test(token)) {
let fieldName = token;
if (tokens[index + 1] === ':' && /^[A-Za-z_]/.test(tokens[index + 2] || '')) {
fieldName = tokens[index + 2];
index += 3;
} else {
index += 1;
}
// Parse arguments if present
const { arguments: args, endIndex: argsEndIndex } = parseArgumentsFromField(tokens, index);
index = argsEndIndex;
// Add argument paths for this field
const currentFieldPath = [...prefix, fieldName].join('.');
for (const arg of args) {
argumentPaths.push([...prefix, fieldName, arg.name]);
if (!argumentValues[currentFieldPath]) {
argumentValues[currentFieldPath] = {};
}
argumentValues[currentFieldPath][arg.name] = arg.value;
}
while (tokens[index] === '@') {
index += 2;
if (tokens[index] === '(') {
let depth = 1;
index += 1;
while (index < tokens.length && depth > 0) {
if (tokens[index] === '(') depth += 1;
if (tokens[index] === ')') depth -= 1;
index += 1;
}
}
}
if (tokens[index] === '{') {
const nested = parseSelectionSet(tokens, index, [...prefix, fieldName]);
if (nested.fieldPaths.length > 0) {
fieldPaths.push(...nested.fieldPaths);
} else {
fieldPaths.push([...prefix, fieldName]);
}
argumentPaths.push(...nested.argumentPaths);
for (const [fieldPath, values] of Object.entries(nested.argumentValues)) {
argumentValues[fieldPath] = {
...(argumentValues[fieldPath] || {}),
...values,
};
}
index = nested.index;
} else {
fieldPaths.push([...prefix, fieldName]);
}
continue;
}
index += 1;
}
return { fieldPaths, argumentPaths, argumentValues, index };
}
+127
View File
@@ -0,0 +1,127 @@
export type GraphQlVariableDefinition = {
name: string;
type: string;
};
export function extractGraphQlVariableDefinitions(text: string): GraphQlVariableDefinition[] {
if (!text) return [];
const cleaned = text.replace(/#[^\n]*/g, '');
const regex = /\$([A-Za-z_][A-Za-z0-9_]*)\s*:\s*([^=,)\n]+)/g;
const names = new Set<string>();
const definitions: GraphQlVariableDefinition[] = [];
let match: RegExpExecArray | null = null;
while ((match = regex.exec(cleaned))) {
const name = match[1];
if (names.has(name)) continue;
names.add(name);
definitions.push({
name,
type: match[2].trim(),
});
}
return definitions;
}
function unwrapNonNull(typeText: string): string {
let current = (typeText || '').trim();
while (current.endsWith('!')) {
current = current.slice(0, -1).trim();
}
return current;
}
function isListType(typeText: string): boolean {
const unwrapped = unwrapNonNull(typeText);
return unwrapped.startsWith('[') && unwrapped.endsWith(']');
}
function getInnerListType(typeText: string): string {
const unwrapped = unwrapNonNull(typeText);
if (!(unwrapped.startsWith('[') && unwrapped.endsWith(']'))) return unwrapped;
return unwrapped.slice(1, -1).trim();
}
function getBaseType(typeText: string): string {
let current = unwrapNonNull(typeText);
while (current.startsWith('[') && current.endsWith(']')) {
current = current.slice(1, -1).trim();
current = unwrapNonNull(current);
}
return current;
}
function parseJsonIfPossible(raw: string): any {
const trimmed = (raw || '').trim();
if (!trimmed) return null;
try {
return JSON.parse(trimmed);
} catch {
return raw;
}
}
function toInt(raw: string): number | null {
const trimmed = (raw || '').trim();
if (!trimmed) return null;
const num = Number(trimmed);
if (!Number.isFinite(num)) return null;
return Math.trunc(num);
}
function toFloat(raw: string): number | null {
const trimmed = (raw || '').trim();
if (!trimmed) return null;
const num = Number(trimmed);
if (!Number.isFinite(num)) return null;
return num;
}
function toBoolean(raw: string): boolean | null {
const lowered = (raw || '').trim().toLowerCase();
if (!lowered) return null;
if (['true', '1', 'yes', 'y', 'on'].includes(lowered)) return true;
if (['false', '0', 'no', 'n', 'off'].includes(lowered)) return false;
return null;
}
function convertByGraphQlTypeValue(raw: any, graphQlType: string): any {
if (raw == null) return null;
if (isListType(graphQlType)) {
const innerType = getInnerListType(graphQlType);
const parsed = typeof raw === 'string' ? parseJsonIfPossible(raw) : raw;
const arrayValue = Array.isArray(parsed) ? parsed : [parsed];
return arrayValue.map(item => convertByGraphQlTypeValue(item, innerType));
}
const baseType = getBaseType(graphQlType);
const stringValue = typeof raw === 'string' ? raw : JSON.stringify(raw);
if (baseType === 'Int') return toInt(stringValue);
if (baseType === 'Float') return toFloat(stringValue);
if (baseType === 'Boolean') return toBoolean(stringValue);
if (baseType === 'String' || baseType === 'ID') return String(raw);
if (typeof raw === 'string') {
return parseJsonIfPossible(raw);
}
return raw;
}
export function convertGraphQlVariablesForRequest(
queryText: string,
rawVariables: Record<string, string> = {}
): Record<string, any> {
const definitions = extractGraphQlVariableDefinitions(queryText || '');
const next: Record<string, any> = {};
for (const definition of definitions) {
const raw = rawVariables?.[definition.name] ?? '';
next[definition.name] = convertByGraphQlTypeValue(raw, definition.type);
}
return next;
}
+175
View File
@@ -0,0 +1,175 @@
import type { GraphQLField, GraphQLInputValue, GraphQLIntrospectionResult, GraphQLType, GraphQLTypeRef } from './graphqlIntrospection';
export type GraphQLExplorerOperationType = 'query' | 'mutation' | 'subscription';
export interface GraphQLExplorerFieldNode {
name: string;
description?: string;
typeName: string;
typeDisplay: string;
isLeaf: boolean;
isArgument?: boolean;
arguments?: GraphQLExplorerFieldNode[];
children?: GraphQLExplorerFieldNode[];
}
export interface GraphQLExplorerOperation {
operationType: GraphQLExplorerOperationType;
rootTypeName: string;
fields: GraphQLExplorerFieldNode[];
}
interface GraphQLExplorerOptions {
maxDepth?: number;
}
const DEFAULT_MAX_DEPTH = 2;
function getTypeDisplay(typeRef: GraphQLTypeRef | null | undefined): string {
if (!typeRef) return 'Unknown';
if (typeRef.kind === 'NON_NULL') return `${getTypeDisplay(typeRef.ofType)}!`;
if (typeRef.kind === 'LIST') return `[${getTypeDisplay(typeRef.ofType)}]`;
return typeRef.name || 'Unknown';
}
function unwrapNamedType(typeRef: GraphQLTypeRef | null | undefined): GraphQLTypeRef | null {
if (!typeRef) return null;
if (typeRef.kind === 'NON_NULL' || typeRef.kind === 'LIST') return unwrapNamedType(typeRef.ofType);
return typeRef;
}
function buildTypeMap(types: GraphQLType[]): Map<string, GraphQLType> {
return new Map(types.map(type => [type.name, type]));
}
function isCompositeType(type: GraphQLType | undefined): boolean {
return type?.kind === 'OBJECT' || type?.kind === 'INTERFACE';
}
function buildFieldNode(
field: GraphQLField,
typeMap: Map<string, GraphQLType>,
depth: number,
maxDepth: number,
visitedTypes: Set<string>
): GraphQLExplorerFieldNode {
const namedType = unwrapNamedType(field.type);
const typeDef = namedType?.name ? typeMap.get(namedType.name) : undefined;
const composite = isCompositeType(typeDef);
const nextVisited = new Set(visitedTypes);
if (typeDef?.name) {
nextVisited.add(typeDef.name);
}
let children: GraphQLExplorerFieldNode[] | undefined;
if (composite && depth < maxDepth && typeDef?.fields && !visitedTypes.has(typeDef.name)) {
children = typeDef.fields.map(childField =>
buildFieldNode(childField, typeMap, depth + 1, maxDepth, nextVisited)
);
}
return {
name: field.name,
description: field.description,
typeName: namedType?.name || 'Unknown',
typeDisplay: getTypeDisplay(field.type),
isLeaf: !composite || !children || children.length === 0,
children,
};
}
function buildOperationFields(
rootTypeName: string,
types: GraphQLType[],
maxDepth: number
): GraphQLExplorerFieldNode[] {
const typeMap = buildTypeMap(types);
const rootType = typeMap.get(rootTypeName);
if (!rootType?.fields) return [];
return rootType.fields.map(field => buildFieldNode(field, typeMap, 1, maxDepth, new Set([rootTypeName])));
}
export function buildGraphQlExplorerOperations(
introspectionResult: GraphQLIntrospectionResult,
options: GraphQLExplorerOptions = {}
): GraphQLExplorerOperation[] {
const { __schema } = introspectionResult || {};
if (!__schema?.types) return [];
const maxDepth = options.maxDepth ?? DEFAULT_MAX_DEPTH;
const operations: GraphQLExplorerOperation[] = [];
if (__schema.queryType?.name) {
operations.push({
operationType: 'query',
rootTypeName: __schema.queryType.name,
fields: buildOperationFields(__schema.queryType.name, __schema.types, maxDepth),
});
}
if (__schema.mutationType?.name) {
operations.push({
operationType: 'mutation',
rootTypeName: __schema.mutationType.name,
fields: buildOperationFields(__schema.mutationType.name, __schema.types, maxDepth),
});
}
if (__schema.subscriptionType?.name) {
operations.push({
operationType: 'subscription',
rootTypeName: __schema.subscriptionType.name,
fields: buildOperationFields(__schema.subscriptionType.name, __schema.types, maxDepth),
});
}
return operations;
}
export function buildGraphQlQueryText(
operationType: GraphQLExplorerOperationType,
selectionPaths: string[],
options: { operationName?: string; indent?: string } = {}
): string {
const indent = options.indent ?? ' ';
const opName = options.operationName?.trim();
const tree = new Map<string, Map<string, any>>();
for (const path of selectionPaths) {
if (!path) continue;
const parts = path.split('.').filter(Boolean);
let node = tree;
for (const part of parts) {
if (!node.has(part)) {
node.set(part, new Map());
}
node = node.get(part) as Map<string, any>;
}
}
const renderTree = (node: Map<string, any>, level: number): string[] => {
const lines: string[] = [];
for (const [name, children] of node.entries()) {
if (children.size === 0) {
lines.push(`${indent.repeat(level)}${name}`);
} else {
lines.push(`${indent.repeat(level)}${name} {`);
lines.push(...renderTree(children, level + 1));
lines.push(`${indent.repeat(level)}}`);
}
}
return lines;
};
const header = opName ? `${operationType} ${opName}` : operationType;
const lines = [`${header} {`];
if (tree.size > 0) {
lines.push(...renderTree(tree, 1));
}
lines.push('}');
return lines.join('\n');
}
+495
View File
@@ -0,0 +1,495 @@
import type { RestApiDefinition } from './restApiDef';
import type { AxiosInstance } from 'axios';
const DEFAULT_INTROSPECTION_DEPTH = 6;
function buildTypeRefSelection(depth: number): string {
if (depth <= 0) {
return `
kind
name
`;
}
return `
kind
name
ofType {
${buildTypeRefSelection(depth - 1)}
}
`;
}
function buildIntrospectionQuery(maxDepth: number): string {
const typeRefSelection = buildTypeRefSelection(maxDepth);
return `
query IntrospectionQuery {
__schema {
types {
kind
name
description
fields {
name
description
type {
${typeRefSelection}
}
args {
name
description
type {
${typeRefSelection}
}
defaultValue
}
}
inputFields {
name
description
type {
${typeRefSelection}
}
}
}
queryType {
name
}
mutationType {
name
}
subscriptionType {
name
}
}
}
`;
}
export interface GraphQLTypeRef {
kind: string;
name?: string;
ofType?: GraphQLTypeRef | null;
}
export interface GraphQLInputValue {
name: string;
description?: string;
type: GraphQLTypeRef;
defaultValue?: string;
}
export interface GraphQLField {
name: string;
description?: string;
type: GraphQLTypeRef;
args?: GraphQLInputValue[];
}
export interface GraphQLType {
kind: string;
name: string;
description?: string;
fields?: GraphQLField[];
inputFields?: GraphQLField[];
possibleTypes?: GraphQLTypeRef[];
}
export interface GraphQLIntrospectionResult {
__schema: {
types: GraphQLType[];
queryType?: { name: string };
mutationType?: { name: string };
subscriptionType?: { name: string };
};
}
function getTypeString(type: GraphQLTypeRef | null | undefined): string {
if (!type) return 'Unknown';
if (type.kind === 'NON_NULL') return getTypeString(type.ofType) + '!';
if (type.kind === 'LIST') return '[' + getTypeString(type.ofType) + ']';
return type.name || 'Unknown';
}
function findType(types: GraphQLType[], name: string): GraphQLType | undefined {
return types.find(t => t.name === name);
}
function unwrapNamedTypeRef(typeRef: GraphQLTypeRef | null | undefined): GraphQLTypeRef | null {
if (!typeRef) return null;
if (typeRef.kind === 'NON_NULL' || typeRef.kind === 'LIST') return unwrapNamedTypeRef(typeRef.ofType);
return typeRef;
}
function unwrapListTypeRef(typeRef: GraphQLTypeRef | null | undefined): GraphQLTypeRef | null {
if (!typeRef) return null;
if (typeRef.kind === 'NON_NULL') return unwrapListTypeRef(typeRef.ofType);
if (typeRef.kind === 'LIST') return unwrapNamedTypeRef(typeRef.ofType);
return null;
}
function buildTypeMap(types: GraphQLType[]): Map<string, GraphQLType> {
return new Map((types || []).map(type => [type.name, type]));
}
function isScalarLikeField(field: GraphQLField, typeMap: Map<string, GraphQLType>): boolean {
const namedType = unwrapNamedTypeRef(field.type);
if (!namedType?.name) return false;
const type = typeMap.get(namedType.name);
if (!type) return namedType.kind === 'SCALAR' || namedType.kind === 'ENUM';
return type.kind === 'SCALAR' || type.kind === 'ENUM';
}
export function scoreFieldName(name: string): number {
const lowerName = (name || '').toLowerCase();
const exactOrder = [
'id',
'name',
'title',
'email',
'username',
'status',
'createdat',
'updatedat',
'type',
'code',
'key',
];
const exactIndex = exactOrder.indexOf(lowerName);
if (exactIndex >= 0) {
return 500 - exactIndex;
}
if (lowerName.endsWith('id')) return 300;
if (lowerName.includes('name')) return 280;
if (lowerName.includes('title')) return 260;
if (lowerName.includes('email')) return 240;
if (lowerName.includes('status')) return 220;
if (lowerName.includes('date') || lowerName.endsWith('at')) return 200;
return 100;
}
export function chooseUsefulNodeAttributes(nodeType: GraphQLType | undefined, typeMap: Map<string, GraphQLType>): string[] {
if (!nodeType?.fields?.length) return ['__typename'];
const scalarFields = nodeType.fields.filter(field => isScalarLikeField(field, typeMap));
if (scalarFields.length === 0) return ['__typename'];
return scalarFields
.map((field, index) => ({
field,
score: scoreFieldName(field.name),
index,
}))
.sort((left, right) => {
if (right.score !== left.score) return right.score - left.score;
return left.index - right.index;
})
.slice(0, 10)
.map(item => item.field.name);
}
function stringifyArgumentValue(argumentTypeRef: GraphQLTypeRef | null | undefined, value: number | string): string {
const namedType = unwrapNamedTypeRef(argumentTypeRef);
if (!namedType?.name) {
// Fallback: safely stringify as a JSON string literal
return JSON.stringify(String(value));
}
const typeName = namedType.name.toLowerCase();
if (typeName === 'int' || typeName === 'float') {
const numValue = typeof value === 'number' ? value : Number(value);
if (Number.isFinite(numValue)) {
return String(numValue);
}
// If the value cannot be parsed as a valid number, fall back to a quoted string
return JSON.stringify(String(value));
}
// For non-numeric types, safely serialize as a JSON string literal
return JSON.stringify(String(value));
}
export function buildFirstTenArgs(field: GraphQLField, filterParamName?: string | null, filterValue?: string): string {
const args = field.args || [];
if (args.length === 0) return '';
const argPairs: string[] = [];
// Add pagination argument
const candidates = ['first', 'limit', 'pagesize', 'perpage', 'take', 'size', 'count', 'maxresults'];
const paginationArg = args.find(item => candidates.includes((item.name || '').toLowerCase()));
if (paginationArg) {
argPairs.push(`${paginationArg.name}: ${stringifyArgumentValue(paginationArg.type, 10)}`);
}
// Add filter argument if provided
if (filterParamName && filterValue) {
const filterArg = args.find(item => item.name === filterParamName);
if (filterArg) {
argPairs.push(`${filterParamName}: ${stringifyArgumentValue(filterArg.type, filterValue)}`);
}
}
if (argPairs.length === 0) return '';
return `(${argPairs.join(', ')})`;
}
export type GraphQLConnectionProjection =
| {
kind: 'edges';
nodeTypeName: string;
hasPageInfo: boolean;
}
| {
kind: 'listField';
listFieldName: string;
nodeTypeName: string;
};
export function detectConnectionProjection(
field: GraphQLField,
typeMap: Map<string, GraphQLType>
): GraphQLConnectionProjection | null {
const fieldTypeRef = unwrapNamedTypeRef(field.type);
if (!fieldTypeRef?.name) return null;
const returnType = typeMap.get(fieldTypeRef.name);
if (!returnType || returnType.kind !== 'OBJECT' || !returnType.fields?.length) return null;
const edgesField = returnType.fields.find(item => item.name === 'edges');
if (edgesField) {
const edgeTypeRef = unwrapListTypeRef(edgesField.type);
if (edgeTypeRef?.name) {
const edgeType = typeMap.get(edgeTypeRef.name);
const nodeField = edgeType?.fields?.find(item => item.name === 'node');
const nodeTypeRef = unwrapNamedTypeRef(nodeField?.type);
if (nodeTypeRef?.name) {
const hasPageInfo = !!returnType.fields.find(item => item.name === 'pageInfo');
return {
kind: 'edges',
nodeTypeName: nodeTypeRef.name,
hasPageInfo,
};
}
}
}
const listFieldNames = ['nodes', 'items', 'results', 'data'];
for (const listFieldName of listFieldNames) {
const listField = returnType.fields.find(item => item.name === listFieldName);
if (!listField) continue;
const listItemTypeRef = unwrapListTypeRef(listField.type);
if (!listItemTypeRef?.name) continue;
return {
kind: 'listField',
listFieldName,
nodeTypeName: listItemTypeRef.name,
};
}
return null;
}
function buildConnectionQuery(field: GraphQLField, typeMap: Map<string, GraphQLType>): string | null {
const projection = detectConnectionProjection(field, typeMap);
if (!projection) return null;
const nodeType = typeMap.get(projection.nodeTypeName);
const selectedAttributes = chooseUsefulNodeAttributes(nodeType, typeMap);
const argsString = buildFirstTenArgs(field);
const attributeBlock = selectedAttributes.map(attr => ` ${attr}`).join('\n');
if (projection.kind === 'edges') {
const pageInfoBlock = projection.hasPageInfo
? `
pageInfo {
hasNextPage
endCursor
}`
: '';
return `query {
${field.name}${argsString} {
edges {
node {
${attributeBlock}
}
}${pageInfoBlock}
}
}`;
}
return `query {
${field.name}${argsString} {
${projection.listFieldName} {
${attributeBlock}
}
}
}`;
}
function buildConnectionEndpoints(
types: GraphQLType[],
rootTypeName?: string
): Array<{
name: string;
description?: string;
fields?: string;
connectionQuery?: string;
}> {
if (!rootTypeName) return [];
const rootType = findType(types, rootTypeName);
if (!rootType?.fields?.length) return [];
const typeMap = buildTypeMap(types);
const connectionEndpoints = [];
for (const field of rootType.fields) {
const connectionQuery = buildConnectionQuery(field, typeMap);
if (!connectionQuery) continue;
connectionEndpoints.push({
name: field.name,
description: field.description || '',
fields: field.description,
connectionQuery,
});
}
return connectionEndpoints;
}
function buildOperationEndpoints(
types: GraphQLType[],
operationType: 'OBJECT',
rootTypeName?: string
): Array<{ name: string; description?: string; fields?: string }> {
if (!rootTypeName) return [];
const rootType = findType(types, rootTypeName);
if (!rootType || !rootType.fields) return [];
return rootType.fields.map(field => ({
name: field.name,
description: field.description || '',
fields: field.description,
}));
}
export function extractRestApiDefinitionFromGraphQlIntrospectionResult(
introspectionResult: GraphQLIntrospectionResult
): RestApiDefinition {
const { __schema } = introspectionResult;
const categories: any[] = [];
// Connections (query fields returning connection-like payloads)
if (__schema.queryType?.name) {
const connectionEndpoints = buildConnectionEndpoints(__schema.types, __schema.queryType.name);
if (connectionEndpoints.length > 0) {
categories.push({
name: 'Connections',
endpoints: connectionEndpoints.map(connection => ({
method: 'POST',
path: connection.name,
summary: connection.description,
description: connection.fields,
parameters: [],
connectionQuery: connection.connectionQuery,
})),
});
}
}
// Queries
if (__schema.queryType?.name) {
const queryEndpoints = buildOperationEndpoints(__schema.types, 'OBJECT', __schema.queryType.name);
if (queryEndpoints.length > 0) {
categories.push({
name: 'Queries',
endpoints: queryEndpoints.map(q => ({
method: 'POST',
path: q.name,
summary: q.description,
description: q.fields,
parameters: [],
})),
});
}
}
// Mutations
if (__schema.mutationType?.name) {
const mutationEndpoints = buildOperationEndpoints(__schema.types, 'OBJECT', __schema.mutationType.name);
if (mutationEndpoints.length > 0) {
categories.push({
name: 'Mutations',
endpoints: mutationEndpoints.map(m => ({
method: 'POST',
path: m.name,
summary: m.description,
description: m.fields,
parameters: [],
})),
});
}
}
// Subscriptions
if (__schema.subscriptionType?.name) {
const subscriptionEndpoints = buildOperationEndpoints(__schema.types, 'OBJECT', __schema.subscriptionType.name);
if (subscriptionEndpoints.length > 0) {
categories.push({
name: 'Subscriptions',
endpoints: subscriptionEndpoints.map(s => ({
method: 'POST',
path: s.name,
summary: s.description,
description: s.fields,
parameters: [],
})),
});
}
}
return {
categories,
servers: [],
};
}
export async function fetchGraphQLSchema(
url: string,
headers: Record<string, string>,
axios: AxiosInstance,
maxDepth: number = DEFAULT_INTROSPECTION_DEPTH
): Promise<GraphQLIntrospectionResult> {
try {
const query = buildIntrospectionQuery(maxDepth);
const response = await axios.post(
url,
{ query },
{
timeout: 10000,
headers: {
'Content-Type': 'application/json',
...headers,
},
}
);
if (response.data.errors) {
throw new Error(`GraphQL introspection error: ${JSON.stringify(response.data.errors)}`);
}
if (!response.data.data) {
throw new Error('Invalid introspection response: no data field');
}
return response.data.data as GraphQLIntrospectionResult;
} catch (err: any) {
throw new Error(`DBGM-00312 Could not fetch GraphQL schema: ${err.message}`);
}
}
+13
View File
@@ -0,0 +1,13 @@
export * from './openApiDriver';
export * from './oDataDriver';
export * from './graphQlDriver';
export * from './openApiAdapter';
export * from './oDataAdapter';
export * from './oDataMetadataParser';
export * from './restApiExecutor';
export * from './arrayify';
export * from './graphqlIntrospection';
export * from './graphqlExplorer';
export * from './graphQlQueryParser';
export * from './graphQlVariables';
export * from './restAuthTools';
+70
View File
@@ -0,0 +1,70 @@
const { analyseODataDefinition } = require('./oDataAdapter');
function findEndpoint(apiInfo, path, method = 'GET') {
return apiInfo.categories
.flatMap(category => category.endpoints)
.find(endpoint => endpoint.path === path && endpoint.method === method);
}
test('deduces mandatory company parameter for customers and items from ContainsTarget metadata', () => {
const serviceDocument = {
'@odata.context': 'https://example/odata/$metadata',
value: [
{ name: 'companies', kind: 'EntitySet', url: 'companies' },
{ name: 'customers', kind: 'EntitySet', url: 'customers' },
{ name: 'items', kind: 'EntitySet', url: 'items' },
],
};
const metadataXml = `<?xml version="1.0" encoding="utf-8"?>
<edmx:Edmx Version="4.0" xmlns:edmx="http://docs.oasis-open.org/odata/ns/edmx">
<edmx:DataServices>
<Schema Namespace="Microsoft.NAV" Alias="NAV" xmlns="http://docs.oasis-open.org/odata/ns/edm">
<EntityType Name="company">
<Key><PropertyRef Name="id"/></Key>
<Property Name="id" Type="Edm.Guid"/>
<Property Name="displayName" Type="Edm.String"/>
<NavigationProperty Name="customers" Type="Collection(NAV.customer)" ContainsTarget="true" />
<NavigationProperty Name="items" Type="Collection(NAV.item)" ContainsTarget="true" />
</EntityType>
<EntityType Name="customer">
<Property Name="id" Type="Edm.Guid"/>
</EntityType>
<EntityType Name="item">
<Property Name="id" Type="Edm.Guid"/>
</EntityType>
<EntityContainer Name="default">
<EntitySet Name="companies" EntityType="NAV.company">
<NavigationPropertyBinding Path="customers" Target="customers"/>
<NavigationPropertyBinding Path="items" Target="items"/>
</EntitySet>
<EntitySet Name="customers" EntityType="NAV.customer"/>
<EntitySet Name="items" EntityType="NAV.item"/>
</EntityContainer>
</Schema>
</edmx:DataServices>
</edmx:Edmx>`;
const apiInfo = analyseODataDefinition(serviceDocument, 'https://example/odata', metadataXml);
const customersGet = findEndpoint(apiInfo, '/customers', 'GET');
const itemsGet = findEndpoint(apiInfo, '/items', 'GET');
expect(customersGet).toBeDefined();
expect(itemsGet).toBeDefined();
const customersCompany = customersGet.parameters.find(param => param.name === 'company');
const itemsCompany = itemsGet.parameters.find(param => param.name === 'company');
expect(customersCompany).toBeDefined();
expect(customersCompany.required).toBe(true);
expect(customersCompany.in).toBe('query');
expect(customersCompany.odataLookupEntitySet).toBe('companies');
expect(customersCompany.odataLookupPath).toBe('/companies');
expect(itemsCompany).toBeDefined();
expect(itemsCompany.required).toBe(true);
expect(itemsCompany.in).toBe('query');
expect(itemsCompany.odataLookupEntitySet).toBe('companies');
expect(itemsCompany.odataLookupPath).toBe('/companies');
});
+458
View File
@@ -0,0 +1,458 @@
import { RestApiDefinition, RestApiEndpoint, RestApiParameter, RestApiServer } from './restApiDef';
import { parseODataMetadataDocument } from './oDataMetadataParser';
export type ODataServiceResource = {
name?: string;
kind?: string;
url?: string;
};
export type ODataServiceDocument = {
'@odata.context'?: string;
value?: ODataServiceResource[];
};
export interface ODataMetadataNavigationProperty {
name: string;
type?: string;
containsTarget: boolean;
nullable: boolean;
}
export interface ODataMetadataEntityType {
typeName: string;
fullTypeName: string;
keyProperties: string[];
stringProperties: string[];
navigationProperties: ODataMetadataNavigationProperty[];
}
export interface ODataMetadataEntitySet {
name: string;
entityType: string;
navigationBindings: Record<string, string>;
}
export interface ODataMetadataDocument {
entityTypes: Record<string, ODataMetadataEntityType>;
entitySets: Record<string, ODataMetadataEntitySet>;
}
function normalizeServiceRoot(contextUrl: string | undefined, fallbackUrl: string): string {
const safeFallback = String(fallbackUrl ?? '').trim();
if (typeof contextUrl === 'string' && contextUrl.trim()) {
try {
const resolved = new URL(contextUrl.trim(), safeFallback || undefined);
resolved.hash = '';
resolved.search = '';
resolved.pathname = resolved.pathname.replace(/\/$metadata$/i, '');
const url = resolved.toString();
return url.endsWith('/') ? url : `${url}/`;
} catch {
// ignore, fallback below
}
}
return safeFallback.endsWith('/') ? safeFallback : `${safeFallback}/`;
}
function normalizeEndpointPath(valueUrl: string | undefined): string | null {
const input = String(valueUrl ?? '').trim();
if (!input) return null;
try {
const parsed = new URL(input, 'http://odata.local');
const pathWithQuery = `${parsed.pathname}${parsed.search}`;
return pathWithQuery.startsWith('/') ? pathWithQuery : `/${pathWithQuery}`;
} catch {
return input.startsWith('/') ? input : `/${input}`;
}
}
function inferMethods(kind: string | undefined): RestApiEndpoint['method'][] {
const normalizedKind = String(kind ?? '').toLowerCase();
if (normalizedKind === 'actionimport') return ['POST'];
if (normalizedKind === 'entityset') return ['GET', 'POST'];
return ['GET'];
}
function toLowerCamelCase(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
return text.charAt(0).toLowerCase() + text.slice(1);
}
function normalizeSingularName(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
if (/ies$/i.test(text)) return `${text.slice(0, -3)}y`;
if (/sses$/i.test(text)) return text;
if (/s$/i.test(text) && text.length > 1) return text.slice(0, -1);
return text;
}
function normalizePluralName(value: string | undefined): string {
const text = String(value ?? '').trim();
if (!text) return '';
if (/y$/i.test(text)) return `${text.slice(0, -1)}ies`;
if (/s$/i.test(text)) return text;
return `${text}s`;
}
function normalizeEntityTypeName(typeName: string | undefined): string {
const text = String(typeName ?? '').trim();
if (!text) return '';
const collectionMatch = text.match(/^Collection\((.+)\)$/i);
const unwrapped = collectionMatch ? collectionMatch[1] : text;
const slashStripped = unwrapped.includes('/') ? unwrapped.split('/').pop() || unwrapped : unwrapped;
return slashStripped.trim();
}
function buildTypeReferenceKeys(typeReference: string | undefined): string[] {
const normalizedReference = normalizeEntityTypeName(typeReference);
if (!normalizedReference) return [];
const keys = new Set<string>();
const lower = normalizedReference.toLowerCase();
keys.add(lower);
const withoutNamespace = normalizedReference.includes('.')
? normalizedReference.split('.').pop() || normalizedReference
: normalizedReference;
keys.add(withoutNamespace.toLowerCase());
return Array.from(keys);
}
function buildEntityTypeLookup(entityTypes: Record<string, ODataMetadataEntityType>): Map<string, ODataMetadataEntityType> {
const lookup = new Map<string, ODataMetadataEntityType>();
for (const [entityTypeKey, entityType] of Object.entries(entityTypes || {})) {
const keys = new Set<string>([
...buildTypeReferenceKeys(entityTypeKey),
...buildTypeReferenceKeys(entityType.fullTypeName),
...buildTypeReferenceKeys(entityType.typeName),
]);
for (const key of keys) {
if (!lookup.has(key)) {
lookup.set(key, entityType);
}
}
}
return lookup;
}
function resolveEntityType(
entityTypeLookup: Map<string, ODataMetadataEntityType>,
typeReference: string | undefined
): ODataMetadataEntityType | null {
const keys = buildTypeReferenceKeys(typeReference);
for (const key of keys) {
const found = entityTypeLookup.get(key);
if (found) return found;
}
return null;
}
function resolveLookupPath(entitySetName: string, serviceResourceMap: Map<string, ODataServiceResource>): string {
const serviceResource = serviceResourceMap.get(entitySetName);
const resourceUrl = String(serviceResource?.url ?? '').trim();
if (!resourceUrl) return `/${entitySetName}`;
return resourceUrl.startsWith('/') ? resourceUrl : `/${resourceUrl}`;
}
function buildServiceResourceNameLookup(resources: ODataServiceResource[]): Map<string, string> {
const lookup = new Map<string, string>();
for (const resource of resources || []) {
const resourceName = String(resource?.name ?? '').trim();
if (!resourceName) continue;
const lower = resourceName.toLowerCase();
if (!lookup.has(lower)) {
lookup.set(lower, resourceName);
}
}
return lookup;
}
function resolveServiceResourceNameForEntityType(
entityType: ODataMetadataEntityType,
serviceResourceNameLookup: Map<string, string>
): string | null {
const baseNames = [
String(entityType?.typeName ?? '').trim(),
normalizeSingularName(entityType?.typeName),
normalizeEntityTypeName(entityType?.fullTypeName),
normalizeSingularName(normalizeEntityTypeName(entityType?.fullTypeName)),
].filter(Boolean);
const candidates = new Set<string>();
for (const baseName of baseNames) {
candidates.add(baseName);
candidates.add(normalizeSingularName(baseName));
candidates.add(normalizePluralName(baseName));
}
for (const candidate of candidates) {
const matched = serviceResourceNameLookup.get(String(candidate).toLowerCase());
if (matched) return matched;
}
return null;
}
type MandatoryNavigationTargetParameter = {
name: string;
lookupEntitySet: string;
lookupPath: string;
lookupValueField?: string;
lookupLabelField?: string;
};
type MandatoryNavigationByTarget = Record<string, MandatoryNavigationTargetParameter[]>;
type ParentNavigationContext = {
parentEntitySetName: string;
parentType: ODataMetadataEntityType;
navigationBindings: Record<string, string>;
};
function deduceMandatoryNavigationByTarget(
metadataDocument: ODataMetadataDocument | null,
resources: ODataServiceResource[]
): MandatoryNavigationByTarget {
if (!metadataDocument) return {};
const entityTypeLookup = buildEntityTypeLookup(metadataDocument.entityTypes || {});
const serviceResourceMap = new Map<string, ODataServiceResource>();
for (const resource of resources) {
const resourceName = String(resource?.name ?? '').trim();
if (resourceName) {
serviceResourceMap.set(resourceName, resource);
}
}
const serviceResourceNameLookup = buildServiceResourceNameLookup(resources);
const entitySetsByEntityType = new Map<string, string[]>();
for (const [entitySetName, entitySet] of Object.entries(metadataDocument.entitySets || {})) {
const typeKeys = buildTypeReferenceKeys(entitySet?.entityType);
if (typeKeys.length === 0) continue;
for (const typeKey of typeKeys) {
const list = entitySetsByEntityType.get(typeKey) || [];
if (!list.includes(entitySetName)) {
list.push(entitySetName);
entitySetsByEntityType.set(typeKey, list);
}
}
}
const mandatoryByTarget: MandatoryNavigationByTarget = {};
const parentContexts: ParentNavigationContext[] = [];
const parentTypeKeysCovered = new Set<string>();
for (const [parentEntitySetName, parentEntitySet] of Object.entries(metadataDocument.entitySets || {})) {
const parentType = resolveEntityType(entityTypeLookup, parentEntitySet.entityType);
if (!parentType) continue;
parentContexts.push({
parentEntitySetName,
parentType,
navigationBindings: parentEntitySet.navigationBindings || {},
});
for (const typeKey of buildTypeReferenceKeys(parentEntitySet.entityType)) {
parentTypeKeysCovered.add(typeKey);
}
}
for (const entityType of Object.values(metadataDocument.entityTypes || {})) {
const typeKeys = [
...buildTypeReferenceKeys(entityType.fullTypeName),
...buildTypeReferenceKeys(entityType.typeName),
];
const alreadyCovered = typeKeys.some(typeKey => parentTypeKeysCovered.has(typeKey));
if (alreadyCovered) continue;
if (!Array.isArray(entityType.navigationProperties) || entityType.navigationProperties.length === 0) {
continue;
}
const parentEntitySetName = resolveServiceResourceNameForEntityType(entityType, serviceResourceNameLookup);
if (!parentEntitySetName) continue;
parentContexts.push({
parentEntitySetName,
parentType: entityType,
navigationBindings: {},
});
for (const typeKey of typeKeys) {
parentTypeKeysCovered.add(typeKey);
}
}
for (const { parentEntitySetName, parentType, navigationBindings } of parentContexts) {
const parentParamName =
toLowerCamelCase(parentType.typeName) ||
toLowerCamelCase(normalizeSingularName(parentEntitySetName)) ||
toLowerCamelCase(parentEntitySetName);
if (!parentParamName) continue;
for (const navProperty of parentType.navigationProperties || []) {
if (!navProperty.containsTarget) continue;
const targetNames = new Set<string>();
const directBoundTarget = navigationBindings?.[navProperty.name];
if (directBoundTarget) {
targetNames.add(directBoundTarget);
}
const navTypeKeys = buildTypeReferenceKeys(navProperty.type);
if (navTypeKeys.length > 0) {
const typeTargets = navTypeKeys.flatMap(typeKey => entitySetsByEntityType.get(typeKey) || []);
for (const targetName of typeTargets) {
targetNames.add(targetName);
}
}
for (const targetEntitySetName of targetNames) {
const targetList = mandatoryByTarget[targetEntitySetName] || [];
const exists = targetList.some(item => item.name.toLowerCase() === parentParamName.toLowerCase());
if (exists) continue;
targetList.push({
name: parentParamName,
lookupEntitySet: parentEntitySetName,
lookupPath: resolveLookupPath(parentEntitySetName, serviceResourceMap),
lookupValueField: parentType.keyProperties?.[0],
lookupLabelField: parentType.stringProperties?.find(prop => /name/i.test(prop)) || parentType.stringProperties?.[0],
});
mandatoryByTarget[targetEntitySetName] = targetList;
}
}
}
return mandatoryByTarget;
}
function buildMandatoryNavigationParameters(
resource: ODataServiceResource,
mandatoryByTarget: MandatoryNavigationByTarget
): RestApiParameter[] {
const resourceName = String(resource?.name ?? '').trim();
if (!resourceName) return [];
const mandatoryTargets = mandatoryByTarget[resourceName] || [];
const mandatoryParameters: RestApiParameter[] = [];
const seenNames = new Set<string>();
for (const mandatoryTarget of mandatoryTargets) {
const normalizedName = mandatoryTarget.name.toLowerCase();
if (seenNames.has(normalizedName)) continue;
const description = mandatoryTarget.lookupEntitySet
? `Required navigation parameter deduced from OData metadata (lookup: ${mandatoryTarget.lookupEntitySet})`
: 'Required navigation parameter deduced from OData metadata';
mandatoryParameters.push({
name: mandatoryTarget.name,
in: 'query',
dataType: 'string',
required: true,
description,
odataLookupPath: mandatoryTarget.lookupPath,
odataLookupEntitySet: mandatoryTarget.lookupEntitySet,
odataLookupValueField: mandatoryTarget.lookupValueField,
odataLookupLabelField: mandatoryTarget.lookupLabelField,
});
seenNames.add(normalizedName);
}
return mandatoryParameters;
}
function createODataResourceEndpoints(
resource: ODataServiceResource,
mandatoryByTarget: MandatoryNavigationByTarget
): RestApiEndpoint[] {
const path = normalizeEndpointPath(resource.url);
if (!path) return [];
const summary = resource.name || resource.url || path;
const descriptionKind = String(resource.kind ?? '').trim();
const methods = inferMethods(resource.kind);
const mandatoryNavigationParameters = buildMandatoryNavigationParameters(resource, mandatoryByTarget);
return methods.map(method => {
const parameters: RestApiParameter[] = [...mandatoryNavigationParameters];
if (method === 'POST') {
parameters.push({
name: 'body',
in: 'body',
dataType: 'object',
contentType: 'application/json',
});
}
return {
method,
path,
summary,
description: descriptionKind ? `OData ${descriptionKind}` : 'OData resource',
parameters,
};
});
}
export function analyseODataDefinition(
doc: ODataServiceDocument,
endpointUrl: string,
metadataDocumentXml?: string | null
): RestApiDefinition {
const resources = Array.isArray(doc?.value) ? doc.value : [];
const categoriesByName = new Map<string, RestApiEndpoint[]>();
const metadataDocument = metadataDocumentXml ? parseODataMetadataDocument(metadataDocumentXml) : null;
const mandatoryByTarget = deduceMandatoryNavigationByTarget(metadataDocument, resources);
for (const resource of resources) {
const endpoints = createODataResourceEndpoints(resource, mandatoryByTarget);
if (endpoints.length === 0) continue;
const categoryName = String(resource.kind ?? 'Resources').trim() || 'Resources';
const existingEndpoints = categoriesByName.get(categoryName) || [];
existingEndpoints.push(...endpoints);
categoriesByName.set(categoryName, existingEndpoints);
}
const metadataEndpoint: RestApiEndpoint = {
method: 'GET',
path: '/$metadata',
summary: '$metadata',
description: 'OData service metadata',
parameters: [],
};
const metadataCategory = categoriesByName.get('Metadata') || [];
metadataCategory.push(metadataEndpoint);
categoriesByName.set('Metadata', metadataCategory);
const serviceRoot = normalizeServiceRoot(doc?.['@odata.context'], endpointUrl);
const servers: RestApiServer[] = serviceRoot ? [{ url: serviceRoot }] : [];
return {
categories: Array.from(categoriesByName.entries()).map(([name, endpoints]) => ({
name,
endpoints,
})),
servers,
};
}
+93
View File
@@ -0,0 +1,93 @@
import type { EngineDriver } from 'dbgate-types';
import { buildRestAuthHeaders } from './restAuthTools';
import { apiDriverBase } from './restDriverBase';
function resolveServiceRoot(contextUrl: string | undefined, fallbackUrl: string): string {
const safeFallback = String(fallbackUrl ?? '').trim();
if (typeof contextUrl === 'string' && contextUrl.trim()) {
try {
const resolved = new URL(contextUrl.trim(), safeFallback || undefined);
resolved.hash = '';
resolved.search = '';
resolved.pathname = resolved.pathname.replace(/\/$metadata$/i, '');
const url = resolved.toString();
return url.endsWith('/') ? url : `${url}/`;
} catch {
// ignore, fallback below
}
}
return safeFallback.endsWith('/') ? safeFallback : `${safeFallback}/`;
}
async function loadODataServiceDocument(dbhan: any) {
if (!dbhan?.connection?.apiServerUrl1) {
throw new Error('DBGM-00000 OData endpoint URL is not configured');
}
const response = await dbhan.axios.get(dbhan.connection.apiServerUrl1, {
headers: buildRestAuthHeaders(dbhan.connection.restAuth),
});
const document = response?.data;
if (!document || typeof document !== 'object') {
throw new Error('DBGM-00000 OData service document is empty or invalid');
}
if (!document['@odata.context']) {
throw new Error('DBGM-00000 OData service document does not contain @odata.context');
}
return document;
}
function getODataVersion(document: any): string {
const contextUrl = String(document?.['@odata.context'] ?? '').trim();
const versionMatch = contextUrl.match(/\/v(\d+(?:\.\d+)*)\/$metadata$/i);
if (versionMatch?.[1]) return versionMatch[1];
return '';
}
// @ts-ignore
export const oDataDriver: EngineDriver = {
...apiDriverBase,
engine: 'odata@rest',
title: 'OData - REST',
databaseEngineTypes: ['rest', 'odata'],
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" fill="#f9a000"/><rect x="12" y="12" width="47" height="12" fill="#ffffff"/><rect x="69" y="12" width="47" height="12" fill="#ffffff"/><rect x="12" y="37" width="47" height="12" fill="#ffffff"/><rect x="69" y="37" width="47" height="12" fill="#ffffff"/><rect x="12" y="62" width="47" height="12" fill="#ffffff"/><rect x="69" y="62" width="47" height="12" fill="#ffffff"/><rect x="69" y="87" width="47" height="12" fill="#ffffff"/><circle cx="35" cy="102" r="20" fill="#e6e6e6"/></svg>',
apiServerUrl1Label: 'OData Service URL',
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
return false;
},
beforeConnectionSave: connection => ({
...connection,
singleDatabase: true,
defaultDatabase: '_api_database_',
}),
async connect(connection: any) {
return {
connection,
client: null,
database: '_api_database_',
axios: connection.axios,
};
},
async getVersion(dbhan: any) {
const document = await loadODataServiceDocument(dbhan);
const resourcesCount = Array.isArray(document?.value) ? document.value.length : 0;
const odataVersion = getODataVersion(document);
return {
version: odataVersion || 'OData',
versionText: `OData${odataVersion ? ` ${odataVersion}` : ''}, ${resourcesCount} resources`,
};
},
};
+161
View File
@@ -0,0 +1,161 @@
import type { ODataMetadataDocument, ODataMetadataEntitySet, ODataMetadataEntityType, ODataMetadataNavigationProperty } from './oDataAdapter';
function decodeXmlEntities(value: string): string {
return String(value ?? '')
.replace(/&quot;/g, '"')
.replace(/&apos;/g, "'")
.replace(/&lt;/g, '<')
.replace(/&gt;/g, '>')
.replace(/&amp;/g, '&');
}
function parseXmlAttributes(attributesText: string): Record<string, string> {
const attributes: Record<string, string> = {};
const regex = /([A-Za-z_][A-Za-z0-9_.:-]*)\s*=\s*("([^"]*)"|'([^']*)')/g;
let match = regex.exec(attributesText || '');
while (match) {
const rawName = match[1];
const localName = rawName.includes(':') ? rawName.split(':').pop() || rawName : rawName;
const rawValue = match[3] ?? match[4] ?? '';
const decoded = decodeXmlEntities(rawValue);
attributes[rawName] = decoded;
attributes[localName] = decoded;
match = regex.exec(attributesText || '');
}
return attributes;
}
function extractXmlElements(xml: string, elementName: string): Array<{ attributes: Record<string, string>; innerXml: string }> {
const elements: Array<{ attributes: Record<string, string>; innerXml: string }> = [];
const fullTagRegex = new RegExp(
`<(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}\\b([^>]*)>([\\s\\S]*?)<\\/(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}>`,
'gi'
);
const selfClosingRegex = new RegExp(
`<(?:[A-Za-z_][A-Za-z0-9_.-]*:)?${elementName}\\b([^>]*)\\/>`,
'gi'
);
let fullMatch = fullTagRegex.exec(xml || '');
while (fullMatch) {
elements.push({
attributes: parseXmlAttributes(fullMatch[1] || ''),
innerXml: fullMatch[2] || '',
});
fullMatch = fullTagRegex.exec(xml || '');
}
let selfClosingMatch = selfClosingRegex.exec(xml || '');
while (selfClosingMatch) {
elements.push({
attributes: parseXmlAttributes(selfClosingMatch[1] || ''),
innerXml: '',
});
selfClosingMatch = selfClosingRegex.exec(xml || '');
}
return elements;
}
function toBoolAttribute(value: string | undefined): boolean {
return String(value ?? '').trim().toLowerCase() === 'true';
}
function normalizeEntitySetName(value: string | undefined): string {
const input = String(value ?? '').trim();
if (!input) return '';
const noContainer = input.includes('/') ? input.split('/').pop() || '' : input;
return noContainer.includes('.') ? noContainer.split('.').pop() || noContainer : noContainer;
}
export function parseODataMetadataDocument(metadataXml: string): ODataMetadataDocument {
const schemas = extractXmlElements(metadataXml || '', 'Schema');
const entityTypes: Record<string, ODataMetadataEntityType> = {};
const entitySets: Record<string, ODataMetadataEntitySet> = {};
for (const schema of schemas) {
const namespace = String(schema.attributes.Namespace || '').trim();
for (const entityTypeNode of extractXmlElements(schema.innerXml, 'EntityType')) {
const typeName = String(entityTypeNode.attributes.Name || '').trim();
if (!typeName) continue;
const fullTypeName = namespace ? `${namespace}.${typeName}` : typeName;
const keyProperties: string[] = [];
const stringProperties: string[] = [];
const navigationProperties: ODataMetadataNavigationProperty[] = [];
for (const keyNode of extractXmlElements(entityTypeNode.innerXml, 'Key')) {
for (const propRef of extractXmlElements(keyNode.innerXml, 'PropertyRef')) {
const keyName = String(propRef.attributes.Name || '').trim();
if (keyName && !keyProperties.includes(keyName)) {
keyProperties.push(keyName);
}
}
}
for (const propertyNode of extractXmlElements(entityTypeNode.innerXml, 'Property')) {
const propName = String(propertyNode.attributes.Name || '').trim();
const propType = String(propertyNode.attributes.Type || '').trim();
if (propName && /^Edm\.String$/i.test(propType)) {
stringProperties.push(propName);
}
}
for (const navNode of extractXmlElements(entityTypeNode.innerXml, 'NavigationProperty')) {
const navName = String(navNode.attributes.Name || '').trim();
if (!navName) continue;
navigationProperties.push({
name: navName,
type: String(navNode.attributes.Type || '').trim(),
containsTarget: toBoolAttribute(navNode.attributes.ContainsTarget),
nullable: navNode.attributes.Nullable === undefined ? true : toBoolAttribute(navNode.attributes.Nullable),
});
}
entityTypes[fullTypeName] = {
typeName,
fullTypeName,
keyProperties,
stringProperties,
navigationProperties,
};
}
for (const entitySetNode of extractXmlElements(schema.innerXml, 'EntitySet')) {
const setName = String(entitySetNode.attributes.Name || '').trim();
const entityType = String(entitySetNode.attributes.EntityType || '').trim();
if (!setName || !entityType) continue;
const navigationBindings: Record<string, string> = {};
for (const bindingNode of extractXmlElements(entitySetNode.innerXml, 'NavigationPropertyBinding')) {
const path = String(bindingNode.attributes.Path || '').trim();
const target = normalizeEntitySetName(bindingNode.attributes.Target);
if (!path || !target) continue;
navigationBindings[path] = target;
const pathLastSegment = path.split('/').pop();
if (pathLastSegment && !navigationBindings[pathLastSegment]) {
navigationBindings[pathLastSegment] = target;
}
}
entitySets[setName] = {
name: setName,
entityType,
navigationBindings,
};
}
}
return {
entityTypes,
entitySets,
};
}
+285
View File
@@ -0,0 +1,285 @@
import type { OpenAPIV3_1 } from 'openapi-types';
import { RestApiDefinition, RestApiCategory, RestApiEndpoint, RestApiParameter, RestApiServer } from './restApiDef';
/**
* Converts an OpenAPI v3.1 document into a simplified REST API definition
* Organizes endpoints by tags into categories
*/
export function analyseOpenApiDefinition(doc: OpenAPIV3_1.Document): RestApiDefinition {
const categories = new Map<string, RestApiEndpoint[]>();
// Process all paths and methods
if (doc.paths) {
for (const [path, pathItem] of Object.entries(doc.paths)) {
if (!pathItem) continue;
// Process each HTTP method in the path
const methods = ['get', 'post', 'put', 'patch', 'delete', 'options', 'head', 'trace'] as const;
for (const method of methods) {
const operation = (pathItem as any)[method] as OpenAPIV3_1.OperationObject | undefined;
if (!operation) continue;
const endpoint: RestApiEndpoint = {
method: method.toUpperCase() as any,
path,
summary: operation.summary,
description: operation.description,
parameters: extractParameters(operation, pathItem as any),
};
// Use tags to organize into categories
const tags = operation.tags || ['Other'];
for (const tag of tags) {
if (!categories.has(tag)) {
categories.set(tag, []);
}
categories.get(tag)!.push(endpoint);
}
}
}
}
// Convert Map to RestApiCategory array
const categoryArray: RestApiCategory[] = Array.from(categories.entries()).map(([name, endpoints]) => ({
name,
endpoints,
}));
const servers: RestApiServer[] = (doc.servers || []).map(server => ({
url: server.url,
description: server.description,
}));
return {
categories: categoryArray,
servers,
};
}
/**
* Extract parameters from operation and path item
*/
function extractParameters(
operation: OpenAPIV3_1.OperationObject,
pathItem: OpenAPIV3_1.PathItemObject
): RestApiParameter[] {
const parameters: RestApiParameter[] = [];
// Path item level parameters (apply to all methods)
if (pathItem.parameters) {
for (const param of pathItem.parameters) {
if (!('$ref' in param)) {
parameters.push(convertParameter(param as OpenAPIV3_1.ParameterObject));
}
}
}
// Operation level parameters
if (operation.parameters) {
for (const param of operation.parameters) {
if (!('$ref' in param)) {
parameters.push(convertParameter(param as OpenAPIV3_1.ParameterObject));
}
}
}
const bodyParameter = convertRequestBodyParameter(operation.requestBody);
if (bodyParameter) {
parameters.push(bodyParameter);
}
return parameters;
}
function isSchemaObject(schema: OpenAPIV3_1.SchemaObject | OpenAPIV3_1.ReferenceObject | undefined): schema is OpenAPIV3_1.SchemaObject {
return !!schema && !('$ref' in schema);
}
function isExampleObject(example: OpenAPIV3_1.ExampleObject | OpenAPIV3_1.ReferenceObject | undefined): example is OpenAPIV3_1.ExampleObject {
return !!example && !('$ref' in example);
}
function cloneValue(value: any) {
if (value == null) return value;
if (typeof value !== 'object') return value;
try {
return JSON.parse(JSON.stringify(value));
} catch {
return value;
}
}
function extractMediaTypeExample(mediaType: OpenAPIV3_1.MediaTypeObject | undefined): any {
if (!mediaType) return undefined;
if (mediaType.example !== undefined) return cloneValue(mediaType.example);
if (mediaType.examples) {
const firstExample = Object.values(mediaType.examples)[0];
if (isExampleObject(firstExample) && firstExample.value !== undefined) {
return cloneValue(firstExample.value);
}
}
return undefined;
}
function buildSchemaExample(
schema: OpenAPIV3_1.SchemaObject | undefined,
recursionDepth = 0
): any {
if (!schema || recursionDepth > 6) return undefined;
if (schema.example !== undefined) return cloneValue(schema.example);
if (schema.default !== undefined) return cloneValue(schema.default);
if (schema.oneOf?.length) {
const oneOfSchema = schema.oneOf[0];
return isSchemaObject(oneOfSchema) ? buildSchemaExample(oneOfSchema, recursionDepth + 1) : undefined;
}
if (schema.anyOf?.length) {
const anyOfSchema = schema.anyOf[0];
return isSchemaObject(anyOfSchema) ? buildSchemaExample(anyOfSchema, recursionDepth + 1) : undefined;
}
if (schema.allOf?.length) {
const mergedObject = {};
let hasValue = false;
for (const item of schema.allOf) {
if (!isSchemaObject(item)) continue;
const itemExample = buildSchemaExample(item, recursionDepth + 1);
if (itemExample && typeof itemExample === 'object' && !Array.isArray(itemExample)) {
Object.assign(mergedObject, itemExample);
hasValue = true;
}
}
return hasValue ? mergedObject : undefined;
}
if (schema.enum?.length) return cloneValue(schema.enum[0]);
if (schema.type === 'object' || schema.properties || schema.additionalProperties) {
const result: Record<string, any> = {};
let hasAnyProperty = false;
for (const [propertyName, propertySchema] of Object.entries(schema.properties || {})) {
if (!isSchemaObject(propertySchema)) continue;
const propertyValue = buildSchemaExample(propertySchema, recursionDepth + 1);
if (propertyValue !== undefined) {
result[propertyName] = propertyValue;
hasAnyProperty = true;
}
}
if (schema.additionalProperties) {
if (schema.additionalProperties === true) {
result.additionalProp1 = 'string';
hasAnyProperty = true;
} else if (isSchemaObject(schema.additionalProperties)) {
result.additionalProp1 = buildSchemaExample(schema.additionalProperties, recursionDepth + 1) ?? 'string';
hasAnyProperty = true;
}
}
return hasAnyProperty ? result : {};
}
if (schema.type === 'array') {
if (isSchemaObject(schema.items)) {
const itemValue = buildSchemaExample(schema.items, recursionDepth + 1);
return itemValue !== undefined ? [itemValue] : [];
}
return [];
}
if (schema.type === 'number' || schema.type === 'integer') return 0;
if (schema.type === 'boolean') return true;
if (schema.type === 'null') return null;
return 'string';
}
function getSchemaType(schema: OpenAPIV3_1.SchemaObject | undefined): string | undefined {
if (!schema) return undefined;
if (schema.type === 'array') {
if (isSchemaObject(schema.items)) {
return `array<${schema.items.type || 'any'}>`;
}
return 'array';
}
if (Array.isArray(schema.type)) return schema.type.join(' | ');
if (schema.type) return schema.type;
if (schema.properties) return 'object';
return undefined;
}
function isStringListSchema(schema: OpenAPIV3_1.SchemaObject | undefined): boolean {
return schema?.type === 'array' && isSchemaObject(schema.items) && schema.items.type === 'string';
}
function convertRequestBodyParameter(
requestBody: OpenAPIV3_1.RequestBodyObject | OpenAPIV3_1.ReferenceObject | undefined
): RestApiParameter | null {
if (!requestBody || '$ref' in requestBody || !requestBody.content) return null;
const preferredContentTypes = [
'application/json',
'application/x-www-form-urlencoded',
'multipart/form-data',
'text/plain',
];
const availableContentTypes = Object.keys(requestBody.content);
if (availableContentTypes.length === 0) return null;
const selectedContentType =
preferredContentTypes.find(contentType => requestBody.content?.[contentType]) || availableContentTypes[0];
const mediaType = requestBody.content[selectedContentType];
if (!mediaType || !isSchemaObject(mediaType.schema)) {
return {
name: 'body',
in: 'body',
contentType: selectedContentType,
description: requestBody.description,
required: requestBody.required,
};
}
const schema = mediaType.schema;
const mediaTypeExample = extractMediaTypeExample(mediaType);
const generatedExample = buildSchemaExample(schema);
return {
name: 'body',
in: 'body',
dataType: getSchemaType(schema),
contentType: selectedContentType,
isStringList: isStringListSchema(schema),
description: requestBody.description,
required: requestBody.required,
defaultValue: mediaTypeExample ?? generatedExample,
};
}
/**
* Convert OpenAPI parameter to REST API parameter
*/
function convertParameter(param: OpenAPIV3_1.ParameterObject): RestApiParameter {
const schema = isSchemaObject(param.schema) ? param.schema : undefined;
return {
name: param.name,
in: param.in as any,
dataType: getSchemaType(schema),
isStringList: isStringListSchema(schema),
description: param.description,
required: param.required,
defaultValue: schema?.default,
};
}
+94
View File
@@ -0,0 +1,94 @@
import type { EngineDriver } from 'dbgate-types';
import yaml from 'js-yaml';
import { apiDriverBase } from './restDriverBase';
async function loadOpenApiDefinition(dbhan: any) {
if (!dbhan?.connection?.apiServerUrl1) {
throw new Error('DBGM-00313 REST connection URL is not configured');
}
const response = await dbhan.axios.get(dbhan.connection.apiServerUrl1);
const content = response?.data;
let openApiDefinition: any = content;
if (typeof content === 'string') {
try {
openApiDefinition = JSON.parse(content);
} catch {
openApiDefinition = yaml.load(content);
}
}
if (!openApiDefinition || typeof openApiDefinition !== 'object') {
throw new Error('DBGM-00314 API documentation is empty or could not be parsed');
}
return openApiDefinition;
}
// @ts-ignore
export const openApiDriver: EngineDriver = {
...apiDriverBase,
engine: 'openapi@rest',
title: 'OpenAPI - REST',
databaseEngineTypes: ['rest', 'openapi'],
icon: '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#85ea2d" d="M63.999 124.945c-33.607 0-60.95-27.34-60.95-60.949C3.05 30.388 30.392 3.048 64 3.048s60.95 27.342 60.95 60.95c0 33.607-27.343 60.946-60.95 60.946z"/><path fill="#173647" d="M40.3 43.311c-.198 2.19.072 4.454-.073 6.668-.173 2.217-.444 4.407-.888 6.596-.615 3.126-2.56 5.489-5.24 7.458 5.218 3.396 5.807 8.662 6.152 14.003.172 2.88.098 5.785.394 8.638.221 2.215 1.082 2.782 3.372 2.854.935.025 1.894 0 2.978 0v6.842c-6.768 1.156-12.354-.762-13.734-6.496a39.329 39.329 0 0 1-.836-6.4c-.148-2.287.097-4.577-.074-6.864-.492-6.277-1.305-8.393-7.308-8.689v-7.8c.441-.1.86-.174 1.302-.223 3.298-.172 4.701-1.182 5.414-4.43a37.512 37.512 0 0 0 .616-5.536c.247-3.569.148-7.21.763-10.754.86-5.094 4.01-7.556 9.254-7.852 1.476-.074 2.978 0 4.676 0v6.99c-.714.05-1.33.147-1.969.147-4.258-.148-4.48 1.304-4.8 4.848zm8.195 16.193h-.099c-2.462-.123-4.578 1.796-4.702 4.258-.122 2.485 1.797 4.603 4.259 4.724h.295c2.436.148 4.527-1.724 4.676-4.16v-.245c.05-2.486-1.944-4.527-4.43-4.577zm15.43 0c-2.386-.074-4.38 1.796-4.454 4.159 0 .149 0 .271.024.418 0 2.684 1.821 4.406 4.578 4.406 2.707 0 4.406-1.772 4.406-4.553-.025-2.682-1.823-4.455-4.554-4.43Zm15.801 0a4.596 4.596 0 0 0-4.676 4.454 4.515 4.515 0 0 0 4.528 4.528h.05c2.264.394 4.553-1.796 4.701-4.429.122-2.437-2.092-4.553-4.604-4.553Zm21.682.369c-2.855-.123-4.284-1.083-4.996-3.79a27.444 27.444 0 0 1-.811-5.292c-.198-3.298-.174-6.62-.395-9.918-.516-7.826-6.177-10.557-14.397-9.205v6.792c1.304 0 2.313 0 3.322.025 1.748.024 3.077.69 3.249 2.634.172 1.772.172 3.568.344 5.365.346 3.57.542 7.187 1.157 10.706.542 2.904 2.536 5.07 5.02 6.841-4.355 2.929-5.636 7.113-5.857 11.814-.122 3.223-.196 6.472-.368 9.721-.148 2.953-1.181 3.913-4.16 3.987-.835.024-1.648.098-2.583.148v6.964c1.748 0 3.347.1 4.946 0 4.971-.295 7.974-2.706 8.96-7.531.417-2.658.662-5.34.737-8.023.171-2.46.148-4.946.394-7.382.369-3.815 2.116-5.389 5.93-5.636a5.161 5.161 0 0 0 1.06-.245v-7.801c-.64-.074-1.084-.148-1.552-.173zM64 6.1c31.977 0 57.9 25.92 57.9 57.898 0 31.977-25.923 57.899-57.9 57.899-31.976 0-57.898-25.922-57.898-57.9C6.102 32.023 32.024 6.101 64 6.101m0-6.1C28.71 0 0 28.71 0 64c0 35.288 28.71 63.998 64 63.998 35.289 0 64-28.71 64-64S99.289.002 64 .002Z"/></svg>',
apiServerUrl1Label: 'API Definition URL',
apiServerUrl2Label: 'API Server URL',
apiServerUrl2Placeholder: '(optional - if not set, the first server URL from the API definition will be used)',
loadApiServerUrl2Options: true,
showConnectionField: (field, values) => {
if (apiDriverBase.showAuthConnectionField(field, values)) return true;
if (field === 'apiServerUrl1') return true;
if (field === 'apiServerUrl2') return true;
return false;
},
beforeConnectionSave: connection => ({
...connection,
singleDatabase: true,
defaultDatabase: '_api_database_',
}),
async connect(connection: any) {
return {
connection,
client: null,
database: '_api_database_',
axios: connection.axios
};
},
async listDatabases(dbhan: any) {
const openApiDefinition = await loadOpenApiDefinition(dbhan);
const servers = Array.isArray(openApiDefinition.servers) ? openApiDefinition.servers : [];
return servers
.map(server => String(server?.url ?? '').trim())
.filter(Boolean)
.map(url => ({
name: url,
}));
},
async getVersion(dbhan: any) {
const openApiDefinition = await loadOpenApiDefinition(dbhan);
const specVersion = String(openApiDefinition.openapi ?? openApiDefinition.swagger ?? '').trim();
const apiVersion = String(openApiDefinition.info?.version ?? '').trim();
const version = apiVersion || specVersion || 'Unknown';
const versionText = [
apiVersion ? `API ${apiVersion}` : null,
specVersion ? `OpenAPI ${specVersion}` : null,
]
.filter(Boolean)
.join(', ');
return {
version,
...(versionText ? { versionText } : {}),
};
},
};
+65
View File
@@ -0,0 +1,65 @@
export interface RestApiParameter {
name: string;
in: 'query' | 'header' | 'path' | 'cookie' | 'body';
dataType?: string;
contentType?: string;
isStringList?: boolean;
description?: string;
required?: boolean;
defaultValue?: any;
options?: Array<{ label: string; value: string }>;
odataLookupPath?: string;
odataLookupEntitySet?: string;
odataLookupValueField?: string;
odataLookupLabelField?: string;
}
export interface RestApiEndpoint {
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE' | 'OPTIONS' | 'HEAD';
path: string;
summary?: string;
description?: string;
parameters: RestApiParameter[];
}
export interface RestApiCategory {
name: string;
endpoints: RestApiEndpoint[];
}
export interface RestApiServer {
url: string;
description?: string;
}
export interface RestApiDefinition {
categories: RestApiCategory[];
servers?: RestApiServer[];
}
export interface RestApiAuthorization_None {
type: 'none';
}
export interface RestApiAuthorization_Basic {
type: 'basic';
user: string;
password: string;
}
export interface RestApiAuthorization_Bearer {
type: 'bearer';
token: string;
}
export interface RestApiAuthorization_ApiKey {
type: 'apikey';
header: string;
value: string;
}
export type RestApiAuthorization =
| RestApiAuthorization_None
| RestApiAuthorization_Basic
| RestApiAuthorization_Bearer
| RestApiAuthorization_ApiKey;
+134
View File
@@ -0,0 +1,134 @@
const { executeODataApiEndpoint } = require('./restApiExecutor');
function createDefinition() {
return {
categories: [
{
name: 'EntitySet',
endpoints: [
{
method: 'GET',
path: '/customers',
parameters: [
{
name: 'company',
in: 'query',
dataType: 'string',
required: true,
},
],
},
{
method: 'GET',
path: '/$metadata',
parameters: [],
},
],
},
],
};
}
test('adds OData system query options from parameterValues', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/customers',
'GET',
{
company: '123',
'$top': 50,
'$skip': '10',
'$count': true,
'$select': ['id', 'displayName'],
'$orderby': 'displayName asc',
'$filter': 'displayName ne null',
'$search': 'dino',
'$expand': 'addresses',
'$format': 'application/json',
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const requestUrl = String(calls[0].url);
const parsed = new URL(requestUrl);
expect(parsed.pathname).toBe('/odata/customers');
expect(parsed.searchParams.get('company')).toBe('123');
expect(parsed.searchParams.get('$top')).toBe('50');
expect(parsed.searchParams.get('$skip')).toBe('10');
expect(parsed.searchParams.get('$count')).toBe('true');
expect(parsed.searchParams.get('$select')).toBe('id,displayName');
expect(parsed.searchParams.get('$orderby')).toBe('displayName asc');
expect(parsed.searchParams.get('$filter')).toBe('displayName ne null');
expect(parsed.searchParams.get('$search')).toBe('dino');
expect(parsed.searchParams.get('$expand')).toBe('addresses');
expect(parsed.searchParams.get('$format')).toBe('application/json');
});
test('accepts non-dollar aliases and ignores invalid system option values', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/customers',
'GET',
{
company: '123',
top: 'abc',
skip: -1,
count: 'yes',
select: ['id'],
filter: 'id ne null',
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const parsed = new URL(String(calls[0].url));
expect(parsed.searchParams.get('$top')).toBeNull();
expect(parsed.searchParams.get('$skip')).toBeNull();
expect(parsed.searchParams.get('$count')).toBeNull();
expect(parsed.searchParams.get('$select')).toBe('id');
expect(parsed.searchParams.get('$filter')).toBe('id ne null');
});
test('does not add OData system query options to $metadata endpoint', async () => {
const calls = [];
const axios = async args => {
calls.push(args);
return { status: 200, data: {} };
};
await executeODataApiEndpoint(
createDefinition(),
'/$metadata',
'GET',
{
'$top': 10,
'$count': true,
},
'https://example.test/odata',
null,
axios
);
expect(calls).toHaveLength(1);
const parsed = new URL(String(calls[0].url));
expect(parsed.pathname).toBe('/odata/$metadata');
expect(parsed.search).toBe('');
});
+329
View File
@@ -0,0 +1,329 @@
import type { AxiosInstance } from 'axios';
import { RestApiAuthorization, RestApiDefinition, RestApiParameter } from './restApiDef';
function hasValue(value: any) {
if (value === null || value === undefined) return false;
if (typeof value === 'string') return value.trim() !== '';
if (Array.isArray(value)) return value.length > 0;
return true;
}
function normalizeValueForRequest(value: any, parameter: RestApiParameter): any {
if (!hasValue(value)) return undefined;
if (parameter.isStringList) {
if (Array.isArray(value)) return value.filter(item => item != null && String(item).trim() !== '');
return [String(value)];
}
if (parameter.in === 'body' && typeof value === 'string') {
const trimmed = value.trim();
if (!trimmed) return undefined;
if ((parameter.contentType || '').includes('json') || parameter.dataType === 'object') {
try {
return JSON.parse(trimmed);
} catch {
return value;
}
}
}
return value;
}
function splitPathAndQuery(path: string) {
const value = String(path || '');
const index = value.indexOf('?');
if (index < 0) {
return {
pathOnly: value,
queryString: '',
};
}
return {
pathOnly: value.slice(0, index),
queryString: value.slice(index + 1),
};
}
function addAuthHeaders(headers: Record<string, string>, auth: RestApiAuthorization | null) {
if (!auth) return;
if (auth.type === 'basic') {
const basicAuth = Buffer.from(`${auth.user}:${auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else if (auth.type === 'bearer') {
headers['Authorization'] = `Bearer ${auth.token}`;
} else if (auth.type === 'apikey') {
headers[auth.header] = auth.value;
}
}
function findEndpointDefinition(
definition: RestApiDefinition,
endpoint: string,
method: string
) {
return definition.categories
.flatMap(category => category.endpoints)
.find(ep => ep.path === endpoint && ep.method === method);
}
function buildRequestUrl(server: string, pathOnly: string) {
const normalizedServer = String(server || '').trim();
const normalizedPath = String(pathOnly || '').trim();
if (!normalizedServer) {
return normalizedPath;
}
try {
const baseUrl = normalizedServer.endsWith('/') ? normalizedServer : `${normalizedServer}/`;
const relativePath = normalizedPath.replace(/^\//, '');
return new URL(relativePath, baseUrl).toString();
} catch {
return normalizedServer + normalizedPath;
}
}
function appendQueryAndCookies(
url: string,
query: URLSearchParams,
cookies: string[],
headers: Record<string, string>
) {
const queryStringValue = query.toString();
if (queryStringValue) {
const separator = url.includes('?') ? '&' : '?';
url += separator + queryStringValue;
}
if (cookies.length > 0) {
headers['Cookie'] = cookies.join('; ');
}
return url;
}
const ODATA_SYSTEM_QUERY_OPTIONS = new Set([
'$filter',
'$select',
'$expand',
'$orderby',
'$top',
'$skip',
'$count',
'$search',
'$format',
]);
const ODATA_SYSTEM_QUERY_ALIASES: Record<string, string> = {
filter: '$filter',
select: '$select',
expand: '$expand',
orderby: '$orderby',
top: '$top',
skip: '$skip',
count: '$count',
search: '$search',
format: '$format',
};
function resolveODataQueryOptionKey(rawKey: string): string | null {
const key = String(rawKey || '').trim();
if (!key) return null;
const keyLower = key.toLowerCase();
if (ODATA_SYSTEM_QUERY_OPTIONS.has(keyLower)) {
return keyLower;
}
return ODATA_SYSTEM_QUERY_ALIASES[keyLower] || null;
}
function normalizeODataQueryOptionValue(optionKey: string, value: any): string | null {
if (!hasValue(value)) return null;
if (Array.isArray(value)) {
const items = value.filter(item => hasValue(item)).map(item => String(item).trim()).filter(Boolean);
if (items.length === 0) return null;
return items.join(',');
}
if (optionKey === '$count') {
if (typeof value === 'boolean') return value ? 'true' : 'false';
const lowered = String(value).trim().toLowerCase();
if (lowered === 'true' || lowered === 'false') return lowered;
return null;
}
if (optionKey === '$top' || optionKey === '$skip') {
const numeric = Number(value);
if (Number.isFinite(numeric) && numeric >= 0) {
return String(Math.trunc(numeric));
}
return null;
}
return String(value).trim();
}
function applyODataSystemQueryOptions(query: URLSearchParams, parameterValues: Record<string, any>) {
for (const [rawKey, rawValue] of Object.entries(parameterValues || {})) {
const optionKey = resolveODataQueryOptionKey(rawKey);
if (!optionKey) continue;
const normalizedValue = normalizeODataQueryOptionValue(optionKey, rawValue);
if (!hasValue(normalizedValue)) continue;
query.set(optionKey, String(normalizedValue));
}
}
export async function executeRestApiEndpointOpenApi(
definition: RestApiDefinition,
endpoint: string,
method: string,
parameterValues: Record<string, any>,
server: string,
auth: RestApiAuthorization | null,
axios: AxiosInstance
): Promise<any> {
const endpointDef = findEndpointDefinition(definition, endpoint, method);
if (!endpointDef) {
throw new Error(`Endpoint ${method} ${endpoint} not found in definition.`);
}
const { pathOnly, queryString } = splitPathAndQuery(endpointDef.path);
let url = buildRequestUrl(server, pathOnly);
const headers: Record<string, string> = {};
const query = new URLSearchParams(queryString);
const cookies: string[] = [];
let body: any = undefined;
for (const param of endpointDef.parameters) {
const value = normalizeValueForRequest(parameterValues[param.name], param);
if (!hasValue(value) && param.in !== 'path') {
continue;
}
if (param.in === 'path') {
url = url.replace(`{${param.name}}`, encodeURIComponent(value));
} else if (param.in === 'query') {
if (Array.isArray(value)) {
for (const item of value) {
query.append(param.name, String(item));
}
} else {
query.append(param.name, String(value));
}
} else if (param.in === 'header') {
headers[param.name] = Array.isArray(value) ? value.map(item => String(item)).join(',') : String(value);
} else if (param.in === 'cookie') {
if (Array.isArray(value)) {
for (const item of value) {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(item))}`);
}
} else {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
}
} else if (param.in === 'body') {
body = value;
if (param.contentType && !headers['Content-Type']) {
headers['Content-Type'] = param.contentType;
}
}
}
url = appendQueryAndCookies(url, query, cookies, headers);
addAuthHeaders(headers, auth);
const resp = await axios({
method,
url,
headers,
data: body,
});
return resp;
}
export async function executeODataApiEndpoint(
definition: RestApiDefinition,
endpoint: string,
method: string,
parameterValues: Record<string, any>,
server: string,
auth: RestApiAuthorization | null,
axios: AxiosInstance
): Promise<any> {
const endpointDef = findEndpointDefinition(definition, endpoint, method);
if (!endpointDef) {
throw new Error(`Endpoint ${method} ${endpoint} not found in definition.`);
}
const { pathOnly, queryString } = splitPathAndQuery(endpointDef.path);
const metadataPath = pathOnly.replace(/\/+$/, '') === '/$metadata';
let url = buildRequestUrl(server, pathOnly);
const headers: Record<string, string> = {
Accept: 'application/json',
'OData-Version': '4.0',
};
const query = metadataPath ? new URLSearchParams() : new URLSearchParams(queryString);
const cookies: string[] = [];
let body: any = undefined;
for (const param of endpointDef.parameters) {
const value = normalizeValueForRequest(parameterValues[param.name], param);
if (!hasValue(value) && param.in !== 'path') {
continue;
}
if (param.in === 'path') {
url = url.replace(`{${param.name}}`, encodeURIComponent(value));
} else if (param.in === 'query') {
if (metadataPath) continue;
if (Array.isArray(value)) {
for (const item of value) {
query.append(param.name, String(item));
}
} else {
query.append(param.name, String(value));
}
} else if (param.in === 'header') {
headers[param.name] = Array.isArray(value) ? value.map(item => String(item)).join(',') : String(value);
} else if (param.in === 'cookie') {
if (Array.isArray(value)) {
for (const item of value) {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(item))}`);
}
} else {
cookies.push(`${encodeURIComponent(param.name)}=${encodeURIComponent(String(value))}`);
}
} else if (param.in === 'body') {
body = value;
if (param.contentType && !headers['Content-Type']) {
headers['Content-Type'] = param.contentType;
}
}
}
if (!metadataPath) {
applyODataSystemQueryOptions(query, parameterValues);
}
url = appendQueryAndCookies(url, query, cookies, headers);
addAuthHeaders(headers, auth);
const resp = await axios({
method,
url,
headers,
data: body,
});
return resp;
}
+15
View File
@@ -0,0 +1,15 @@
import { RestApiAuthorization } from './restApiDef';
export function buildRestAuthHeaders(auth: RestApiAuthorization | null) {
const headers = {};
if (!auth) return headers;
if (auth.type === 'basic') {
const basicAuth = Buffer.from(`${auth.user}:${auth.password}`).toString('base64');
headers['Authorization'] = `Basic ${basicAuth}`;
} else if (auth.type === 'bearer') {
headers['Authorization'] = `Bearer ${auth.token}`;
} else if (auth.type === 'apikey') {
headers[auth.header] = auth.value;
}
return headers;
}
+42
View File
@@ -0,0 +1,42 @@
import { driverBase } from 'dbgate-tools';
export const apiDriverBase = {
...driverBase,
supportExecuteQuery: false,
getAuthTypes() {
return [
{
title: 'No Authentication',
name: 'none',
},
{
title: 'Basic Authentication',
name: 'basic',
},
{
title: 'Bearer Token Authentication',
name: 'bearer',
},
{
title: 'API Key Authentication',
name: 'apikey',
},
];
},
showAuthConnectionField: (field, values) => {
if (field === 'authType') return true;
if (values?.authType === 'basic') {
if (field === 'user') return true;
if (field === 'password') return true;
}
if (values?.authType === 'bearer') {
if (field === 'authToken') return true;
}
if (values?.authType === 'apikey') {
if (field === 'apiKeyHeader') return true;
if (field === 'apiKeyValue') return true;
}
return false;
},
};
+14
View File
@@ -0,0 +1,14 @@
{
"compilerOptions": {
"target": "ES2018",
"module": "commonjs",
"declaration": true,
"skipLibCheck": true,
"outDir": "lib",
"preserveWatchOutput": true,
"esModuleInterop": true
},
"include": [
"src/**/*"
]
}
+2 -2
View File
@@ -41,7 +41,7 @@ STORAGE_DATABASE=dbname
STORAGE_ENGINE=mysql@dbgate-plugin-mysql
```
You could find more about environment variable configuration on [DbGate docs](https://dbgate.org/docs/env-variables/) page.
You could find more about environment variable configuration on [DbGate docs](https://docs.dbgate.io/env-variables/) page.
After installing, you can run dbgate with command:
```sh
@@ -65,7 +65,7 @@ dbgate-serve
Then open http://localhost:3000 in your browser
## Download desktop app
You can also download binary packages for desktop app from https://dbgate.org . Or run from source code, as described on [github](https://github.com/dbgate/dbgate)
You can also download binary packages for desktop app from https://www.dbgate.io . Or run from source code, as described on [github](https://github.com/dbgate/dbgate)
## Use Oracle with Instant client (thick mode)
If you are Oracle database user and you would like to use Oracle instant client (thick mode) instead of thin mode (pure JS NPM package), please make the following:
+15 -15
View File
@@ -1,7 +1,7 @@
{
"name": "dbgate-serve",
"version": "6.0.0-alpha.1",
"homepage": "https://dbgate.org/",
"version": "7.0.0-alpha.1",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
@@ -18,19 +18,19 @@
"web"
],
"dependencies": {
"dbgate-api": "^6.0.0-alpha.1",
"dbgate-plugin-clickhouse": "^6.0.0-alpha.1",
"dbgate-plugin-csv": "^6.0.0-alpha.1",
"dbgate-plugin-excel": "^6.0.0-alpha.1",
"dbgate-plugin-mongo": "^6.0.0-alpha.1",
"dbgate-plugin-mssql": "^6.0.0-alpha.1",
"dbgate-plugin-mysql": "^6.0.0-alpha.1",
"dbgate-plugin-oracle": "^6.0.0-alpha.1",
"dbgate-plugin-postgres": "^6.0.0-alpha.1",
"dbgate-plugin-redis": "^6.0.0-alpha.1",
"dbgate-plugin-sqlite": "^6.0.0-alpha.1",
"dbgate-plugin-xml": "^6.0.0-alpha.1",
"dbgate-web": "^6.0.0-alpha.1",
"dbgate-api": "^7.0.0-alpha.1",
"dbgate-plugin-clickhouse": "^7.0.0-alpha.1",
"dbgate-plugin-csv": "^7.0.0-alpha.1",
"dbgate-plugin-excel": "^7.0.0-alpha.1",
"dbgate-plugin-mongo": "^7.0.0-alpha.1",
"dbgate-plugin-mssql": "^7.0.0-alpha.1",
"dbgate-plugin-mysql": "^7.0.0-alpha.1",
"dbgate-plugin-oracle": "^7.0.0-alpha.1",
"dbgate-plugin-postgres": "^7.0.0-alpha.1",
"dbgate-plugin-redis": "^7.0.0-alpha.1",
"dbgate-plugin-sqlite": "^7.0.0-alpha.1",
"dbgate-plugin-xml": "^7.0.0-alpha.1",
"dbgate-web": "^7.0.0-alpha.1",
"dotenv": "^16.0.0"
}
}
+3 -3
View File
@@ -1,9 +1,9 @@
{
"version": "6.0.0-alpha.1",
"version": "7.0.0-alpha.1",
"name": "dbgate-sqltree",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"homepage": "https://dbgate.org/",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
@@ -27,7 +27,7 @@
],
"devDependencies": {
"@types/node": "^13.7.0",
"dbgate-types": "^6.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"typescript": "^4.4.3"
},
"dependencies": {
+75 -10
View File
@@ -1,8 +1,55 @@
import type { SqlDumper } from 'dbgate-types';
import { Condition, BinaryCondition } from './types';
import { Condition, BinaryCondition, LikeCondition } from './types';
import { dumpSqlExpression } from './dumpSqlExpression';
import { dumpSqlSelect } from './dumpSqlCommand';
function dumpLikeAsFunctionCondition(dmp: SqlDumper, condition: LikeCondition) {
// For DynamoDB: contains() works only on string attributes
// For numeric values, search both as number and as string
const likeExpr = condition.right;
let isNumericValue = false;
let numericStringValue = '';
if (likeExpr.exprType === 'value' && typeof likeExpr.value === 'string') {
const cleanedStr = (likeExpr.value || '').replace(/%/g, '').trim();
// Only match valid decimal numbers (not Infinity, NaN, etc.)
isNumericValue = /^-?\d+(\.\d+)?$/.test(cleanedStr);
numericStringValue = cleanedStr;
} else if (likeExpr.exprType === 'value' && typeof likeExpr.value === 'number') {
isNumericValue = Number.isFinite(likeExpr.value);
numericStringValue = String(likeExpr.value);
}
if (isNumericValue) {
// For numeric values: (column = value OR contains(column, 'value'))
dmp.putRaw('(');
dumpSqlExpression(dmp, condition.left);
dmp.putRaw(' = ');
dmp.put('%s', numericStringValue);
dmp.putRaw(' OR contains(');
dumpSqlExpression(dmp, condition.left);
dmp.putRaw(', ');
dmp.put('%v', numericStringValue);
dmp.putRaw('))');
} else {
// String value: contains(column, value)
dmp.putRaw('contains(');
dumpSqlExpression(dmp, condition.left);
dmp.putRaw(', ');
if (likeExpr.exprType === 'value') {
let cleanValue = likeExpr.value;
if (typeof cleanValue === 'string') {
cleanValue = cleanValue.replace(/%/g, '');
}
dmp.put('%v', cleanValue);
} else {
dumpSqlExpression(dmp, likeExpr);
}
dmp.putRaw(')');
}
}
export function dumpSqlCondition(dmp: SqlDumper, condition: Condition) {
switch (condition.conditionType) {
case 'binary':
@@ -19,14 +66,28 @@ export function dumpSqlCondition(dmp: SqlDumper, condition: Condition) {
dmp.put(' ^is ^not ^null');
break;
case 'isEmpty':
dmp.put('^trim(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(") = ''");
// Use DATALENGTH for MSSQL TEXT/NTEXT/IMAGE columns to avoid TRIM error
if (dmp.dialect.useDatalengthForEmptyString?.(condition.expr?.['dataType'])) {
dmp.put('^datalength(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(') = 0');
} else {
dmp.put('^trim(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(") = ''");
}
break;
case 'isNotEmpty':
dmp.put('^trim(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(") <> ''");
// Use DATALENGTH for MSSQL TEXT/NTEXT/IMAGE columns to avoid TRIM error
if (dmp.dialect.useDatalengthForEmptyString?.(condition.expr?.['dataType'])) {
dmp.put('^datalength(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(') > 0');
} else {
dmp.put('^trim(');
dumpSqlExpression(dmp, condition.expr);
dmp.put(") <> ''");
}
break;
case 'and':
case 'or':
@@ -37,9 +98,13 @@ export function dumpSqlCondition(dmp: SqlDumper, condition: Condition) {
});
break;
case 'like':
dumpSqlExpression(dmp, condition.left);
dmp.put(dmp.dialect.ilike ? ' ^ilike ' : ' ^like ');
dumpSqlExpression(dmp, condition.right);
if (dmp.dialect.likeAsFunction) {
dumpLikeAsFunctionCondition(dmp, condition);
} else {
dumpSqlExpression(dmp, condition.left);
dmp.put(dmp.dialect.ilike ? ' ^ilike ' : ' ^like ');
dumpSqlExpression(dmp, condition.right);
}
break;
case 'notLike':
dumpSqlExpression(dmp, condition.left);
+5 -5
View File
@@ -1,9 +1,9 @@
{
"version": "6.0.0-alpha.1",
"version": "7.0.0-alpha.1",
"name": "dbgate-tools",
"main": "lib/index.js",
"typings": "lib/index.d.ts",
"homepage": "https://dbgate.org/",
"homepage": "https://www.dbgate.io/",
"repository": {
"type": "git",
"url": "https://github.com/dbgate/dbgate.git"
@@ -26,15 +26,15 @@
],
"devDependencies": {
"@types/node": "^13.7.0",
"dbgate-types": "^6.0.0-alpha.1",
"dbgate-types": "^7.0.0-alpha.1",
"jest": "^28.1.3",
"ts-jest": "^28.0.7",
"typescript": "^4.4.3"
},
"dependencies": {
"blueimp-md5": "^2.19.0",
"dbgate-query-splitter": "^4.11.9",
"dbgate-sqltree": "^6.0.0-alpha.1",
"dbgate-query-splitter": "^4.12.0",
"dbgate-sqltree": "^7.0.0-alpha.1",
"debug": "^4.3.4",
"json-stable-stringify": "^1.0.1",
"lodash": "^4.17.21",
+6 -1
View File
@@ -544,9 +544,14 @@ export class SqlDumper implements AlterProcessor {
}
this.endCommand();
}
indexType(ix: IndexInfo) {
if (ix.isUnique) {
this.put(' ^unique');
}
}
createIndex(ix: IndexInfo) {
this.put('^create');
if (ix.isUnique) this.put(' ^unique');
this.indexType(ix);
this.put(' ^index %i &n^on %f (&>&n', ix.constraintName, ix);
this.putCollection(',&n', ix.columns, col => {
this.put('%i %k', col.columnName, col.isDescending == true ? 'DESC' : 'ASC');

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