Compare commits
252 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0a920195d5 | |||
| 66fc6b93ae | |||
| 881d5a8008 | |||
| 5d263de954 | |||
| c8d0494000 | |||
| a9b48b5aa5 | |||
| f08a951eef | |||
| 8758a4bc86 | |||
| aae328f8c8 | |||
| 1953578a33 | |||
| 543bdd79d9 | |||
| e0e1a3c8e4 | |||
| f1d84f448e | |||
| 7c5c21f15d | |||
| 41ffaeebe3 | |||
| 5d9b44b647 | |||
| a18d2c5650 | |||
| e0379bcf12 | |||
| e91242d5a2 | |||
| 8177187b3a | |||
| 6b3e1144bc | |||
| dfec88f52d | |||
| b8df67659a | |||
| 861da64581 | |||
| ab147a2cc9 | |||
| e13191e894 | |||
| 7f69ea8dc0 | |||
| ef2140696b | |||
| 4607900c3b | |||
| 3258d55796 | |||
| 35e6966c39 | |||
| 885756b259 | |||
| 5fbc1b937c | |||
| 7e444e9fc2 | |||
| c051237914 | |||
| 3855b0dd28 | |||
| afcc9e096a | |||
| f4df1fbff4 | |||
| 45b3a5af91 | |||
| f54b18e652 | |||
| b1210d19ad | |||
| 21cbcc79c6 | |||
| a7d0c8fb0f | |||
| 1e3dc54d81 | |||
| 48f294fd83 | |||
| 298ad0de4b | |||
| c7953f9231 | |||
| afd97eae7d | |||
| f4e558b7e8 | |||
| 12c99c646e | |||
| 6c1a2eedbe | |||
| 8a73216035 | |||
| c6a93f12f7 | |||
| 09f44d94b3 | |||
| c26748154a | |||
| 2474f915d4 | |||
| 53f940cd23 | |||
| 991b648854 | |||
| 663f057a9a | |||
| 61963fb824 | |||
| bdf3cf5b36 | |||
| 5cc459594b | |||
| 8d315e52df | |||
| 48a24a8704 | |||
| cdce52f0e5 | |||
| d12ccbeac4 | |||
| 0b1620105a | |||
| 2ae9c98acb | |||
| ed00848a1e | |||
| 06f7741dbf | |||
| 8d3b7cace8 | |||
| 8f0775e337 | |||
| 444cb6aa0c | |||
| b4acc19ea2 | |||
| 1ef17cd861 | |||
| e564e930e5 | |||
| a30badbbe0 | |||
| b33d21fdb3 | |||
| 78da83f7db | |||
| 8f6313d4ec | |||
| 14962a5622 | |||
| b8048e7592 | |||
| cf9823e123 | |||
| 1667dbfde0 | |||
| 416436a612 | |||
| dc1b724d8d | |||
| 080dc44175 | |||
| be148297a2 | |||
| 6cf6d8c876 | |||
| 3921f50feb | |||
| 6fc63be56a | |||
| 6a03f9a6fe | |||
| 721fdf09b3 | |||
| bd4a52318b | |||
| 3978865902 | |||
| c1228ee426 | |||
| d0c39dc932 | |||
| 63a8586d7c | |||
| e0a79c033e | |||
| fa12f127ce | |||
| 10916eadd5 | |||
| 7f4e8e9c8f | |||
| d06840f934 | |||
| 75f4df8b51 | |||
| e9ea6d27ae | |||
| 48019d43c3 | |||
| 04dbeb633d | |||
| 71631865c4 | |||
| d4a39cf481 | |||
| 3a71dfff64 | |||
| d8c865b3ce | |||
| 71356b798c | |||
| 66ddd1741f | |||
| 8a60f3c8a7 | |||
| 25d2a40c50 | |||
| 17b389146c | |||
| bd1f609b39 | |||
| 01e1831d57 | |||
| c341baa781 | |||
| 995a0c33c3 | |||
| 75845cb42d | |||
| 822d6acfb0 | |||
| 2c4510a717 | |||
| ac2391c91a | |||
| e805563ce5 | |||
| 88fb1d920e | |||
| dca60fad7a | |||
| 8226b05e7e | |||
| 0106331978 | |||
| f5c0e7d2e9 | |||
| 4568b24351 | |||
| 042502f41f | |||
| d342d73818 | |||
| 3b922216c1 | |||
| 10fa9b6812 | |||
| 33ccbf790b | |||
| 50b4baee4b | |||
| be57a56095 | |||
| f351453b9c | |||
| dda67d3351 | |||
| 23e1e744e8 | |||
| 4b0affe182 | |||
| ab836bc747 | |||
| cf86d7e352 | |||
| 05a36d3878 | |||
| 0417084a39 | |||
| cdfe39f226 | |||
| b73dde3a48 | |||
| 0dea597226 | |||
| 2f38928c89 | |||
| 35c7b5e952 | |||
| ba28b17263 | |||
| 51b0e004fa | |||
| 8cb59b02a8 | |||
| 38bfd130a3 | |||
| 369d90e057 | |||
| c27cdd1734 | |||
| e7e4f39311 | |||
| 0443a21e05 | |||
| 50c01886ec | |||
| a9e1219f6c | |||
| 7bc31dde70 | |||
| 65f2f1d08f | |||
| 684027eaab | |||
| 1c3ec9c3bb | |||
| 3a8ff2c05d | |||
| d5bd179c68 | |||
| 8b938a39cf | |||
| c9610cbc39 | |||
| 931733d605 | |||
| 44e5d0e195 | |||
| 08b83dc3fd | |||
| b7f261a836 | |||
| d0b4ca33c2 | |||
| 160391f5a9 | |||
| dfe4a96b02 | |||
| a3f67eb519 | |||
| 0f9d52552b | |||
| a217de4c39 | |||
| d2d85e63f6 | |||
| 7a6077b5ff | |||
| d48c4d9729 | |||
| 6d677401bf | |||
| a3d4fa2f86 | |||
| 59e19b6a22 | |||
| 1a76da40d1 | |||
| cb15ba01f0 | |||
| 78af7f136e | |||
| cc6a95b579 | |||
| 4b3f723bdc | |||
| d372e2ff76 | |||
| 4201d1cb1e | |||
| afed70ba63 | |||
| be488346c5 | |||
| eeeb688439 | |||
| b84ce77326 | |||
| 30fca423dc | |||
| fabbb31572 | |||
| ac76ac004e | |||
| 9d2051183a | |||
| 942fdb51d5 | |||
| d2600a3168 | |||
| c4248cce22 | |||
| 16f16f9fed | |||
| d49cb976bc | |||
| 6fae6a9865 | |||
| 06f3730756 | |||
| 30e1333f75 | |||
| 0d6fa98767 | |||
| ae6c9edd0d | |||
| 35de1f1c4e | |||
| 57142f4afb | |||
| cd72d65b89 | |||
| 2199ab0513 | |||
| e93f058109 | |||
| b68de49cbd | |||
| 3f05934b6b | |||
| 3a5713dbb7 | |||
| 4c43158285 | |||
| daa743b3b3 | |||
| 41f0ae18c4 | |||
| e6b8aefe5b | |||
| 8b2437cb16 | |||
| 292495ab0d | |||
| 017b137d7f | |||
| 7969030313 | |||
| c8efad4c3f | |||
| 7bf9d8f675 | |||
| e275f15f00 | |||
| 30017a5217 | |||
| 64c5cbe8c3 | |||
| b2b226573c | |||
| 69f796998f | |||
| 4d64be3ac7 | |||
| 4408b794d6 | |||
| 666da8a879 | |||
| b166342579 | |||
| 433f5bf7d2 | |||
| b8ae153ef5 | |||
| bb59c2bab7 | |||
| ab7c6c5118 | |||
| 85c1ea449e | |||
| b51d679b78 | |||
| 2a2bc9e625 | |||
| d00f059567 | |||
| 81a840347c | |||
| e691675bf9 | |||
| 9cd57c3ae1 | |||
| 0e3310a39b | |||
| 447818ac2a | |||
| dd0eb846b0 | |||
| 1b62ca4b21 |
@@ -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.
|
||||
|
||||
|
||||
@@ -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 [...]
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
- windows-2022
|
||||
- ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade pip setuptools
|
||||
- name: Install python 3.11 (MacOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
run: |
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
- windows-2022
|
||||
- ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade pip setuptools
|
||||
- name: Install python 3.11 (MacOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
run: |
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
- windows-2022
|
||||
- ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade pip setuptools
|
||||
- name: Install python 3.11 (MacOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
run: |
|
||||
@@ -43,7 +47,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
- windows-2022
|
||||
- ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade pip setuptools
|
||||
- name: Install python 3.11 (MacOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
run: |
|
||||
@@ -43,7 +47,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -21,6 +21,10 @@ jobs:
|
||||
- windows-2022
|
||||
- ubuntu-22.04
|
||||
steps:
|
||||
- uses: actions/setup-python@v5
|
||||
with:
|
||||
python-version: '3.11'
|
||||
- run: python -m pip install --upgrade pip setuptools
|
||||
- name: Install python 3.11 (MacOS)
|
||||
if: matrix.os == 'macos-14'
|
||||
run: |
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -44,7 +44,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -35,7 +35,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -30,7 +30,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: a5f52768cea7e98cae5e5b1f5fef3c47a475b8a6
|
||||
ref: 215e3c1a6c1bf764d19ab6c028bf1f8ad28a21a1
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
Vendored
+6
-1
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
# 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`.
|
||||
@@ -8,6 +8,58 @@ Builds:
|
||||
- linux - application for linux
|
||||
- win - application for Windows
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
@@ -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!
|
||||
|
||||
|
||||
@@ -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
@@ -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",
|
||||
|
||||
@@ -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'
|
||||
|
||||
@@ -49,6 +49,9 @@ module.exports = defineConfig({
|
||||
case 'charts':
|
||||
serverProcess = exec('yarn start:charts');
|
||||
break;
|
||||
case 'redis':
|
||||
serverProcess = exec('yarn start:redis');
|
||||
break;
|
||||
}
|
||||
|
||||
await waitOn({ resources: ['http://localhost:3000'] });
|
||||
|
||||
@@ -225,7 +225,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 +255,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 +264,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 });
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
|
||||
@@ -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)"
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -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"
|
||||
Vendored
+1
-6
@@ -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
|
||||
|
||||
Vendored
+6
@@ -0,0 +1,6 @@
|
||||
CONNECTIONS=redis
|
||||
|
||||
LABEL_redis=Redis-connection
|
||||
SERVER_redis=localhost
|
||||
ENGINE_redis=redis@dbgate-plugin-redis
|
||||
PORT_redis=16011
|
||||
@@ -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')
|
||||
|
||||
@@ -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')));
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
@@ -23,6 +23,7 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -31,6 +32,7 @@
|
||||
"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",
|
||||
"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",
|
||||
@@ -39,7 +41,8 @@
|
||||
"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": "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 && yarn test:redis",
|
||||
"test:ci": "yarn test"
|
||||
},
|
||||
"dependencies": {}
|
||||
|
||||
@@ -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
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "7.0.0-premium-beta.3",
|
||||
"version": "7.0.7-premium-beta.13",
|
||||
"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",
|
||||
|
||||
Vendored
+6
-1
@@ -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
|
||||
|
||||
@@ -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-datalib": "^7.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-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",
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -165,6 +165,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 });
|
||||
}
|
||||
@@ -393,6 +398,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);
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -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) => {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
|
||||
module.exports = {
|
||||
version: '6.0.0-alpha.1',
|
||||
version: '7.0.0-alpha.1',
|
||||
buildTime: '2024-12-01T00:00:00Z'
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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 };
|
||||
@@ -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}`);
|
||||
|
||||
@@ -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": [
|
||||
|
||||
@@ -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,
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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>
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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),
|
||||
})) || []
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,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.
|
||||
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}")`;
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
lib
|
||||
@@ -0,0 +1,7 @@
|
||||
# dbgate-rest
|
||||
|
||||
REST API support for DbGate
|
||||
|
||||
## Installation
|
||||
|
||||
yarn add dbgate-rest
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
preset: 'ts-jest',
|
||||
testEnvironment: 'node',
|
||||
moduleFileExtensions: ['ts', 'js'],
|
||||
reporters: ['default', 'github-actions'],
|
||||
};
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
});
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
@@ -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';
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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`,
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,161 @@
|
||||
import type { ODataMetadataDocument, ODataMetadataEntitySet, ODataMetadataEntityType, ODataMetadataNavigationProperty } from './oDataAdapter';
|
||||
|
||||
function decodeXmlEntities(value: string): string {
|
||||
return String(value ?? '')
|
||||
.replace(/"/g, '"')
|
||||
.replace(/'/g, "'")
|
||||
.replace(/</g, '<')
|
||||
.replace(/>/g, '>')
|
||||
.replace(/&/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,
|
||||
};
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -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 } : {}),
|
||||
};
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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('');
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,14 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2018",
|
||||
"module": "commonjs",
|
||||
"declaration": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "lib",
|
||||
"preserveWatchOutput": true,
|
||||
"esModuleInterop": true
|
||||
},
|
||||
"include": [
|
||||
"src/**/*"
|
||||
]
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -19,14 +19,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':
|
||||
|
||||
@@ -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,7 +26,7 @@
|
||||
],
|
||||
"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"
|
||||
@@ -34,7 +34,7 @@
|
||||
"dependencies": {
|
||||
"blueimp-md5": "^2.19.0",
|
||||
"dbgate-query-splitter": "^4.11.9",
|
||||
"dbgate-sqltree": "^6.0.0-alpha.1",
|
||||
"dbgate-sqltree": "^7.0.0-alpha.1",
|
||||
"debug": "^4.3.4",
|
||||
"json-stable-stringify": "^1.0.1",
|
||||
"lodash": "^4.17.21",
|
||||
|
||||
@@ -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');
|
||||
|
||||
@@ -27,6 +27,7 @@ interface SqlGeneratorOptions {
|
||||
createIndexes: boolean;
|
||||
insert: boolean;
|
||||
skipAutoincrementColumn: boolean;
|
||||
skipComputedColumns: boolean;
|
||||
disableConstraints: boolean;
|
||||
omitNulls: boolean;
|
||||
truncate: boolean;
|
||||
@@ -260,9 +261,12 @@ export class SqlGenerator {
|
||||
}
|
||||
|
||||
processReadable(table: TableInfo, readable) {
|
||||
const columnsFiltered = this.options.skipAutoincrementColumn
|
||||
const columnsFilteredPre = this.options.skipAutoincrementColumn
|
||||
? table.columns.filter(x => !x.autoIncrement)
|
||||
: table.columns;
|
||||
const columnsFiltered = this.options.skipComputedColumns
|
||||
? columnsFilteredPre.filter(x => !x.computedExpression)
|
||||
: columnsFilteredPre;
|
||||
const columnNames = columnsFiltered.map(x => x.columnName);
|
||||
let isClosed = false;
|
||||
let isHeaderRead = false;
|
||||
|
||||
@@ -1,351 +0,0 @@
|
||||
import _omit from 'lodash/omit';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
|
||||
export const DB_KEYS_SHOW_INCREMENT = 100;
|
||||
|
||||
export interface DbKeysNodeModelBase {
|
||||
text?: string;
|
||||
sortKey: string;
|
||||
key: string;
|
||||
count?: number;
|
||||
level: number;
|
||||
keyPath: string[];
|
||||
parentKey: string;
|
||||
}
|
||||
|
||||
export interface DbKeysLeafNodeModel extends DbKeysNodeModelBase {
|
||||
type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
|
||||
}
|
||||
|
||||
export interface DbKeysFolderNodeModel extends DbKeysNodeModelBase {
|
||||
// root: string;
|
||||
type: 'dir';
|
||||
// visibleCount?: number;
|
||||
// isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface DbKeysFolderStateMode {
|
||||
key: string;
|
||||
visibleCount?: number;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface DbKeysTreeModel {
|
||||
treeKeySeparator: string;
|
||||
root: DbKeysFolderNodeModel;
|
||||
dirsByKey: { [key: string]: DbKeysFolderNodeModel };
|
||||
dirStateByKey: { [key: string]: DbKeysFolderStateMode };
|
||||
childrenByKey: { [key: string]: DbKeysNodeModel[] };
|
||||
keyObjectsByKey: { [key: string]: DbKeysNodeModel };
|
||||
scannedKeys: number;
|
||||
loadCount: number;
|
||||
dbsize: number;
|
||||
cursor: string;
|
||||
loadedAll: boolean;
|
||||
// refreshAll?: boolean;
|
||||
}
|
||||
|
||||
export type DbKeysNodeModel = DbKeysLeafNodeModel | DbKeysFolderNodeModel;
|
||||
|
||||
export interface DbKeyLoadedModel {
|
||||
key: string;
|
||||
|
||||
type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface DbKeysLoadResult {
|
||||
nextCursor: string;
|
||||
keys: DbKeyLoadedModel[];
|
||||
dbsize: number;
|
||||
}
|
||||
|
||||
// export type DbKeysLoadFunction = (root: string, limit: number) => Promise<DbKeysLoadResult>;
|
||||
|
||||
export type DbKeysChangeModelFunction = (
|
||||
func: (model: DbKeysTreeModel) => DbKeysTreeModel,
|
||||
loadNextPage: boolean
|
||||
) => void;
|
||||
|
||||
// function dbKeys_findFolderNode(node: DbKeysNodeModel, root: string) {
|
||||
// if (node.type != 'dir') {
|
||||
// return null;
|
||||
// }
|
||||
// if (node.root === root) {
|
||||
// return node;
|
||||
// }
|
||||
// for (const child of node.children ?? []) {
|
||||
// const res = dbKeys_findFolderNode(child, root);
|
||||
// if (res) {
|
||||
// return res;
|
||||
// }
|
||||
// }
|
||||
// return null;
|
||||
// }
|
||||
|
||||
// export async function dbKeys_loadKeysFromNode(
|
||||
// tree: DbKeysTreeModel,
|
||||
// callingRoot: string,
|
||||
// separator: string,
|
||||
// loader: DbKeysLoadFunction
|
||||
// ): Promise<DbKeysTreeModel> {
|
||||
// const callingRootNode = tree.dirsByKey[callingRoot];
|
||||
// if (!callingRootNode) {
|
||||
// return tree;
|
||||
// }
|
||||
// const newItems = await loader(callingRoot, callingRootNode.maxShowCount ?? SHOW_INCREMENT);
|
||||
|
||||
// return {
|
||||
// ...tree,
|
||||
// childrenByKey: {
|
||||
// ...tree.childrenByKey,
|
||||
// [callingRoot]: newItems,
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
// export async function dbKeys_loadMissing(tree: DbKeysTreeModel, loader: DbKeysLoadFunction): Promise<DbKeysTreeModel> {
|
||||
// const childrenByKey = { ...tree.childrenByKey };
|
||||
// const dirsByKey = { ...tree.dirsByKey };
|
||||
|
||||
// for (const root in tree.dirsByKey) {
|
||||
// const dir = tree.dirsByKey[root];
|
||||
|
||||
// if (dir.isExpanded && dir.shouldLoadNext) {
|
||||
// if (!tree.childrenByKey[root] || dir.hasNext) {
|
||||
// const loadCount = dir.maxShowCount && dir.shouldLoadNext ? dir.maxShowCount + SHOW_INCREMENT : SHOW_INCREMENT;
|
||||
// const items = await loader(root, loadCount + 1);
|
||||
|
||||
// childrenByKey[root] = items.slice(0, loadCount);
|
||||
// dirsByKey[root] = {
|
||||
// ...dir,
|
||||
// shouldLoadNext: false,
|
||||
// maxShowCount: loadCount,
|
||||
// hasNext: items.length > loadCount,
|
||||
// };
|
||||
|
||||
// for (const child of items.slice(0, loadCount)) {
|
||||
// if (child.type == 'dir' && !dirsByKey[child.root]) {
|
||||
// dirsByKey[child.root] = {
|
||||
// shouldLoadNext: false,
|
||||
// maxShowCount: null,
|
||||
// hasNext: false,
|
||||
// isExpanded: false,
|
||||
// type: 'dir',
|
||||
// level: dir.level + 1,
|
||||
// root: child.root,
|
||||
// text: child.text,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// } else {
|
||||
// dirsByKey[root] = {
|
||||
// ...dir,
|
||||
// shouldLoadNext: false,
|
||||
// };
|
||||
// }
|
||||
// }
|
||||
// }
|
||||
|
||||
// return {
|
||||
// ...tree,
|
||||
// dirsByKey,
|
||||
// childrenByKey,
|
||||
// refreshAll: false,
|
||||
// };
|
||||
// }
|
||||
|
||||
export function dbKeys_mergeNextPage(tree: DbKeysTreeModel, nextPage: DbKeysLoadResult): DbKeysTreeModel {
|
||||
const keyObjectsByKey = { ...tree.keyObjectsByKey };
|
||||
|
||||
for (const keyObj of nextPage.keys) {
|
||||
const keyPath = keyObj.key.split(tree.treeKeySeparator);
|
||||
keyObjectsByKey[keyObj.key] = {
|
||||
...keyObj,
|
||||
level: keyPath.length,
|
||||
text: keyPath[keyPath.length - 1],
|
||||
sortKey: keyPath[keyPath.length - 1],
|
||||
keyPath,
|
||||
parentKey: keyPath.slice(0, -1).join(tree.treeKeySeparator),
|
||||
};
|
||||
}
|
||||
|
||||
const dirsByKey: { [key: string]: DbKeysFolderNodeModel } = {};
|
||||
const childrenByKey: { [key: string]: DbKeysNodeModel[] } = {};
|
||||
|
||||
dirsByKey[''] = tree.root;
|
||||
|
||||
for (const keyObj of Object.values(keyObjectsByKey)) {
|
||||
const dirPath = keyObj.keyPath.slice(0, -1);
|
||||
const dirKey = dirPath.join(tree.treeKeySeparator);
|
||||
|
||||
let dirDepth = keyObj.keyPath.length - 1;
|
||||
|
||||
while (dirDepth > 0) {
|
||||
const newDirPath = keyObj.keyPath.slice(0, dirDepth);
|
||||
const newDirKey = newDirPath.join(tree.treeKeySeparator);
|
||||
if (!dirsByKey[newDirKey]) {
|
||||
dirsByKey[newDirKey] = {
|
||||
level: keyObj.level - 1,
|
||||
keyPath: newDirPath,
|
||||
parentKey: newDirPath.slice(0, -1).join(tree.treeKeySeparator),
|
||||
type: 'dir',
|
||||
key: newDirKey,
|
||||
text: `${newDirPath[newDirPath.length - 1]}${tree.treeKeySeparator}*`,
|
||||
sortKey: newDirPath[newDirPath.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
dirDepth -= 1;
|
||||
}
|
||||
|
||||
if (!childrenByKey[dirKey]) {
|
||||
childrenByKey[dirKey] = [];
|
||||
}
|
||||
|
||||
childrenByKey[dirKey].push(keyObj);
|
||||
}
|
||||
|
||||
for (const dirObj of Object.values(dirsByKey)) {
|
||||
if (dirObj.key == '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!childrenByKey[dirObj.parentKey]) {
|
||||
childrenByKey[dirObj.parentKey] = [];
|
||||
}
|
||||
childrenByKey[dirObj.parentKey].push(dirObj);
|
||||
|
||||
// set key count
|
||||
dirsByKey[dirObj.key].count = childrenByKey[dirObj.key].length;
|
||||
}
|
||||
|
||||
for (const key in childrenByKey) {
|
||||
childrenByKey[key] = _sortBy(childrenByKey[key], 'sortKey');
|
||||
}
|
||||
|
||||
return {
|
||||
...tree,
|
||||
cursor: nextPage.nextCursor,
|
||||
dirsByKey,
|
||||
childrenByKey,
|
||||
keyObjectsByKey,
|
||||
scannedKeys: tree.scannedKeys + tree.loadCount,
|
||||
loadedAll: nextPage.nextCursor == '0',
|
||||
dbsize: nextPage.dbsize,
|
||||
};
|
||||
}
|
||||
|
||||
export function dbKeys_markNodeExpanded(tree: DbKeysTreeModel, root: string, isExpanded: boolean): DbKeysTreeModel {
|
||||
const node = tree.dirStateByKey[root];
|
||||
return {
|
||||
...tree,
|
||||
dirStateByKey: {
|
||||
...tree.dirStateByKey,
|
||||
[root]: {
|
||||
...node,
|
||||
isExpanded,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function dbKeys_showNextItems(tree: DbKeysTreeModel, root: string): DbKeysTreeModel {
|
||||
const node = tree.dirStateByKey[root];
|
||||
return {
|
||||
...tree,
|
||||
dirStateByKey: {
|
||||
...tree.dirStateByKey,
|
||||
[root]: {
|
||||
...node,
|
||||
visibleCount: (node?.visibleCount ?? DB_KEYS_SHOW_INCREMENT) + DB_KEYS_SHOW_INCREMENT,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function dbKeys_createNewModel(treeKeySeparator: string): DbKeysTreeModel {
|
||||
const root: DbKeysFolderNodeModel = {
|
||||
level: 0,
|
||||
type: 'dir',
|
||||
keyPath: [],
|
||||
parentKey: '',
|
||||
key: '',
|
||||
sortKey: '',
|
||||
};
|
||||
return {
|
||||
treeKeySeparator,
|
||||
childrenByKey: {},
|
||||
keyObjectsByKey: {},
|
||||
dirsByKey: {
|
||||
'': root,
|
||||
},
|
||||
dirStateByKey: {
|
||||
'': {
|
||||
key: '',
|
||||
visibleCount: DB_KEYS_SHOW_INCREMENT,
|
||||
isExpanded: true,
|
||||
},
|
||||
},
|
||||
scannedKeys: 0,
|
||||
dbsize: 0,
|
||||
loadCount: 2000,
|
||||
cursor: '0',
|
||||
root,
|
||||
loadedAll: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function dbKeys_clearLoadedData(tree: DbKeysTreeModel): DbKeysTreeModel {
|
||||
return {
|
||||
...tree,
|
||||
childrenByKey: {},
|
||||
keyObjectsByKey: {},
|
||||
dirsByKey: {
|
||||
'': tree.root,
|
||||
},
|
||||
scannedKeys: 0,
|
||||
dbsize: 0,
|
||||
cursor: '0',
|
||||
loadedAll: false,
|
||||
};
|
||||
}
|
||||
|
||||
// export function dbKeys_reloadFolder(tree: DbKeysTreeModel, root: string): DbKeysTreeModel {
|
||||
// return {
|
||||
// ...tree,
|
||||
// childrenByKey: _omit(tree.childrenByKey, root),
|
||||
// dirsByKey: {
|
||||
// ...tree.dirsByKey,
|
||||
// [root]: {
|
||||
// ...tree.dirsByKey[root],
|
||||
// shouldLoadNext: true,
|
||||
// hasNext: undefined,
|
||||
// },
|
||||
// },
|
||||
// };
|
||||
// }
|
||||
|
||||
function addFlatItems(tree: DbKeysTreeModel, root: string, res: DbKeysNodeModel[], visitedRoots: string[] = []) {
|
||||
const item = tree.dirStateByKey[root];
|
||||
if (!item?.isExpanded) {
|
||||
return false;
|
||||
}
|
||||
const children = tree.childrenByKey[root] || [];
|
||||
for (const child of children) {
|
||||
res.push(child);
|
||||
if (child.type == 'dir') {
|
||||
if (visitedRoots.includes(child.key)) {
|
||||
console.warn('Redis: preventing infinite loop for root', child.key);
|
||||
return false;
|
||||
}
|
||||
addFlatItems(tree, child.key, res, [...visitedRoots, root]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function dbKeys_getFlatList(tree: DbKeysTreeModel) {
|
||||
const res: DbKeysNodeModel[] = [];
|
||||
addFlatItems(tree, '', res);
|
||||
return res;
|
||||
}
|
||||
@@ -75,6 +75,7 @@ export const driverBase = {
|
||||
dialect,
|
||||
databaseEngineTypes: ['sql'],
|
||||
supportedCreateDatabase: true,
|
||||
supportExecuteQuery: true,
|
||||
|
||||
async analyseFull(pool, version) {
|
||||
const analyser = new this.analyserClass(pool, this, version);
|
||||
|
||||
@@ -24,6 +24,6 @@ export * from './getConnectionLabel';
|
||||
export * from './detectSqlFilterBehaviour';
|
||||
export * from './filterBehaviours';
|
||||
export * from './schemaInfoTools';
|
||||
export * from './dbKeysLoader';
|
||||
export * from './redisKeysLoader';
|
||||
export * from './rowProgressReporter';
|
||||
export * from './diagramTools';
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
import _omit from 'lodash/omit';
|
||||
import _sortBy from 'lodash/sortBy';
|
||||
|
||||
export const DB_KEYS_SHOW_INCREMENT = 100;
|
||||
|
||||
export interface RedisNodeModelBase {
|
||||
text?: string;
|
||||
sortKey: string;
|
||||
key: string;
|
||||
count?: number;
|
||||
level: number;
|
||||
keyPath: string[];
|
||||
parentKey: string;
|
||||
}
|
||||
|
||||
export interface RedisLeafNodeModel extends RedisNodeModelBase {
|
||||
type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
|
||||
}
|
||||
|
||||
export interface RedisFolderNodeModel extends RedisNodeModelBase {
|
||||
// root: string;
|
||||
type: 'dir';
|
||||
// visibleCount?: number;
|
||||
// isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface RedisFolderStateMode {
|
||||
key: string;
|
||||
visibleCount?: number;
|
||||
isExpanded?: boolean;
|
||||
}
|
||||
|
||||
export interface RedisTreeModel {
|
||||
treeKeySeparator: string;
|
||||
root: RedisFolderNodeModel;
|
||||
dirsByKey: { [key: string]: RedisFolderNodeModel };
|
||||
dirStateByKey: { [key: string]: RedisFolderStateMode };
|
||||
childrenByKey: { [key: string]: RedisNodeModel[] };
|
||||
keyObjectsByKey: { [key: string]: RedisNodeModel };
|
||||
scannedKeys: number;
|
||||
loadCount: number;
|
||||
dbsize: number;
|
||||
cursor: string;
|
||||
loadedAll: boolean;
|
||||
// refreshAll?: boolean;
|
||||
}
|
||||
|
||||
export type RedisNodeModel = RedisLeafNodeModel | RedisFolderNodeModel;
|
||||
|
||||
export interface RedisLoadedModel {
|
||||
key: string;
|
||||
|
||||
type: 'string' | 'hash' | 'set' | 'list' | 'zset' | 'stream' | 'binary' | 'ReJSON-RL';
|
||||
count?: number;
|
||||
}
|
||||
|
||||
export interface RedisLoadResult {
|
||||
nextCursor: string;
|
||||
keys: RedisLoadedModel[];
|
||||
dbsize: number;
|
||||
}
|
||||
|
||||
export type RedisChangeModelFunction = (func: (model: RedisTreeModel) => RedisTreeModel, loadNextPage: boolean) => void;
|
||||
|
||||
export function redis_mergeNextPage(tree: RedisTreeModel, nextPage: RedisLoadResult): RedisTreeModel {
|
||||
const keyObjectsByKey = { ...tree.keyObjectsByKey };
|
||||
|
||||
for (const keyObj of nextPage.keys) {
|
||||
const keyPath = keyObj.key.split(tree.treeKeySeparator);
|
||||
keyObjectsByKey[keyObj.key] = {
|
||||
...keyObj,
|
||||
level: keyPath.length,
|
||||
text: keyPath[keyPath.length - 1],
|
||||
sortKey: keyPath[keyPath.length - 1],
|
||||
keyPath,
|
||||
parentKey: keyPath.slice(0, -1).join(tree.treeKeySeparator),
|
||||
};
|
||||
}
|
||||
|
||||
const dirsByKey: { [key: string]: RedisFolderNodeModel } = {};
|
||||
const childrenByKey: { [key: string]: RedisNodeModel[] } = {};
|
||||
|
||||
dirsByKey[''] = tree.root;
|
||||
|
||||
for (const keyObj of Object.values(keyObjectsByKey)) {
|
||||
const dirPath = keyObj.keyPath.slice(0, -1);
|
||||
const dirKey = dirPath.join(tree.treeKeySeparator);
|
||||
|
||||
let dirDepth = keyObj.keyPath.length - 1;
|
||||
|
||||
while (dirDepth > 0) {
|
||||
const newDirPath = keyObj.keyPath.slice(0, dirDepth);
|
||||
const newDirKey = newDirPath.join(tree.treeKeySeparator);
|
||||
if (!dirsByKey[newDirKey]) {
|
||||
dirsByKey[newDirKey] = {
|
||||
level: keyObj.level - 1,
|
||||
keyPath: newDirPath,
|
||||
parentKey: newDirPath.slice(0, -1).join(tree.treeKeySeparator),
|
||||
type: 'dir',
|
||||
key: newDirKey,
|
||||
text: `${newDirPath[newDirPath.length - 1]}${tree.treeKeySeparator}*`,
|
||||
sortKey: newDirPath[newDirPath.length - 1],
|
||||
};
|
||||
}
|
||||
|
||||
dirDepth -= 1;
|
||||
}
|
||||
|
||||
if (!childrenByKey[dirKey]) {
|
||||
childrenByKey[dirKey] = [];
|
||||
}
|
||||
|
||||
childrenByKey[dirKey].push(keyObj);
|
||||
}
|
||||
|
||||
for (const dirObj of Object.values(dirsByKey)) {
|
||||
if (dirObj.key == '') {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!childrenByKey[dirObj.parentKey]) {
|
||||
childrenByKey[dirObj.parentKey] = [];
|
||||
}
|
||||
childrenByKey[dirObj.parentKey].push(dirObj);
|
||||
|
||||
// set key count
|
||||
dirsByKey[dirObj.key].count = childrenByKey[dirObj.key]?.length || 0;
|
||||
}
|
||||
|
||||
for (const key in childrenByKey) {
|
||||
childrenByKey[key] = _sortBy(childrenByKey[key], 'sortKey');
|
||||
}
|
||||
|
||||
return {
|
||||
...tree,
|
||||
cursor: nextPage.nextCursor,
|
||||
dirsByKey,
|
||||
childrenByKey,
|
||||
keyObjectsByKey,
|
||||
scannedKeys: tree.scannedKeys + tree.loadCount,
|
||||
loadedAll: nextPage.nextCursor == '0',
|
||||
dbsize: nextPage.dbsize,
|
||||
};
|
||||
}
|
||||
|
||||
export function redis_markNodeExpanded(tree: RedisTreeModel, root: string, isExpanded: boolean): RedisTreeModel {
|
||||
const node = tree.dirStateByKey[root];
|
||||
return {
|
||||
...tree,
|
||||
dirStateByKey: {
|
||||
...tree.dirStateByKey,
|
||||
[root]: {
|
||||
...node,
|
||||
isExpanded,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function redis_showNextItems(tree: RedisTreeModel, root: string): RedisTreeModel {
|
||||
const node = tree.dirStateByKey[root];
|
||||
return {
|
||||
...tree,
|
||||
dirStateByKey: {
|
||||
...tree.dirStateByKey,
|
||||
[root]: {
|
||||
...node,
|
||||
visibleCount: (node?.visibleCount ?? DB_KEYS_SHOW_INCREMENT) + DB_KEYS_SHOW_INCREMENT,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export function redis_createNewModel(treeKeySeparator: string): RedisTreeModel {
|
||||
const root: RedisFolderNodeModel = {
|
||||
level: 0,
|
||||
type: 'dir',
|
||||
keyPath: [],
|
||||
parentKey: '',
|
||||
key: '',
|
||||
sortKey: '',
|
||||
};
|
||||
return {
|
||||
treeKeySeparator,
|
||||
childrenByKey: {},
|
||||
keyObjectsByKey: {},
|
||||
dirsByKey: {
|
||||
'': root,
|
||||
},
|
||||
dirStateByKey: {
|
||||
'': {
|
||||
key: '',
|
||||
visibleCount: DB_KEYS_SHOW_INCREMENT,
|
||||
isExpanded: true,
|
||||
},
|
||||
},
|
||||
scannedKeys: 0,
|
||||
dbsize: 0,
|
||||
loadCount: 2000,
|
||||
cursor: '0',
|
||||
root,
|
||||
loadedAll: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function redis_clearLoadedData(tree: RedisTreeModel): RedisTreeModel {
|
||||
return {
|
||||
...tree,
|
||||
childrenByKey: {},
|
||||
keyObjectsByKey: {},
|
||||
dirsByKey: {
|
||||
'': tree.root,
|
||||
},
|
||||
scannedKeys: 0,
|
||||
dbsize: 0,
|
||||
cursor: '0',
|
||||
loadedAll: false,
|
||||
};
|
||||
}
|
||||
|
||||
function addFlatItems(tree: RedisTreeModel, root: string, res: RedisNodeModel[], visitedRoots: string[] = []) {
|
||||
const item = tree.dirStateByKey[root];
|
||||
if (!item?.isExpanded) {
|
||||
return false;
|
||||
}
|
||||
const children = tree.childrenByKey[root] || [];
|
||||
for (const child of children) {
|
||||
res.push(child);
|
||||
if (child.type == 'dir') {
|
||||
if (visitedRoots.includes(child.key)) {
|
||||
console.warn('Redis: preventing infinite loop for root', child.key);
|
||||
return false;
|
||||
}
|
||||
addFlatItems(tree, child.key, res, [...visitedRoots, root]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function redis_getFlatList(tree: RedisTreeModel) {
|
||||
const res: RedisNodeModel[] = [];
|
||||
addFlatItems(tree, '', res);
|
||||
return res;
|
||||
}
|
||||
|
||||
export interface SupportedRedisKeyType {
|
||||
name: string;
|
||||
label: string;
|
||||
dbKeyFields: {
|
||||
name: string;
|
||||
cols?: number;
|
||||
label?: string;
|
||||
placeholder?: string;
|
||||
}[];
|
||||
dbKeyFieldsForGrid?: {
|
||||
name: string;
|
||||
cols?: number;
|
||||
label?: string;
|
||||
}[];
|
||||
keyColumn?: string;
|
||||
showItemList?: boolean;
|
||||
showGeneratedId?: boolean;
|
||||
}
|
||||
|
||||
export const supportedRedisKeyTypes: SupportedRedisKeyType[] = [
|
||||
{
|
||||
name: 'string',
|
||||
label: 'String',
|
||||
dbKeyFields: [{ name: 'value' }],
|
||||
},
|
||||
{
|
||||
name: 'list',
|
||||
label: 'List',
|
||||
dbKeyFields: [{ name: 'value', cols: 12 }],
|
||||
showItemList: true,
|
||||
},
|
||||
{
|
||||
name: 'set',
|
||||
label: 'Set',
|
||||
dbKeyFields: [{ name: 'value', cols: 12 }],
|
||||
keyColumn: 'value',
|
||||
showItemList: true,
|
||||
},
|
||||
{
|
||||
name: 'zset',
|
||||
label: 'Sorted Set',
|
||||
dbKeyFields: [
|
||||
{ name: 'member', cols: 8 },
|
||||
{ name: 'score', cols: 4 },
|
||||
],
|
||||
keyColumn: 'member',
|
||||
showItemList: true,
|
||||
},
|
||||
{
|
||||
name: 'hash',
|
||||
label: 'Hash',
|
||||
dbKeyFields: [
|
||||
{ name: 'key', cols: 3, label: 'Field' },
|
||||
{ name: 'value', cols: 7 },
|
||||
{ name: 'ttl', cols: 2, label: 'TTL' },
|
||||
],
|
||||
keyColumn: 'key',
|
||||
showItemList: true,
|
||||
},
|
||||
{
|
||||
name: 'stream',
|
||||
label: 'Stream',
|
||||
dbKeyFields: [
|
||||
{ name: 'field', cols: 6 },
|
||||
{ name: 'value', cols: 6 },
|
||||
],
|
||||
dbKeyFieldsForGrid: [
|
||||
{ name: 'id', cols: 6 },
|
||||
{ name: 'value', cols: 6 },
|
||||
],
|
||||
keyColumn: 'id',
|
||||
showItemList: true,
|
||||
showGeneratedId: true,
|
||||
},
|
||||
{
|
||||
name: 'json',
|
||||
label: 'JSON',
|
||||
dbKeyFields: [{ name: 'value' }],
|
||||
},
|
||||
];
|
||||
|
||||
export function findSupportedRedisKeyType(type: string): SupportedRedisKeyType | undefined {
|
||||
return supportedRedisKeyTypes.find(t => t.name === type);
|
||||
}
|
||||
Vendored
+8
@@ -10,6 +10,8 @@ export interface SqlDialect {
|
||||
offsetFetchRangeSyntax?: boolean;
|
||||
offsetFirstSkipSyntax?: boolean;
|
||||
offsetNotSupported?: boolean;
|
||||
useDatalengthForEmptyString?(dataType: string): boolean;
|
||||
disableGroupingForDataType?(dataType: string): boolean;
|
||||
quoteIdentifier(s: string): string;
|
||||
fallbackDataType?: string;
|
||||
explicitDropConstraint?: boolean;
|
||||
@@ -48,6 +50,12 @@ export interface SqlDialect {
|
||||
multipleSchema?: boolean;
|
||||
filteredIndexes?: boolean;
|
||||
namedDefaultConstraint?: boolean;
|
||||
indexTypes?: {
|
||||
value: string;
|
||||
label: string;
|
||||
isUnique?: boolean;
|
||||
indexType?: string;
|
||||
}[];
|
||||
|
||||
specificNullabilityImplementation?: boolean;
|
||||
implicitNullDeclaration?: boolean;
|
||||
|
||||
Vendored
+13
-3
@@ -15,6 +15,8 @@ import {
|
||||
} from './dbinfo';
|
||||
import { FilterBehaviour } from './filter-type';
|
||||
|
||||
export type EngineDriverIcon = string | { light: string; dark?: string };
|
||||
|
||||
export interface StreamOptions {
|
||||
recordset: (columns) => void;
|
||||
row: (row) => void;
|
||||
@@ -71,7 +73,7 @@ export interface WriteTableOptions {
|
||||
export interface EngineAuthType {
|
||||
title: string;
|
||||
name: string;
|
||||
disabledFields: string[];
|
||||
disabledFields?: string[];
|
||||
}
|
||||
|
||||
export interface ReadCollectionOptions {
|
||||
@@ -240,7 +242,6 @@ export interface EngineDriver<TClient = any, TDataBase = any> extends FilterBeha
|
||||
databaseEngineTypes: string[];
|
||||
editorMode?: string;
|
||||
readOnlySessions: boolean;
|
||||
supportedKeyTypes: SupportedDbKeyType[];
|
||||
dataEditorTypesBehaviour: DataEditorTypesBehaviour;
|
||||
supportsDatabaseUrl?: boolean;
|
||||
supportsDatabaseBackup?: boolean;
|
||||
@@ -257,12 +258,20 @@ export interface EngineDriver<TClient = any, TDataBase = any> extends FilterBeha
|
||||
supportsTransactions?: boolean;
|
||||
implicitTransactions?: boolean; // transaction is started with first SQL command, no BEGIN TRANSACTION is needed
|
||||
premiumOnly?: boolean;
|
||||
supportExecuteQuery?: boolean;
|
||||
|
||||
collectionSingularLabel?: string;
|
||||
collectionPluralLabel?: string;
|
||||
collectionNameLabel?: string;
|
||||
newCollectionFormParams?: any[];
|
||||
icon?: any;
|
||||
disableRenameCollection?: boolean;
|
||||
icon?: EngineDriverIcon;
|
||||
|
||||
apiServerUrl1Label?: string;
|
||||
apiServerUrl1Placeholder?: string;
|
||||
apiServerUrl2Label?: string;
|
||||
apiServerUrl2Placeholder?: string;
|
||||
loadApiServerUrl2Options?: boolean;
|
||||
|
||||
supportedCreateDatabase?: boolean;
|
||||
showConnectionField?: (
|
||||
@@ -404,6 +413,7 @@ export interface EngineDriver<TClient = any, TDataBase = any> extends FilterBeha
|
||||
): { message: string; severity: 'info' | 'error' | 'debug' } | null;
|
||||
getNativeOperationFormArgs(operation: 'backup' | 'restore'): any[];
|
||||
getAdvancedConnectionFields(): any[];
|
||||
sortCollectionDisplayColumns?(columns: any[]): any[];
|
||||
|
||||
analyserClass?: any;
|
||||
dumperClass?: any;
|
||||
|
||||
Vendored
+1
@@ -28,6 +28,7 @@ export interface ThemeDefinition {
|
||||
isBuiltInTheme?: boolean;
|
||||
themeVariables?: { [key: string]: string };
|
||||
themePublicCloudPath?: string;
|
||||
editorTheme?: string;
|
||||
}
|
||||
|
||||
export interface PluginDefinition {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"version": "6.0.0-alpha.1",
|
||||
"version": "7.0.0-alpha.1",
|
||||
"name": "dbgate-types",
|
||||
"homepage": "https://dbgate.org/",
|
||||
"homepage": "https://www.dbgate.io/",
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dbgate/dbgate.git"
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# dbgate-web
|
||||
|
||||
This package is used internally by [DbGate](https://dbgate.org)
|
||||
This package is used internally by [DbGate](https://www.dbgate.io)
|
||||
|
||||
@@ -13,6 +13,8 @@ for (const page of [
|
||||
'admin-license',
|
||||
'set-admin-password',
|
||||
'redirect',
|
||||
'forgot-password',
|
||||
'reset-password',
|
||||
]) {
|
||||
const text = template.replace(/{{page}}/g, page);
|
||||
fs.writeFileSync(`public/${page || 'index'}.html`, text);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user