Compare commits
42 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 02e3bfaa8a | |||
| c7259e4663 | |||
| 42d1ca8fd4 | |||
| 1cf52d8b39 | |||
| a7cf51bdf7 | |||
| dfdb31e2f8 | |||
| 3508ddc3ca | |||
| 137fc6b928 | |||
| e6f5295420 | |||
| 2bb08921c3 | |||
| ee2d0e4c30 | |||
| c43a838572 | |||
| 17ff6a8013 | |||
| 62ad6a0d08 | |||
| 5c049fa867 | |||
| 619f17114a | |||
| 1c1431014c | |||
| 9d1d7b7e34 | |||
| f68ca1e786 | |||
| 8d16a30064 | |||
| cf601c33c0 | |||
| 588cd39d7c | |||
| 79ebfa9b7a | |||
| 0c6b2746d1 | |||
| 978972c55c | |||
| 37854fc577 | |||
| 5537e193a6 | |||
| 0d42b2b133 | |||
| 44bd7972d4 | |||
| 5143eb39f7 | |||
| cf51883b3e | |||
| 484ca0c78a | |||
| 8f5cad0e2c | |||
| 988512a571 | |||
| f8bd380051 | |||
| 281131dbba | |||
| ea3a61077a | |||
| d1a898b40d | |||
| a521a81ef0 | |||
| 2505c61975 | |||
| ab5a54dbb6 | |||
| 44ad8fa60a |
@@ -47,7 +47,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -47,7 +47,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -39,7 +39,7 @@ jobs:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- 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: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- 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: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- 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: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -23,26 +23,49 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Checkout dbgate/dbgate-pro
|
||||
uses: actions/checkout@v2
|
||||
with:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
mv dbgate-pro/* ../dbgate-pro/
|
||||
cd ..
|
||||
mkdir dbgate-merged
|
||||
cd dbgate-pro
|
||||
cd sync
|
||||
yarn
|
||||
node sync.js --nowatch
|
||||
cd ..
|
||||
- name: yarn install
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
yarn install
|
||||
- name: Integration tests
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd integration-tests
|
||||
yarn test:ci
|
||||
- name: Filter parser tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/filterparser
|
||||
yarn test:ci
|
||||
- name: Datalib (perspective) tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/datalib
|
||||
yarn test:ci
|
||||
- name: Tools tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/tools
|
||||
yarn test:ci
|
||||
services:
|
||||
@@ -98,3 +121,14 @@ jobs:
|
||||
FIREBIRD_USE_LEGACY_AUTH: true
|
||||
ports:
|
||||
- '3050:3050'
|
||||
mongodb:
|
||||
image: mongo:4.0.12
|
||||
ports:
|
||||
- '27017:27017'
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- mongo-config:/data/configdb
|
||||
dynamodb:
|
||||
image: amazon/dynamodb-local
|
||||
ports:
|
||||
- '8000:8000'
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
# --------------------------------------------------------------------------------------------
|
||||
# This file is generated. Do not edit manually
|
||||
# --------------------------------------------------------------------------------------------
|
||||
name: SAST
|
||||
'on':
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
jobs:
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
- name: Clone Apiiro malicious code ruleset
|
||||
run: git clone https://github.com/apiiro/malicious-code-ruleset.git /apiiro-ruleset
|
||||
- name: Run Semgrep SAST scan
|
||||
run: semgrep ci --code --config=auto --config /apiiro-ruleset --json > sast-report.json || true
|
||||
- name: Upload SAST report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sast-report
|
||||
path: sast-report.json
|
||||
@@ -9,6 +9,12 @@ Builds:
|
||||
- linux - application for linux
|
||||
- win - application for Windows
|
||||
|
||||
## 7.1.5
|
||||
|
||||
- FIXED: Issues with cloud and file loading
|
||||
- ADDED: Support for displaying MongoDB UUID #1394
|
||||
- ADDED: SVG icon sanitization
|
||||
|
||||
## 7.1.4
|
||||
|
||||
- FIXED: NPM installation failure #1375
|
||||
|
||||
@@ -400,6 +400,14 @@ function createWindow() {
|
||||
},
|
||||
});
|
||||
|
||||
mainWindow.webContents.session.webRequest.onBeforeSendHeaders(
|
||||
{ urls: ['https://*.tile.openstreetmap.org/*'] },
|
||||
(details, callback) => {
|
||||
details.requestHeaders['Referer'] = 'https://www.dbgate.io';
|
||||
callback({ requestHeaders: details.requestHeaders });
|
||||
}
|
||||
);
|
||||
|
||||
if (initialConfig['winIsMaximized']) {
|
||||
mainWindow.maximize();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,536 @@
|
||||
const requireEngineDriver = require('dbgate-api/src/utility/requireEngineDriver');
|
||||
const crypto = require('crypto');
|
||||
const stream = require('stream');
|
||||
const { mongoDbEngine, dynamoDbEngine } = require('../engines');
|
||||
const tableWriter = require('dbgate-api/src/shell/tableWriter');
|
||||
const tableReader = require('dbgate-api/src/shell/tableReader');
|
||||
const copyStream = require('dbgate-api/src/shell/copyStream');
|
||||
|
||||
function randomCollectionName() {
|
||||
return 'test_' + crypto.randomBytes(6).toString('hex');
|
||||
}
|
||||
|
||||
const documentEngines = [
|
||||
{ label: 'MongoDB', engine: mongoDbEngine },
|
||||
{ label: 'DynamoDB', engine: dynamoDbEngine },
|
||||
];
|
||||
|
||||
async function connectEngine(engine) {
|
||||
const driver = requireEngineDriver(engine.connection);
|
||||
const conn = await driver.connect(engine.connection);
|
||||
return { driver, conn };
|
||||
}
|
||||
|
||||
async function createCollection(driver, conn, collectionName, engine) {
|
||||
if (engine.connection.engine.startsWith('dynamodb')) {
|
||||
await driver.operation(conn, {
|
||||
type: 'createCollection',
|
||||
collection: {
|
||||
name: collectionName,
|
||||
partitionKey: '_id',
|
||||
partitionKeyType: 'S',
|
||||
},
|
||||
});
|
||||
} else {
|
||||
await driver.operation(conn, {
|
||||
type: 'createCollection',
|
||||
collection: { name: collectionName },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
async function dropCollection(driver, conn, collectionName) {
|
||||
try {
|
||||
await driver.operation(conn, {
|
||||
type: 'dropCollection',
|
||||
collection: collectionName,
|
||||
});
|
||||
} catch (e) {
|
||||
// Ignore errors when dropping (collection may not exist)
|
||||
}
|
||||
}
|
||||
|
||||
async function insertDocument(driver, conn, collectionName, doc) {
|
||||
return driver.updateCollection(conn, {
|
||||
inserts: [{ pureName: collectionName, document: {}, fields: doc }],
|
||||
updates: [],
|
||||
deletes: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function readAll(driver, conn, collectionName) {
|
||||
return driver.readCollection(conn, { pureName: collectionName, limit: 1000 });
|
||||
}
|
||||
|
||||
async function updateDocument(driver, conn, collectionName, condition, fields) {
|
||||
return driver.updateCollection(conn, {
|
||||
inserts: [],
|
||||
updates: [{ pureName: collectionName, condition, fields }],
|
||||
deletes: [],
|
||||
});
|
||||
}
|
||||
|
||||
async function deleteDocument(driver, conn, collectionName, condition) {
|
||||
return driver.updateCollection(conn, {
|
||||
inserts: [],
|
||||
updates: [],
|
||||
deletes: [{ pureName: collectionName, condition }],
|
||||
});
|
||||
}
|
||||
|
||||
describe('Collection CRUD', () => {
|
||||
describe.each(documentEngines.map(e => [e.label, e.engine]))('%s', (label, engine) => {
|
||||
let driver;
|
||||
let conn;
|
||||
let collectionName;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await connectEngine(engine);
|
||||
driver = result.driver;
|
||||
conn = result.conn;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (conn) {
|
||||
await driver.close(conn);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
collectionName = randomCollectionName();
|
||||
await createCollection(driver, conn, collectionName, engine);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await dropCollection(driver, conn, collectionName);
|
||||
});
|
||||
|
||||
// ---- INSERT ----
|
||||
|
||||
test('insert a single document', async () => {
|
||||
const res = await insertDocument(driver, conn, collectionName, {
|
||||
_id: 'doc1',
|
||||
name: 'Alice',
|
||||
age: 30,
|
||||
});
|
||||
expect(res.inserted.length).toBe(1);
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Alice');
|
||||
expect(all.rows[0].age).toBe(30);
|
||||
});
|
||||
|
||||
test('insert multiple documents', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'a1', name: 'Alice' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'a2', name: 'Bob' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'a3', name: 'Charlie' });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(3);
|
||||
const names = all.rows.map(r => r.name).sort();
|
||||
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
|
||||
test('insert document with nested object', async () => {
|
||||
await insertDocument(driver, conn, collectionName, {
|
||||
_id: 'nested1',
|
||||
name: 'Alice',
|
||||
address: { city: 'Prague', zip: '11000' },
|
||||
});
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].address.city).toBe('Prague');
|
||||
expect(all.rows[0].address.zip).toBe('11000');
|
||||
});
|
||||
|
||||
// ---- READ ----
|
||||
|
||||
test('read from empty collection returns no rows', async () => {
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(0);
|
||||
});
|
||||
|
||||
test('read with limit', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'l1', name: 'A' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'l2', name: 'B' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'l3', name: 'C' });
|
||||
|
||||
const limited = await driver.readCollection(conn, {
|
||||
pureName: collectionName,
|
||||
limit: 2,
|
||||
});
|
||||
expect(limited.rows.length).toBe(2);
|
||||
});
|
||||
|
||||
test('count documents', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'c1', name: 'A' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'c2', name: 'B' });
|
||||
|
||||
const result = await driver.readCollection(conn, {
|
||||
pureName: collectionName,
|
||||
countDocuments: true,
|
||||
});
|
||||
expect(result.count).toBe(2);
|
||||
});
|
||||
|
||||
test('count documents on empty collection returns zero', async () => {
|
||||
const result = await driver.readCollection(conn, {
|
||||
pureName: collectionName,
|
||||
countDocuments: true,
|
||||
});
|
||||
expect(result.count).toBe(0);
|
||||
});
|
||||
|
||||
// ---- UPDATE ----
|
||||
|
||||
test('update an existing document', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'u1', name: 'Alice', age: 25 });
|
||||
|
||||
const res = await updateDocument(driver, conn, collectionName, { _id: 'u1' }, { name: 'Alice Updated' });
|
||||
expect(res.errorMessage).toBeUndefined();
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Alice Updated');
|
||||
});
|
||||
|
||||
test('update does not create new document', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'u2', name: 'Bob' });
|
||||
|
||||
await updateDocument(driver, conn, collectionName, { _id: 'nonexistent' }, { name: 'Ghost' });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Bob');
|
||||
});
|
||||
|
||||
test('update only specified fields', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'u3', name: 'Carol', age: 40, city: 'London' });
|
||||
|
||||
await updateDocument(driver, conn, collectionName, { _id: 'u3' }, { age: 41 });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Carol');
|
||||
expect(all.rows[0].age).toBe(41);
|
||||
expect(all.rows[0].city).toBe('London');
|
||||
});
|
||||
|
||||
// ---- DELETE ----
|
||||
|
||||
test('delete an existing document', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'd1', name: 'Alice' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'd2', name: 'Bob' });
|
||||
|
||||
const res = await deleteDocument(driver, conn, collectionName, { _id: 'd1' });
|
||||
expect(res.errorMessage).toBeUndefined();
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Bob');
|
||||
});
|
||||
|
||||
test('delete non-existing document does not affect collection', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'dx1', name: 'Alice' });
|
||||
|
||||
await deleteDocument(driver, conn, collectionName, { _id: 'nonexistent' });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Alice');
|
||||
});
|
||||
|
||||
test('delete all documents leaves empty collection', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'da1', name: 'A' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'da2', name: 'B' });
|
||||
|
||||
await deleteDocument(driver, conn, collectionName, { _id: 'da1' });
|
||||
await deleteDocument(driver, conn, collectionName, { _id: 'da2' });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(0);
|
||||
});
|
||||
|
||||
// ---- EDGE CASES ----
|
||||
|
||||
test('insert and read document with empty string field', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'e1', name: '', value: 'test' });
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('');
|
||||
expect(all.rows[0].value).toBe('test');
|
||||
});
|
||||
|
||||
test('insert and read document with numeric values', async () => {
|
||||
await insertDocument(driver, conn, collectionName, {
|
||||
_id: 'n1',
|
||||
intVal: 42,
|
||||
floatVal: 3.14,
|
||||
zero: 0,
|
||||
negative: -10,
|
||||
});
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].intVal).toBe(42);
|
||||
expect(all.rows[0].floatVal).toBeCloseTo(3.14);
|
||||
expect(all.rows[0].zero).toBe(0);
|
||||
expect(all.rows[0].negative).toBe(-10);
|
||||
});
|
||||
|
||||
test('insert and read document with boolean values', async () => {
|
||||
await insertDocument(driver, conn, collectionName, {
|
||||
_id: 'b1',
|
||||
active: true,
|
||||
deleted: false,
|
||||
});
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].active).toBe(true);
|
||||
expect(all.rows[0].deleted).toBe(false);
|
||||
});
|
||||
|
||||
test('reading non-existing collection returns error or empty', async () => {
|
||||
const result = await driver.readCollection(conn, {
|
||||
pureName: 'nonexistent_collection_' + crypto.randomBytes(4).toString('hex'),
|
||||
limit: 10,
|
||||
});
|
||||
// Depending on the driver, this may return an error or empty rows
|
||||
if (result.errorMessage) {
|
||||
expect(typeof result.errorMessage).toBe('string');
|
||||
} else {
|
||||
expect(result.rows.length).toBe(0);
|
||||
}
|
||||
});
|
||||
|
||||
test('replace full document via update with document field', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'r1', name: 'Original', extra: 'data' });
|
||||
|
||||
await driver.updateCollection(conn, {
|
||||
inserts: [],
|
||||
updates: [
|
||||
{
|
||||
pureName: collectionName,
|
||||
condition: { _id: 'r1' },
|
||||
document: { _id: 'r1', name: 'Replaced' },
|
||||
fields: {},
|
||||
},
|
||||
],
|
||||
deletes: [],
|
||||
});
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].name).toBe('Replaced');
|
||||
});
|
||||
|
||||
test('insert then update then delete lifecycle', async () => {
|
||||
// Insert
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'life1', name: 'Lifecycle', status: 'created' });
|
||||
let all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(1);
|
||||
expect(all.rows[0].status).toBe('created');
|
||||
|
||||
// Update
|
||||
await updateDocument(driver, conn, collectionName, { _id: 'life1' }, { status: 'updated' });
|
||||
all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows[0].status).toBe('updated');
|
||||
|
||||
// Delete
|
||||
await deleteDocument(driver, conn, collectionName, { _id: 'life1' });
|
||||
all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
function createDocumentImportStream(documents) {
|
||||
const pass = new stream.PassThrough({ objectMode: true });
|
||||
pass.write({ __isStreamHeader: true, __isDynamicStructure: true });
|
||||
for (const doc of documents) {
|
||||
pass.write(doc);
|
||||
}
|
||||
pass.end();
|
||||
return pass;
|
||||
}
|
||||
|
||||
function createExportStream() {
|
||||
const writable = new stream.Writable({ objectMode: true });
|
||||
writable.resultArray = [];
|
||||
writable._write = (chunk, encoding, callback) => {
|
||||
writable.resultArray.push(chunk);
|
||||
callback();
|
||||
};
|
||||
return writable;
|
||||
}
|
||||
|
||||
describe('Collection Import/Export', () => {
|
||||
describe.each(documentEngines.map(e => [e.label, e.engine]))('%s', (label, engine) => {
|
||||
let driver;
|
||||
let conn;
|
||||
let collectionName;
|
||||
|
||||
beforeAll(async () => {
|
||||
const result = await connectEngine(engine);
|
||||
driver = result.driver;
|
||||
conn = result.conn;
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
if (conn) {
|
||||
await driver.close(conn);
|
||||
}
|
||||
});
|
||||
|
||||
beforeEach(async () => {
|
||||
collectionName = randomCollectionName();
|
||||
await createCollection(driver, conn, collectionName, engine);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await dropCollection(driver, conn, collectionName);
|
||||
});
|
||||
|
||||
test('import documents via stream', async () => {
|
||||
const documents = [
|
||||
{ _id: 'imp1', name: 'Alice', age: 30 },
|
||||
{ _id: 'imp2', name: 'Bob', age: 25 },
|
||||
{ _id: 'imp3', name: 'Charlie', age: 35 },
|
||||
];
|
||||
|
||||
const reader = createDocumentImportStream(documents);
|
||||
const writer = await tableWriter({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
createIfNotExists: true,
|
||||
});
|
||||
await copyStream(reader, writer);
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(3);
|
||||
const names = all.rows.map(r => r.name).sort();
|
||||
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
|
||||
test('export documents via stream', async () => {
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'exp1', name: 'Alice', city: 'Prague' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'exp2', name: 'Bob', city: 'Vienna' });
|
||||
await insertDocument(driver, conn, collectionName, { _id: 'exp3', name: 'Charlie', city: 'Berlin' });
|
||||
|
||||
const reader = await tableReader({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
});
|
||||
const writer = createExportStream();
|
||||
await copyStream(reader, writer);
|
||||
|
||||
const rows = writer.resultArray.filter(x => !x.__isStreamHeader);
|
||||
expect(rows.length).toBe(3);
|
||||
const names = rows.map(r => r.name).sort();
|
||||
expect(names).toEqual(['Alice', 'Bob', 'Charlie']);
|
||||
});
|
||||
|
||||
test('import then export round-trip', async () => {
|
||||
const documents = [
|
||||
{ _id: 'rt1', name: 'Alice', value: 100 },
|
||||
{ _id: 'rt2', name: 'Bob', value: 200 },
|
||||
{ _id: 'rt3', name: 'Charlie', value: 300 },
|
||||
{ _id: 'rt4', name: 'Diana', value: 400 },
|
||||
];
|
||||
|
||||
// Import
|
||||
const importReader = createDocumentImportStream(documents);
|
||||
const importWriter = await tableWriter({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
createIfNotExists: true,
|
||||
});
|
||||
await copyStream(importReader, importWriter);
|
||||
|
||||
// Export
|
||||
const exportReader = await tableReader({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
});
|
||||
const exportWriter = createExportStream();
|
||||
await copyStream(exportReader, exportWriter);
|
||||
|
||||
const rows = exportWriter.resultArray.filter(x => !x.__isStreamHeader);
|
||||
expect(rows.length).toBe(4);
|
||||
|
||||
const sortedRows = rows.sort((a, b) => a._id.localeCompare(b._id));
|
||||
for (const doc of documents) {
|
||||
const found = sortedRows.find(r => r._id === doc._id);
|
||||
expect(found).toBeDefined();
|
||||
expect(found.name).toBe(doc.name);
|
||||
expect(found.value).toBe(doc.value);
|
||||
}
|
||||
});
|
||||
|
||||
test('import documents with nested objects', async () => {
|
||||
const documents = [
|
||||
{ _id: 'nest1', name: 'Alice', address: { city: 'Prague', zip: '11000' } },
|
||||
{ _id: 'nest2', name: 'Bob', address: { city: 'Vienna', zip: '1010' } },
|
||||
];
|
||||
|
||||
const reader = createDocumentImportStream(documents);
|
||||
const writer = await tableWriter({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
createIfNotExists: true,
|
||||
});
|
||||
await copyStream(reader, writer);
|
||||
|
||||
const all = await readAll(driver, conn, collectionName);
|
||||
expect(all.rows.length).toBe(2);
|
||||
|
||||
const alice = all.rows.find(r => r.name === 'Alice');
|
||||
expect(alice.address.city).toBe('Prague');
|
||||
expect(alice.address.zip).toBe('11000');
|
||||
});
|
||||
|
||||
test('import many documents', async () => {
|
||||
const documents = [];
|
||||
for (let i = 0; i < 150; i++) {
|
||||
documents.push({ _id: `many${i}`, name: `Name${i}`, index: i });
|
||||
}
|
||||
|
||||
const reader = createDocumentImportStream(documents);
|
||||
const writer = await tableWriter({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
createIfNotExists: true,
|
||||
});
|
||||
await copyStream(reader, writer);
|
||||
|
||||
const result = await driver.readCollection(conn, {
|
||||
pureName: collectionName,
|
||||
countDocuments: true,
|
||||
});
|
||||
expect(result.count).toBe(150);
|
||||
});
|
||||
|
||||
test('export empty collection returns no data rows', async () => {
|
||||
const reader = await tableReader({
|
||||
systemConnection: conn,
|
||||
driver,
|
||||
pureName: collectionName,
|
||||
});
|
||||
const writer = createExportStream();
|
||||
await copyStream(reader, writer);
|
||||
|
||||
const rows = writer.resultArray.filter(x => !x.__isStreamHeader);
|
||||
expect(rows.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -123,5 +123,22 @@ services:
|
||||
retries: 3
|
||||
start_period: 40s
|
||||
|
||||
mongodb:
|
||||
image: mongo:4.0.12
|
||||
restart: always
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- mongo-config:/data/configdb
|
||||
ports:
|
||||
- 27017:27017
|
||||
|
||||
dynamodb:
|
||||
image: amazon/dynamodb-local
|
||||
restart: always
|
||||
ports:
|
||||
- 8000:8000
|
||||
|
||||
volumes:
|
||||
firebird-data:
|
||||
mongo-data:
|
||||
mongo-config:
|
||||
|
||||
@@ -738,6 +738,27 @@ const firebirdEngine = {
|
||||
skipDropReferences: true,
|
||||
};
|
||||
|
||||
/** @type {import('dbgate-types').TestEngineInfo} */
|
||||
const mongoDbEngine = {
|
||||
label: 'MongoDB',
|
||||
connection: {
|
||||
engine: 'mongo@dbgate-plugin-mongo',
|
||||
server: 'localhost',
|
||||
port: 27017,
|
||||
},
|
||||
};
|
||||
|
||||
/** @type {import('dbgate-types').TestEngineInfo} */
|
||||
const dynamoDbEngine = {
|
||||
label: 'DynamoDB',
|
||||
connection: {
|
||||
engine: 'dynamodb@dbgate-plugin-dynamodb',
|
||||
server: 'localhost',
|
||||
port: 8000,
|
||||
authType: 'onpremise',
|
||||
},
|
||||
};
|
||||
|
||||
const enginesOnCi = [
|
||||
// all engines, which would be run on GitHub actions
|
||||
mysqlEngine,
|
||||
@@ -788,3 +809,5 @@ module.exports.libsqlFileEngine = libsqlFileEngine;
|
||||
module.exports.libsqlWsEngine = libsqlWsEngine;
|
||||
module.exports.duckdbEngine = duckdbEngine;
|
||||
module.exports.firebirdEngine = firebirdEngine;
|
||||
module.exports.mongoDbEngine = mongoDbEngine;
|
||||
module.exports.dynamoDbEngine = dynamoDbEngine;
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
const requireEngineDriver = require('dbgate-api/src/utility/requireEngineDriver');
|
||||
const engines = require('./engines');
|
||||
const { mongoDbEngine, dynamoDbEngine } = require('./engines');
|
||||
global.DBGATE_PACKAGES = {
|
||||
'dbgate-tools': require('dbgate-tools'),
|
||||
'dbgate-sqltree': require('dbgate-sqltree'),
|
||||
@@ -9,7 +10,7 @@ global.DBGATE_PACKAGES = {
|
||||
async function connectEngine(engine) {
|
||||
const { connection } = engine;
|
||||
const driver = requireEngineDriver(connection);
|
||||
for (;;) {
|
||||
for (; ;) {
|
||||
try {
|
||||
const conn = await driver.connect(connection);
|
||||
await driver.getVersion(conn);
|
||||
@@ -26,7 +27,8 @@ async function connectEngine(engine) {
|
||||
|
||||
async function run() {
|
||||
await new Promise(resolve => setTimeout(resolve, 10000));
|
||||
await Promise.all(engines.map(engine => connectEngine(engine)));
|
||||
const documentEngines = [mongoDbEngine, dynamoDbEngine];
|
||||
await Promise.all([...engines, ...documentEngines].map(engine => connectEngine(engine)));
|
||||
}
|
||||
|
||||
run();
|
||||
|
||||
+1
-1
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"private": true,
|
||||
"version": "7.1.5",
|
||||
"version": "7.1.6",
|
||||
"name": "dbgate-all",
|
||||
"workspaces": [
|
||||
"packages/*",
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
const { filterName } = require('dbgate-tools');
|
||||
const { filterName, getLogger, extractErrorLogData } = require('dbgate-tools');
|
||||
const logger = getLogger('jsldata');
|
||||
const { jsldir, archivedir } = require('../utility/directories');
|
||||
const fs = require('fs');
|
||||
const path = require('path');
|
||||
const lineReader = require('line-reader');
|
||||
const _ = require('lodash');
|
||||
const { __ } = require('lodash/fp');
|
||||
@@ -149,6 +152,10 @@ module.exports = {
|
||||
|
||||
getRows_meta: true,
|
||||
async getRows({ jslid, offset, limit, filters, sort, formatterFunction }) {
|
||||
const fileName = getJslFileName(jslid);
|
||||
if (!fs.existsSync(fileName)) {
|
||||
return [];
|
||||
}
|
||||
const datastore = await this.ensureDatastore(jslid, formatterFunction);
|
||||
return datastore.getRows(offset, limit, _.isEmpty(filters) ? null : filters, _.isEmpty(sort) ? null : sort);
|
||||
},
|
||||
@@ -159,6 +166,72 @@ module.exports = {
|
||||
return fs.existsSync(fileName);
|
||||
},
|
||||
|
||||
streamRows_meta: {
|
||||
method: 'get',
|
||||
raw: true,
|
||||
},
|
||||
streamRows(req, res) {
|
||||
const { jslid } = req.query;
|
||||
if (!jslid) {
|
||||
res.status(400).json({ apiErrorMessage: 'Missing jslid' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Reject file:// jslids — they resolve to arbitrary server-side paths
|
||||
if (jslid.startsWith('file://')) {
|
||||
res.status(403).json({ apiErrorMessage: 'Forbidden jslid scheme' });
|
||||
return;
|
||||
}
|
||||
|
||||
const fileName = getJslFileName(jslid);
|
||||
|
||||
if (!fs.existsSync(fileName)) {
|
||||
res.status(404).json({ apiErrorMessage: 'File not found' });
|
||||
return;
|
||||
}
|
||||
|
||||
// Dereference symlinks and normalize case (Windows) before the allow-list check.
|
||||
// realpathSync is safe here because existsSync confirmed the file is present.
|
||||
// path.resolve() alone cannot dereference symlinks, so a symlink inside an allowed
|
||||
// root could otherwise point to an arbitrary external path.
|
||||
const normalize = p => (process.platform === 'win32' ? p.toLowerCase() : p);
|
||||
const resolveRoot = r => { try { return fs.realpathSync(r); } catch { return path.resolve(r); } };
|
||||
|
||||
let realFile;
|
||||
try {
|
||||
realFile = fs.realpathSync(fileName);
|
||||
} catch {
|
||||
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
|
||||
return;
|
||||
}
|
||||
|
||||
const allowedRoots = [jsldir(), archivedir()].map(r => normalize(resolveRoot(r)) + path.sep);
|
||||
const isAllowed = allowedRoots.some(root => normalize(realFile).startsWith(root));
|
||||
if (!isAllowed) {
|
||||
logger.warn({ jslid, realFile }, 'DBGM-00000 streamRows rejected path outside allowed roots');
|
||||
res.status(403).json({ apiErrorMessage: 'Forbidden path' });
|
||||
return;
|
||||
}
|
||||
res.setHeader('Content-Type', 'application/x-ndjson');
|
||||
res.setHeader('Cache-Control', 'no-cache');
|
||||
const stream = fs.createReadStream(realFile, 'utf-8');
|
||||
|
||||
req.on('close', () => {
|
||||
stream.destroy();
|
||||
});
|
||||
|
||||
stream.on('error', err => {
|
||||
logger.error(extractErrorLogData(err), 'DBGM-00000 Error streaming JSONL file');
|
||||
if (!res.headersSent) {
|
||||
res.status(500).json({ apiErrorMessage: 'Stream error' });
|
||||
} else {
|
||||
res.end();
|
||||
}
|
||||
});
|
||||
|
||||
stream.pipe(res);
|
||||
},
|
||||
|
||||
getStats_meta: true,
|
||||
getStats({ jslid }) {
|
||||
const file = `${getJslFileName(jslid)}.stats`;
|
||||
|
||||
@@ -12,6 +12,13 @@ import isPlainObject from 'lodash/isPlainObject';
|
||||
import md5 from 'blueimp-md5';
|
||||
|
||||
export const MAX_GRID_TEXT_LENGTH = 1000; // maximum length of text in grid cell, longer text is truncated
|
||||
export const MAX_GRID_BINARY_SIZE = 10000; // maximum binary size (base64 chars or byte count) before showing 'too large' in grid cell
|
||||
|
||||
function formatByteSize(bytes: number): string {
|
||||
if (bytes >= 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
if (bytes >= 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${bytes} B`;
|
||||
}
|
||||
|
||||
export type EditorDataType =
|
||||
| 'null'
|
||||
@@ -329,6 +336,9 @@ export function stringifyCellValue(
|
||||
return { value: `${tag}("${uuidStr}")`, gridStyle: 'valueCellStyle' };
|
||||
}
|
||||
}
|
||||
if (intent === 'gridCellIntent' && value.$binary.base64.length > MAX_GRID_BINARY_SIZE) {
|
||||
return { value: `(Field too large, ${formatByteSize(Math.round(value.$binary.base64.length * 3 / 4))})`, gridStyle: 'nullCellStyle' };
|
||||
}
|
||||
return {
|
||||
value: base64ToHex(value.$binary.base64),
|
||||
gridStyle: 'valueCellStyle',
|
||||
@@ -417,6 +427,14 @@ export function stringifyCellValue(
|
||||
}
|
||||
}
|
||||
|
||||
if (value?.type === 'Buffer' && _isArray(value.data)) {
|
||||
if (intent === 'gridCellIntent') {
|
||||
return value.data.length > MAX_GRID_BINARY_SIZE
|
||||
? { value: `(Field too large, ${formatByteSize(value.data.length)})`, gridStyle: 'nullCellStyle' }
|
||||
: { value: '0x' + arrayToHexString(value.data), gridStyle: 'valueCellStyle' };
|
||||
}
|
||||
}
|
||||
|
||||
if (_isArray(value)) {
|
||||
switch (intent) {
|
||||
case 'gridCellIntent':
|
||||
@@ -545,7 +563,7 @@ export function shouldOpenMultilineDialog(value) {
|
||||
}
|
||||
|
||||
export function isJsonLikeLongString(value) {
|
||||
return _isString(value) && value.length > 100 && value.match(/^\s*\{.*\}\s*$|^\s*\[.*\]\s*$/m);
|
||||
return _isString(value) && value.length > 100 && value.length <= MAX_GRID_BINARY_SIZE && value.match(/^\s*\{.*\}\s*$|^\s*\[.*\]\s*$/m);
|
||||
}
|
||||
|
||||
export function getIconForRedisType(type) {
|
||||
|
||||
@@ -26,6 +26,18 @@
|
||||
onClick: () => getCurrentDataGrid().deepRefresh(),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'dataGrid.fetchAll',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.fetchAll', { defaultMessage: 'Fetch all rows' }),
|
||||
toolbarName: __t('command.datagrid.fetchAll.toolbar', { defaultMessage: 'Fetch all' }),
|
||||
icon: 'icon download',
|
||||
toolbar: true,
|
||||
isRelatedToTab: true,
|
||||
testEnabled: () => getCurrentDataGrid()?.canFetchAll(),
|
||||
onClick: () => getCurrentDataGrid().fetchAll(),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'dataGrid.revertRowChanges',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
@@ -432,6 +444,7 @@
|
||||
import CollapseButton from './CollapseButton.svelte';
|
||||
import GenerateSqlFromDataModal from '../modals/GenerateSqlFromDataModal.svelte';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import FetchAllConfirmModal from '../modals/FetchAllConfirmModal.svelte';
|
||||
import StatusBarTabItem from '../widgets/StatusBarTabItem.svelte';
|
||||
import { findCommand } from '../commands/runCommand';
|
||||
import { openJsonDocument } from '../tabs/JsonTab.svelte';
|
||||
@@ -454,6 +467,7 @@
|
||||
import macros from '../macro/macros';
|
||||
|
||||
export let onLoadNextData = undefined;
|
||||
export let onFetchAllRows = undefined;
|
||||
export let grider = undefined;
|
||||
export let display: GridDisplay = undefined;
|
||||
export let conid = undefined;
|
||||
@@ -473,6 +487,9 @@
|
||||
export let errorMessage = undefined;
|
||||
export let pureName = undefined;
|
||||
export let schemaName = undefined;
|
||||
export let isFetchingAll = false;
|
||||
export let isFetchingFromDb = false;
|
||||
export let fetchAllLoadedCount = 0;
|
||||
export let allowDefineVirtualReferences = false;
|
||||
export let formatterFunction;
|
||||
export let passAllRows = null;
|
||||
@@ -647,6 +664,21 @@
|
||||
return canRefresh() && !!conid && !!database;
|
||||
}
|
||||
|
||||
export function canFetchAll() {
|
||||
return !!onFetchAllRows && !isLoadedAll && !isFetchingAll && !isLoading;
|
||||
}
|
||||
|
||||
export function fetchAll() {
|
||||
if (!canFetchAll()) return;
|
||||
|
||||
const settings = $settingsValue || {};
|
||||
if (settings['dataGrid.skipFetchAllConfirm']) {
|
||||
onFetchAllRows();
|
||||
} else {
|
||||
showModal(FetchAllConfirmModal, { onConfirm: () => onFetchAllRows() });
|
||||
}
|
||||
}
|
||||
|
||||
export async function deepRefresh() {
|
||||
callUnsubscribeDbRefresh();
|
||||
await apiCall('database-connections/sync-model', { conid, database });
|
||||
@@ -1977,6 +2009,7 @@
|
||||
|
||||
registerMenu(
|
||||
{ command: 'dataGrid.refresh' },
|
||||
{ command: 'dataGrid.fetchAll', hideDisabled: true },
|
||||
{ placeTag: 'copy' },
|
||||
{
|
||||
text: _t('datagrid.copyAdvanced', { defaultMessage: 'Copy advanced' }),
|
||||
@@ -2404,11 +2437,7 @@
|
||||
</div>
|
||||
{:else if allRowCountError && multipleGridsOnTab}
|
||||
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||
<div
|
||||
class="row-count-label row-count-error"
|
||||
title={allRowCountError}
|
||||
on:click={onReloadRowCount}
|
||||
>
|
||||
<div class="row-count-label row-count-error" title={allRowCountError} on:click={onReloadRowCount}>
|
||||
{_t('datagrid.rows', { defaultMessage: 'Rows' })}: {_t('datagrid.rowCountMany', { defaultMessage: 'Many' })}
|
||||
</div>
|
||||
{/if}
|
||||
@@ -2417,6 +2446,18 @@
|
||||
<LoadingInfo wrapper message="Loading data" />
|
||||
{/if}
|
||||
|
||||
{#if isFetchingAll}
|
||||
<LoadingInfo
|
||||
wrapper
|
||||
message={isFetchingFromDb
|
||||
? _t('datagrid.fetchAll.progressDb', { defaultMessage: 'Fetching data from database...' })
|
||||
: _t('datagrid.fetchAll.progress', {
|
||||
defaultMessage: 'Fetching all rows... {count} loaded',
|
||||
values: { count: fetchAllLoadedCount.toLocaleString() },
|
||||
})}
|
||||
/>
|
||||
{/if}
|
||||
|
||||
{#if !tabControlHiddenTab && !multipleGridsOnTab && allRowCount != null}
|
||||
<StatusBarTabItem text={`${_t('datagrid.rows', { defaultMessage: 'Rows' })}: ${allRowCount.toLocaleString()}`} />
|
||||
{:else if !tabControlHiddenTab && !multipleGridsOnTab && allRowCountError}
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
<script lang="ts">
|
||||
import { getIntSettingsValue } from '../settings/settingsTools';
|
||||
import { onDestroy } from 'svelte';
|
||||
|
||||
import createRef from '../utility/createRef';
|
||||
import { useSettings } from '../utility/metadataLoaders';
|
||||
import { fetchAll, type FetchAllHandle } from '../utility/fetchAll';
|
||||
import { apiCall } from '../utility/api';
|
||||
|
||||
import DataGridCore from './DataGridCore.svelte';
|
||||
|
||||
export let loadDataPage;
|
||||
export let dataPageAvailable;
|
||||
export let loadRowCount;
|
||||
export let startFetchAll = null;
|
||||
export let grider;
|
||||
export let display;
|
||||
export let masterLoadedTime = undefined;
|
||||
@@ -29,6 +33,12 @@
|
||||
let errorMessage = null;
|
||||
let domGrid;
|
||||
|
||||
let isFetchingAll = false;
|
||||
let isFetchingFromDb = false;
|
||||
let fetchAllLoadedCount = 0;
|
||||
let fetchAllHandle: FetchAllHandle | null = null;
|
||||
let readerJslid: string | null = null;
|
||||
|
||||
const loadNextDataRef = createRef(false);
|
||||
const loadedTimeRef = createRef(null);
|
||||
|
||||
@@ -96,11 +106,161 @@
|
||||
// console.log('LOADED', nextRows, loadedRows);
|
||||
}
|
||||
|
||||
async function fetchAllRows() {
|
||||
if (isFetchingAll || isLoadedAll) return;
|
||||
|
||||
const jslid = ($$props as any).jslid;
|
||||
if (jslid) {
|
||||
// Already have a JSONL file (e.g. query tab) — read directly
|
||||
fetchAllViaJslid(jslid);
|
||||
} else if (startFetchAll) {
|
||||
// SQL/table grid: execute full query → stream to JSONL → read from it
|
||||
fetchAllViaReader();
|
||||
} else {
|
||||
fetchAllRowsLegacy();
|
||||
}
|
||||
}
|
||||
|
||||
function stopReader() {
|
||||
if (readerJslid) {
|
||||
apiCall('sessions/stop-loading-reader', { jslid: readerJslid });
|
||||
readerJslid = null;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchAllViaReader() {
|
||||
isFetchingAll = true;
|
||||
isFetchingFromDb = true;
|
||||
fetchAllLoadedCount = loadedRows.length;
|
||||
errorMessage = null;
|
||||
|
||||
// Token guards against a reload/destroy that happens while we await startFetchAll.
|
||||
// loadedTimeRef is already updated by reload(), so we reuse it as our token.
|
||||
const token = loadedTime;
|
||||
|
||||
let jslid;
|
||||
try {
|
||||
jslid = await startFetchAll($$props);
|
||||
} catch (err) {
|
||||
if (loadedTime !== token) return; // reload() already reset state
|
||||
errorMessage = err?.message ?? 'Failed to start data reader';
|
||||
isFetchingAll = false;
|
||||
isFetchingFromDb = false;
|
||||
return;
|
||||
}
|
||||
|
||||
// If reload()/onDestroy ran while we were awaiting, discard the result and
|
||||
// immediately stop the reader that was just started on the server.
|
||||
if (loadedTime !== token) {
|
||||
if (jslid) apiCall('sessions/stop-loading-reader', { jslid });
|
||||
return;
|
||||
}
|
||||
|
||||
if (!jslid) {
|
||||
errorMessage = 'Failed to start data reader';
|
||||
isFetchingAll = false;
|
||||
isFetchingFromDb = false;
|
||||
return;
|
||||
}
|
||||
|
||||
readerJslid = jslid;
|
||||
fetchAllViaJslid(jslid);
|
||||
}
|
||||
|
||||
function fetchAllViaJslid(jslid: string) {
|
||||
if (!isFetchingAll) {
|
||||
isFetchingAll = true;
|
||||
fetchAllLoadedCount = loadedRows.length;
|
||||
errorMessage = null;
|
||||
}
|
||||
|
||||
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
|
||||
const buffer: any[] = [];
|
||||
|
||||
const jslLoadDataPage = async (offset: number, limit: number) => {
|
||||
return apiCall('jsldata/get-rows', { jslid, offset, limit });
|
||||
};
|
||||
|
||||
fetchAllHandle = fetchAll(
|
||||
jslid,
|
||||
jslLoadDataPage,
|
||||
{
|
||||
onPage(rows) {
|
||||
if (rows.length > 0) isFetchingFromDb = false;
|
||||
const processed = preprocessLoadedRow ? rows.map(preprocessLoadedRow) : rows;
|
||||
buffer.push(...processed);
|
||||
fetchAllLoadedCount = buffer.length;
|
||||
},
|
||||
onFinished() {
|
||||
loadedRows = buffer;
|
||||
isLoadedAll = true;
|
||||
isFetchingAll = false;
|
||||
isFetchingFromDb = false;
|
||||
fetchAllHandle = null;
|
||||
readerJslid = null;
|
||||
if (allRowCount == null && !isRawMode) handleLoadRowCount();
|
||||
},
|
||||
onError(msg) {
|
||||
errorMessage = msg;
|
||||
isFetchingAll = false;
|
||||
isFetchingFromDb = false;
|
||||
fetchAllHandle = null;
|
||||
stopReader();
|
||||
},
|
||||
},
|
||||
pageSize
|
||||
);
|
||||
}
|
||||
|
||||
async function fetchAllRowsLegacy() {
|
||||
isFetchingAll = true;
|
||||
fetchAllLoadedCount = loadedRows.length;
|
||||
errorMessage = null;
|
||||
|
||||
const pageSize = getIntSettingsValue('dataGrid.pageSize', 100, 5, 50000);
|
||||
const fetchStart = new Date().getTime();
|
||||
loadedTimeRef.set(fetchStart);
|
||||
|
||||
// Accumulate into a local buffer to avoid O(n²) full-array copies each iteration.
|
||||
const buffer = [...loadedRows];
|
||||
|
||||
try {
|
||||
while (!isLoadedAll) {
|
||||
const nextRows = await loadDataPage($$props, buffer.length, pageSize);
|
||||
|
||||
if (loadedTimeRef.get() !== fetchStart) {
|
||||
// a reload was triggered; abort without overwriting loadedRows with stale data
|
||||
return;
|
||||
}
|
||||
|
||||
if (nextRows.errorMessage) {
|
||||
errorMessage = nextRows.errorMessage;
|
||||
break;
|
||||
}
|
||||
|
||||
if (nextRows.length === 0) {
|
||||
isLoadedAll = true;
|
||||
break;
|
||||
}
|
||||
|
||||
const processed = preprocessLoadedRow ? nextRows.map(preprocessLoadedRow) : nextRows;
|
||||
buffer.push(...processed);
|
||||
fetchAllLoadedCount = buffer.length;
|
||||
}
|
||||
|
||||
// Single assignment triggers Svelte reactivity once for all accumulated rows.
|
||||
loadedRows = buffer;
|
||||
if (allRowCount == null && !isRawMode) handleLoadRowCount();
|
||||
} finally {
|
||||
isFetchingAll = false;
|
||||
}
|
||||
}
|
||||
|
||||
// $: griderProps = { ...$$props, sourceRows: loadProps.loadedRows };
|
||||
// $: grider = griderFactory(griderProps);
|
||||
|
||||
function handleLoadNextData() {
|
||||
if (!isLoadedAll && !errorMessage && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
|
||||
if (!isLoadedAll && !errorMessage && !isFetchingAll && (!grider.disableLoadNextPage || loadedRows.length == 0)) {
|
||||
if (dataPageAvailable($$props)) {
|
||||
// If not, callbacks to load missing metadata are dispatched
|
||||
loadNextData();
|
||||
@@ -109,14 +269,23 @@
|
||||
}
|
||||
|
||||
function reload() {
|
||||
if (fetchAllHandle) {
|
||||
fetchAllHandle.cancel();
|
||||
fetchAllHandle = null;
|
||||
}
|
||||
stopReader();
|
||||
isFetchingFromDb = false;
|
||||
allRowCount = null;
|
||||
allRowCountError = null;
|
||||
isLoading = false;
|
||||
isFetchingAll = false;
|
||||
fetchAllLoadedCount = 0;
|
||||
loadedRows = [];
|
||||
isLoadedAll = false;
|
||||
loadedTime = new Date().getTime();
|
||||
errorMessage = null;
|
||||
loadNextDataRef.set(false);
|
||||
loadedTimeRef.set(null);
|
||||
// loadNextDataToken = 0;
|
||||
}
|
||||
|
||||
@@ -130,6 +299,13 @@
|
||||
}
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
if (fetchAllHandle) {
|
||||
fetchAllHandle.cancel();
|
||||
}
|
||||
stopReader();
|
||||
});
|
||||
|
||||
$: if (setLoadedRows) setLoadedRows(loadedRows);
|
||||
</script>
|
||||
|
||||
@@ -137,10 +313,14 @@
|
||||
{...$$props}
|
||||
bind:this={domGrid}
|
||||
onLoadNextData={handleLoadNextData}
|
||||
onFetchAllRows={fetchAllRows}
|
||||
{errorMessage}
|
||||
{isLoading}
|
||||
{isFetchingAll}
|
||||
{isFetchingFromDb}
|
||||
{fetchAllLoadedCount}
|
||||
allRowCount={rowCountLoaded || allRowCount}
|
||||
allRowCountError={allRowCountError}
|
||||
{allRowCountError}
|
||||
onReloadRowCount={handleLoadRowCount}
|
||||
{isLoadedAll}
|
||||
{loadedTime}
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
import { getActiveComponent } from '../utility/createActivator';
|
||||
import registerCommand from '../commands/registerCommand';
|
||||
import hasPermission from '../utility/hasPermission';
|
||||
import { __t, _t } from '../translations'
|
||||
import { __t, _t } from '../translations';
|
||||
const getCurrentEditor = () => getActiveComponent('SqlDataGridCore');
|
||||
|
||||
registerCommand({
|
||||
id: 'sqlDataGrid.openQuery',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.openQuery', { defaultMessage : 'Open query' }),
|
||||
name: __t('command.openQuery', { defaultMessage: 'Open query' }),
|
||||
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/query'),
|
||||
onClick: () => getCurrentEditor().openQuery(),
|
||||
});
|
||||
@@ -16,7 +16,7 @@
|
||||
registerCommand({
|
||||
id: 'sqlDataGrid.export',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('common.export', { defaultMessage : 'Export' }),
|
||||
name: __t('common.export', { defaultMessage: 'Export' }),
|
||||
icon: 'icon export',
|
||||
keyText: 'CtrlOrCommand+E',
|
||||
testEnabled: () => getCurrentEditor() != null && hasPermission('dbops/export'),
|
||||
@@ -232,6 +232,20 @@
|
||||
return { errorMessage: err.message || 'Error loading row count' };
|
||||
}
|
||||
}
|
||||
|
||||
async function startFetchAll(props) {
|
||||
const { display, conid, database } = props;
|
||||
const sql = display.getExportQuery();
|
||||
if (!sql) return null;
|
||||
|
||||
const resp = await apiCall('sessions/execute-reader', {
|
||||
conid,
|
||||
database,
|
||||
sql,
|
||||
});
|
||||
if (!resp || resp.errorMessage) return null;
|
||||
return resp.jslid;
|
||||
}
|
||||
</script>
|
||||
|
||||
<LoadingDataGridCore
|
||||
@@ -239,6 +253,7 @@
|
||||
{loadDataPage}
|
||||
{dataPageAvailable}
|
||||
{loadRowCount}
|
||||
{startFetchAll}
|
||||
setLoadedRows={handleSetLoadedRows}
|
||||
onPublishedCellsChanged={value => {
|
||||
publishedCells = value;
|
||||
|
||||
@@ -24,7 +24,7 @@
|
||||
{#if isNative}
|
||||
<select
|
||||
value={options.find(x => x.value == value) ? value : defaultValue}
|
||||
class="{selectClass}"
|
||||
class={selectClass}
|
||||
{...$$restProps}
|
||||
on:change={e => {
|
||||
dispatch('change', e.target['value']);
|
||||
@@ -47,7 +47,7 @@
|
||||
{...$$restProps}
|
||||
items={options ?? []}
|
||||
value={isMulti
|
||||
? _.compact((value && Array.isArray(value)) ? value.map(item => options?.find(x => x.value == item)) : [])
|
||||
? _.compact(value && Array.isArray(value) ? value.map(item => options?.find(x => x.value == item)) : [])
|
||||
: (options?.find(x => x.value == value) ?? null)}
|
||||
on:select={e => {
|
||||
if (isMulti) {
|
||||
@@ -69,7 +69,6 @@
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
|
||||
<style>
|
||||
.select {
|
||||
--border: var(--theme-input-border);
|
||||
@@ -78,10 +77,10 @@
|
||||
--background: var(--theme-input-background);
|
||||
--borderHoverColor: var(--theme-input-border-hover-color);
|
||||
--borderFocusColor: var(--theme-input-border-focus-color);
|
||||
--listBackground: var(--theme-input-list-background);
|
||||
--listBackground: var(--theme-input-background);
|
||||
--itemActiveBackground: var(--theme-input-item-active-background);
|
||||
--itemIsActiveBG: var(--theme-input-item-active-background);
|
||||
--itemHoverBG: var(--theme-input-item-hover-background);
|
||||
--itemHoverBG: var(--theme-input-multi-clear-hover);
|
||||
--itemColor: var(--theme-input-item-foreground);
|
||||
--listEmptyColor: var(--theme-input-background);
|
||||
--height: 40px;
|
||||
@@ -95,9 +94,8 @@
|
||||
--multiClearHoverFill: var(--theme-input-multi-clear-foreground);
|
||||
--multiItemActiveBG: var(--theme-input-multi-item-background);
|
||||
--multiItemActiveColor: var(--theme-input-multi-item-foreground);
|
||||
--multiItemBG: var(--theme-input-multi-item-background);
|
||||
--multiItemBG: var(--theme-input-multi-clear-background);
|
||||
--multiItemDisabledHoverBg: var(--theme-input-multi-item-background);
|
||||
--multiItemDisabledHoverColor: var(--theme-input-multi-item-foreground);
|
||||
}
|
||||
|
||||
</style>
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
<script lang="ts">
|
||||
import FormStyledButton from '../buttons/FormStyledButton.svelte';
|
||||
import FormProvider from '../forms/FormProvider.svelte';
|
||||
import FormSubmit from '../forms/FormSubmit.svelte';
|
||||
import TemplatedCheckboxField from '../forms/TemplatedCheckboxField.svelte';
|
||||
import FontIcon from '../icons/FontIcon.svelte';
|
||||
import ModalBase from './ModalBase.svelte';
|
||||
import { closeCurrentModal } from './modalTools';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { _t } from '../translations';
|
||||
|
||||
export let onConfirm;
|
||||
|
||||
const SKIP_SETTING_KEY = 'dataGrid.skipFetchAllConfirm';
|
||||
|
||||
let dontAskAgain = false;
|
||||
</script>
|
||||
|
||||
<FormProvider>
|
||||
<ModalBase {...$$restProps} data-testid="FetchAllConfirmModal">
|
||||
<svelte:fragment slot="header">
|
||||
{_t('datagrid.fetchAll.title', { defaultMessage: 'Fetch All Rows' })}
|
||||
</svelte:fragment>
|
||||
|
||||
<div class="message">
|
||||
<FontIcon icon="img warn" />
|
||||
<span>
|
||||
{_t('datagrid.fetchAll.warning', {
|
||||
defaultMessage:
|
||||
'This will load all remaining rows into memory. For large tables, this may consume a significant amount of memory and could affect application performance.',
|
||||
})}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="mt-2">
|
||||
<TemplatedCheckboxField
|
||||
label={_t('common.dontAskAgain', { defaultMessage: "Don't ask again" })}
|
||||
templateProps={{ noMargin: true }}
|
||||
checked={dontAskAgain}
|
||||
on:change={e => {
|
||||
dontAskAgain = e.detail;
|
||||
apiCall('config/update-settings', { [SKIP_SETTING_KEY]: e.detail });
|
||||
}}
|
||||
data-testid="FetchAllConfirmModal_dontAskAgain"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<svelte:fragment slot="footer">
|
||||
<FormSubmit
|
||||
value={_t('datagrid.fetchAll.confirm', { defaultMessage: 'Fetch All' })}
|
||||
on:click={() => {
|
||||
closeCurrentModal();
|
||||
onConfirm();
|
||||
}}
|
||||
data-testid="FetchAllConfirmModal_okButton"
|
||||
/>
|
||||
<FormStyledButton
|
||||
type="button"
|
||||
value={_t('common.close', { defaultMessage: 'Close' })}
|
||||
on:click={closeCurrentModal}
|
||||
data-testid="FetchAllConfirmModal_closeButton"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ModalBase>
|
||||
</FormProvider>
|
||||
|
||||
<style>
|
||||
.message {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
</style>
|
||||
@@ -55,6 +55,12 @@
|
||||
defaultMessage: 'Skip confirmation when saving collection data (NoSQL)',
|
||||
})}
|
||||
/>
|
||||
<FormCheckboxField
|
||||
name="dataGrid.skipFetchAllConfirm"
|
||||
label={_t('settings.confirmations.skipFetchAllConfirm', {
|
||||
defaultMessage: 'Skip confirmation when fetching all rows',
|
||||
})}
|
||||
/>
|
||||
</FormValues>
|
||||
</div>
|
||||
|
||||
|
||||
@@ -2,223 +2,230 @@
|
||||
import { getActiveComponent } from '../utility/createActivator';
|
||||
import registerCommand from '../commands/registerCommand';
|
||||
import { __t } from '../translations';
|
||||
const getCurrentEditor = () => getActiveComponent('CollectionDataTab');
|
||||
|
||||
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName'];
|
||||
export const allowAddToFavorites = props => true;
|
||||
export const allowSwitchDatabase = props => true;
|
||||
|
||||
registerCommand({
|
||||
id: 'collectionTable.save',
|
||||
group: 'save',
|
||||
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
|
||||
name: __t('command.collectionData.save', { defaultMessage: 'Save' }),
|
||||
// keyText: 'CtrlOrCommand+S',
|
||||
toolbar: true,
|
||||
isRelatedToTab: true,
|
||||
icon: 'icon save',
|
||||
testEnabled: () => getCurrentEditor()?.canSave(),
|
||||
onClick: () => getCurrentEditor().save(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import App from '../App.svelte';
|
||||
import DataGrid from '../datagrid/DataGrid.svelte';
|
||||
import useGridConfig from '../utility/useGridConfig';
|
||||
import {
|
||||
createChangeSet,
|
||||
createGridCache,
|
||||
CollectionGridDisplay,
|
||||
changeSetContainsChanges,
|
||||
runMacroOnChangeSet,
|
||||
changeSetChangedCount,
|
||||
} from 'dbgate-datalib';
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { writable } from 'svelte/store';
|
||||
import createUndoReducer from '../utility/createUndoReducer';
|
||||
import invalidateCommands from '../commands/invalidateCommands';
|
||||
import CollectionDataGridCore from '../datagrid/CollectionDataGridCore.svelte';
|
||||
import { useCollectionInfo, useConnectionInfo, useSettings } from '../utility/metadataLoaders';
|
||||
import { extensions } from '../stores';
|
||||
import CollectionJsonView from '../formview/CollectionJsonView.svelte';
|
||||
import createActivator from '../utility/createActivator';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
|
||||
import ConfirmNoSqlModal from '../modals/ConfirmNoSqlModal.svelte';
import { registerMenu } from '../utility/contextMenu';
|
||||
import { setContext } from 'svelte';
|
||||
import _ from 'lodash';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
|
||||
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
|
||||
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
|
||||
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
|
||||
import { getBoolSettingsValue } from '../settings/settingsTools';
|
||||
import useEditorData from '../query/useEditorData';
|
||||
import { markTabSaved, markTabUnsaved } from '../utility/common';
|
||||
import { getNumberIcon } from '../icons/FontIcon.svelte';
export let tabid;
|
||||
export let conid;
|
||||
export let database;
|
||||
export let schemaName;
|
||||
export let pureName;
|
||||
|
||||
let loadedRows;
|
||||
|
||||
export const activator = createActivator('CollectionDataTab', true);
|
||||
|
||||
const config = useGridConfig(tabid);
|
||||
const cache = writable(createGridCache());
|
||||
const settingsValue = useSettings();
|
||||
|
||||
const { editorState, editorValue, setEditorData } = useEditorData({
|
||||
tabid,
|
||||
onInitialData: value => {
|
||||
dispatchChangeSet({ type: 'reset', value });
|
||||
invalidateCommands();
|
||||
if (changeSetContainsChanges(value)) {
|
||||
markTabUnsaved(tabid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
|
||||
|
||||
$: {
|
||||
setEditorData($changeSetStore.value);
|
||||
if (changeSetContainsChanges($changeSetStore?.value)) {
|
||||
markTabUnsaved(tabid);
|
||||
} else {
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
$changeSetStore;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
$: collectionInfo = useCollectionInfo({ conid, database, schemaName, pureName });
|
||||
|
||||
$: display =
|
||||
$collectionInfo && $connection
|
||||
? new CollectionGridDisplay(
|
||||
$collectionInfo,
|
||||
findEngineDriver($connection, $extensions),
|
||||
//@ts-ignore
|
||||
$config,
|
||||
config.update,
|
||||
$cache,
|
||||
cache.update,
|
||||
loadedRows,
|
||||
$changeSetStore?.value,
|
||||
$connection?.isReadOnly,
|
||||
$settingsValue
|
||||
)
|
||||
: null;
|
||||
// $: console.log('LOADED ROWS MONGO', loadedRows);
|
||||
|
||||
async function handleConfirmChange(changeSet) {
|
||||
const resp = await apiCall('database-connections/update-collection', {
|
||||
conid,
|
||||
database,
|
||||
changeSet: {
|
||||
...changeSet,
|
||||
updates: changeSet.updates.map(update => ({
|
||||
...update,
|
||||
fields: _.mapValues(update.fields, (v, k) => (v === undefined ? { $$undefined$$: true } : v)),
|
||||
})),
|
||||
},
|
||||
});
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, { title: 'Error when saving', message: errorMessage });
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
display?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function canSave() {
|
||||
return changeSetContainsChanges($changeSetStore?.value);
|
||||
}
|
||||
|
||||
export function save() {
|
||||
const json = $changeSetStore?.value;
|
||||
const driver = findEngineDriver($connection, $extensions);
|
||||
const script = driver.getCollectionUpdateScript ? driver.getCollectionUpdateScript(json, $collectionInfo) : null;
|
||||
if (script) {
|
||||
if (getBoolSettingsValue('skipConfirm.collectionDataSave', false)) {
|
||||
handleConfirmChange(json);
|
||||
} else {
|
||||
showModal(ConfirmNoSqlModal, {
|
||||
script,
|
||||
onConfirm: () => handleConfirmChange(json),
|
||||
engine: display.engine,
|
||||
skipConfirmSettingKey: 'skipConfirm.collectionDataSave',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleConfirmChange(json);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunMacro(macro, params, cells) {
|
||||
const newChangeSet = runMacroOnChangeSet(macro, params, cells, $changeSetStore?.value, display, false);
|
||||
if (newChangeSet) {
|
||||
dispatchChangeSet({ type: 'set', value: newChangeSet });
|
||||
}
|
||||
}
|
||||
|
||||
registerMenu({ command: 'collectionTable.save', tag: 'save' });
|
||||
|
||||
const collapsedLeftColumnStore = writable(getLocalStorage('collection_collapsedLeftColumn', false));
|
||||
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
|
||||
$: setLocalStorage('collection_collapsedLeftColumn', $collapsedLeftColumnStore);
|
||||
|
||||
const quickExportHandlerRef = createQuickExportHandlerRef();
|
||||
|
||||
function handleSetLoadedRows(rows) {
|
||||
loadedRows = rows;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ToolStripContainer>
|
||||
<DataGrid
|
||||
setLoadedRows={handleSetLoadedRows}
|
||||
{...$$props}
|
||||
config={$config}
|
||||
setConfig={config.update}
|
||||
cache={$cache}
|
||||
setCache={cache.update}
|
||||
changeSetState={$changeSetStore}
|
||||
focusOnVisible
|
||||
{display}
|
||||
{changeSetStore}
|
||||
{dispatchChangeSet}
|
||||
gridCoreComponent={CollectionDataGridCore}
|
||||
jsonViewComponent={CollectionJsonView}
|
||||
isDynamicStructure
|
||||
showMacros
|
||||
macroCondition={macro => macro.type == 'transformValue'}
|
||||
onRunMacro={handleRunMacro}
|
||||
/>
|
||||
<svelte:fragment slot="toolstrip">
|
||||
<ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
|
||||
<ToolStripCommandButton command="dataForm.refresh" hideDisabled />
|
||||
<ToolStripCommandButton
|
||||
command="collectionTable.save"
|
||||
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.revertAllChanges" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.deleteSelectedRows" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.addNewColumn" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.switchToJson" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled />
|
||||
<ToolStripExportButton {quickExportHandlerRef} command="collectionDataGrid.export" />
|
||||
<ToolStripCommandButton command="collectionJsonView.expandAll" hideDisabled />
|
||||
<ToolStripCommandButton command="collectionJsonView.collapseAll" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.toggleCellDataView" hideDisabled data-testid="CollectionDataTab_toggleCellDataView" />
|
||||
</svelte:fragment>
|
||||
</ToolStripContainer>
|
||||
const getCurrentEditor = () => getActiveComponent('CollectionDataTab');
|
||||
|
||||
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName'];
|
||||
export const allowAddToFavorites = props => true;
|
||||
export const allowSwitchDatabase = props => true;
|
||||
|
||||
registerCommand({
|
||||
id: 'collectionTable.save',
|
||||
group: 'save',
|
||||
category: __t('command.collectionData', { defaultMessage: 'Collection data' }),
|
||||
name: __t('command.collectionData.save', { defaultMessage: 'Save' }),
|
||||
// keyText: 'CtrlOrCommand+S',
|
||||
toolbar: true,
|
||||
isRelatedToTab: true,
|
||||
icon: 'icon save',
|
||||
testEnabled: () => getCurrentEditor()?.canSave(),
|
||||
onClick: () => getCurrentEditor().save(),
|
||||
});
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import App from '../App.svelte';
|
||||
import DataGrid from '../datagrid/DataGrid.svelte';
|
||||
import useGridConfig from '../utility/useGridConfig';
|
||||
import {
|
||||
createChangeSet,
|
||||
createGridCache,
|
||||
CollectionGridDisplay,
|
||||
changeSetContainsChanges,
|
||||
runMacroOnChangeSet,
|
||||
changeSetChangedCount,
|
||||
} from 'dbgate-datalib';
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { writable } from 'svelte/store';
|
||||
import createUndoReducer from '../utility/createUndoReducer';
|
||||
import invalidateCommands from '../commands/invalidateCommands';
|
||||
import CollectionDataGridCore from '../datagrid/CollectionDataGridCore.svelte';
|
||||
import { useCollectionInfo, useConnectionInfo, useSettings } from '../utility/metadataLoaders';
|
||||
import { extensions } from '../stores';
|
||||
import CollectionJsonView from '../formview/CollectionJsonView.svelte';
|
||||
import createActivator from '../utility/createActivator';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
|
||||
import ConfirmNoSqlModal from '../modals/ConfirmNoSqlModal.svelte';
|
||||
import { registerMenu } from '../utility/contextMenu';
|
||||
import { setContext } from 'svelte';
|
||||
import _ from 'lodash';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
|
||||
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
|
||||
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
|
||||
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
|
||||
import { getBoolSettingsValue } from '../settings/settingsTools';
|
||||
import useEditorData from '../query/useEditorData';
|
||||
import { markTabSaved, markTabUnsaved } from '../utility/common';
|
||||
import { getNumberIcon } from '../icons/FontIcon.svelte';
|
||||
export let tabid;
|
||||
export let conid;
|
||||
export let database;
|
||||
export let schemaName;
|
||||
export let pureName;
|
||||
|
||||
let loadedRows;
|
||||
|
||||
export const activator = createActivator('CollectionDataTab', true);
|
||||
|
||||
const config = useGridConfig(tabid);
|
||||
const cache = writable(createGridCache());
|
||||
const settingsValue = useSettings();
|
||||
|
||||
const { editorState, editorValue, setEditorData } = useEditorData({
|
||||
tabid,
|
||||
onInitialData: value => {
|
||||
dispatchChangeSet({ type: 'reset', value });
|
||||
invalidateCommands();
|
||||
if (changeSetContainsChanges(value)) {
|
||||
markTabUnsaved(tabid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
|
||||
|
||||
$: {
|
||||
setEditorData($changeSetStore.value);
|
||||
if (changeSetContainsChanges($changeSetStore?.value)) {
|
||||
markTabUnsaved(tabid);
|
||||
} else {
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
$changeSetStore;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
$: collectionInfo = useCollectionInfo({ conid, database, schemaName, pureName });
|
||||
|
||||
$: display =
|
||||
$collectionInfo && $connection
|
||||
? new CollectionGridDisplay(
|
||||
$collectionInfo,
|
||||
findEngineDriver($connection, $extensions),
|
||||
//@ts-ignore
|
||||
$config,
|
||||
config.update,
|
||||
$cache,
|
||||
cache.update,
|
||||
loadedRows,
|
||||
$changeSetStore?.value,
|
||||
$connection?.isReadOnly,
|
||||
$settingsValue
|
||||
)
|
||||
: null;
|
||||
// $: console.log('LOADED ROWS MONGO', loadedRows);
|
||||
|
||||
async function handleConfirmChange(changeSet) {
|
||||
const resp = await apiCall('database-connections/update-collection', {
|
||||
conid,
|
||||
database,
|
||||
changeSet: {
|
||||
...changeSet,
|
||||
updates: changeSet.updates.map(update => ({
|
||||
...update,
|
||||
fields: _.mapValues(update.fields, (v, k) => (v === undefined ? { $$undefined$$: true } : v)),
|
||||
})),
|
||||
},
|
||||
});
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, { title: 'Error when saving', message: errorMessage });
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
display?.reload();
|
||||
}
|
||||
}
|
||||
|
||||
export function canSave() {
|
||||
return changeSetContainsChanges($changeSetStore?.value);
|
||||
}
|
||||
|
||||
export function save() {
|
||||
const json = $changeSetStore?.value;
|
||||
const driver = findEngineDriver($connection, $extensions);
|
||||
const script = driver.getCollectionUpdateScript ? driver.getCollectionUpdateScript(json, $collectionInfo) : null;
|
||||
if (script) {
|
||||
if (getBoolSettingsValue('skipConfirm.collectionDataSave', false)) {
|
||||
handleConfirmChange(json);
|
||||
} else {
|
||||
showModal(ConfirmNoSqlModal, {
|
||||
script,
|
||||
onConfirm: () => handleConfirmChange(json),
|
||||
engine: display.engine,
|
||||
skipConfirmSettingKey: 'skipConfirm.collectionDataSave',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
handleConfirmChange(json);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRunMacro(macro, params, cells) {
|
||||
const newChangeSet = runMacroOnChangeSet(macro, params, cells, $changeSetStore?.value, display, false);
|
||||
if (newChangeSet) {
|
||||
dispatchChangeSet({ type: 'set', value: newChangeSet });
|
||||
}
|
||||
}
|
||||
|
||||
registerMenu({ command: 'collectionTable.save', tag: 'save' });
|
||||
|
||||
const collapsedLeftColumnStore = writable(getLocalStorage('collection_collapsedLeftColumn', false));
|
||||
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
|
||||
$: setLocalStorage('collection_collapsedLeftColumn', $collapsedLeftColumnStore);
|
||||
|
||||
const quickExportHandlerRef = createQuickExportHandlerRef();
|
||||
|
||||
function handleSetLoadedRows(rows) {
|
||||
loadedRows = rows;
|
||||
}
|
||||
</script>
|
||||
|
||||
<ToolStripContainer>
|
||||
<DataGrid
|
||||
setLoadedRows={handleSetLoadedRows}
|
||||
{...$$props}
|
||||
config={$config}
|
||||
setConfig={config.update}
|
||||
cache={$cache}
|
||||
setCache={cache.update}
|
||||
changeSetState={$changeSetStore}
|
||||
focusOnVisible
|
||||
{display}
|
||||
{changeSetStore}
|
||||
{dispatchChangeSet}
|
||||
gridCoreComponent={CollectionDataGridCore}
|
||||
jsonViewComponent={CollectionJsonView}
|
||||
isDynamicStructure
|
||||
showMacros
|
||||
macroCondition={macro => macro.type == 'transformValue'}
|
||||
onRunMacro={handleRunMacro}
|
||||
/>
|
||||
<svelte:fragment slot="toolstrip">
|
||||
<ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
|
||||
<ToolStripCommandButton command="dataForm.refresh" hideDisabled />
|
||||
<ToolStripCommandButton
|
||||
command="collectionTable.save"
|
||||
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.revertAllChanges" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.deleteSelectedRows" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.addNewColumn" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.switchToJson" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled />
|
||||
<ToolStripExportButton {quickExportHandlerRef} command="collectionDataGrid.export" />
|
||||
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled />
|
||||
<ToolStripCommandButton command="collectionJsonView.expandAll" hideDisabled />
|
||||
<ToolStripCommandButton command="collectionJsonView.collapseAll" hideDisabled />
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.toggleCellDataView"
|
||||
hideDisabled
|
||||
data-testid="CollectionDataTab_toggleCellDataView"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ToolStripContainer>
|
||||
|
||||
@@ -2,434 +2,436 @@
|
||||
import { getActiveComponent } from '../utility/createActivator';
|
||||
import registerCommand from '../commands/registerCommand';
|
||||
import { __t } from '../translations';
|
||||
const getCurrentEditor = () => getActiveComponent('TableDataTab');
|
||||
const INTERVALS = [5, 10, 15, 30, 60];
|
||||
|
||||
const INTERVAL_COMMANDS = [
|
||||
{
|
||||
time: 5,
|
||||
name: __t('command.datagrid.setAutoRefresh.5', { defaultMessage: 'Refresh every 5 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 10,
|
||||
name: __t('command.datagrid.setAutoRefresh.10', { defaultMessage: 'Refresh every 10 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 15,
|
||||
name: __t('command.datagrid.setAutoRefresh.15', { defaultMessage: 'Refresh every 15 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 30,
|
||||
name: __t('command.datagrid.setAutoRefresh.30', { defaultMessage: 'Refresh every 30 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 60,
|
||||
name: __t('command.datagrid.setAutoRefresh.60', { defaultMessage: 'Refresh every 60 seconds' }),
|
||||
},
|
||||
];
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.save',
|
||||
group: 'save',
|
||||
category: __t('command.tableData', { defaultMessage: 'Table data' }),
|
||||
name: __t('command.tableData.save', { defaultMessage: 'Save' }),
|
||||
// keyText: 'CtrlOrCommand+S',
|
||||
toolbar: true,
|
||||
isRelatedToTab: true,
|
||||
icon: 'icon save',
|
||||
testEnabled: () => getCurrentEditor()?.canSave(),
|
||||
onClick: () => getCurrentEditor().save(),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.setAutoRefresh.1',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.setAutoRefresh.1', { defaultMessage: 'Refresh every 1 second' }),
|
||||
isRelatedToTab: true,
|
||||
testEnabled: () => !!getCurrentEditor(),
|
||||
onClick: () => getCurrentEditor().setAutoRefresh(1),
|
||||
});
|
||||
|
||||
for (const { time, name } of INTERVAL_COMMANDS) {
|
||||
registerCommand({
|
||||
id: `tableData.setAutoRefresh.${time}`,
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name,
|
||||
isRelatedToTab: true,
|
||||
testEnabled: () => !!getCurrentEditor(),
|
||||
onClick: () => getCurrentEditor().setAutoRefresh(time),
|
||||
});
|
||||
}
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.stopAutoRefresh',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.stopAutoRefresh', { defaultMessage: 'Stop auto refresh' }),
|
||||
isRelatedToTab: true,
|
||||
keyText: 'CtrlOrCommand+Shift+R',
|
||||
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === true,
|
||||
onClick: () => getCurrentEditor().stopAutoRefresh(null),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.startAutoRefresh',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.startAutoRefresh', { defaultMessage: 'Start auto refresh' }),
|
||||
isRelatedToTab: true,
|
||||
keyText: 'CtrlOrCommand+Shift+R',
|
||||
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === false,
|
||||
onClick: () => getCurrentEditor().startAutoRefresh(),
|
||||
});
|
||||
|
||||
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName', 'isRawMode'];
|
||||
export const allowAddToFavorites = props => true;
|
||||
export const allowSwitchDatabase = props => true;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import App from '../App.svelte';
|
||||
import TableDataGrid from '../datagrid/TableDataGrid.svelte';
|
||||
import useGridConfig from '../utility/useGridConfig';
|
||||
import {
|
||||
changeSetChangedCount,
|
||||
changeSetContainsChanges,
|
||||
changeSetToSql,
|
||||
createChangeSet,
|
||||
createGridCache,
|
||||
getDeleteCascades,
|
||||
} from 'dbgate-datalib';
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { reloadDataCacheFunc } from 'dbgate-datalib';
|
||||
import { writable } from 'svelte/store';
|
||||
import createUndoReducer from '../utility/createUndoReducer';
|
||||
import invalidateCommands from '../commands/invalidateCommands';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
|
||||
import { getTableInfo, useConnectionInfo, useDatabaseInfo } from '../utility/metadataLoaders';
|
||||
import { scriptToSql } from 'dbgate-sqltree';
|
||||
import { extensions, lastUsedDefaultActions } from '../stores';
|
||||
import ConfirmSqlModal from '../modals/ConfirmSqlModal.svelte';
|
||||
import createActivator from '../utility/createActivator';
import { registerMenu } from '../utility/contextMenu';
|
||||
import { showSnackbarSuccess } from '../utility/snackbar';
|
||||
import openNewTab from '../utility/openNewTab';
|
||||
import { onDestroy, setContext } from 'svelte';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
|
||||
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
|
||||
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
|
||||
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
|
||||
import ToolStripCommandSplitButton from '../buttons/ToolStripCommandSplitButton.svelte';
|
||||
import { getBoolSettingsValue, getIntSettingsValue } from '../settings/settingsTools';
|
||||
import useEditorData from '../query/useEditorData';
|
||||
import { markTabSaved, markTabUnsaved } from '../utility/common';
|
||||
import ToolStripButton from '../buttons/ToolStripButton.svelte';
|
||||
import { getNumberIcon } from '../icons/FontIcon.svelte';
|
||||
import { _t } from '../translations';
|
||||
|
||||
export let tabid;
|
||||
export let conid;
|
||||
export let database;
|
||||
export let schemaName;
|
||||
export let pureName;
|
||||
export let isRawMode = false;
|
||||
export let tabPreviewMode;
|
||||
|
||||
export const activator = createActivator('TableDataTab', true);
|
||||
|
||||
const config = useGridConfig(tabid);
|
||||
const cache = writable(createGridCache());
|
||||
const dbinfo = useDatabaseInfo({ conid, database });
|
||||
|
||||
let autoRefreshInterval = getIntSettingsValue('dataGrid.defaultAutoRefreshInterval', 10, 1, 3600);
|
||||
let autoRefreshStarted = false;
|
||||
let autoRefreshTimer = null;
|
||||
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
|
||||
const { editorState, editorValue, setEditorData } = useEditorData({
|
||||
tabid,
|
||||
onInitialData: value => {
|
||||
dispatchChangeSet({ type: 'reset', value });
|
||||
invalidateCommands();
|
||||
if (changeSetContainsChanges(value)) {
|
||||
markTabUnsaved(tabid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
|
||||
|
||||
$: {
|
||||
setEditorData($changeSetStore.value);
|
||||
if (changeSetContainsChanges($changeSetStore?.value)) {
|
||||
markTabUnsaved(tabid);
|
||||
} else {
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmSql(sql) {
|
||||
const resp = await apiCall('database-connections/run-script', { conid, database, sql, useTransaction: true });
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, {
|
||||
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
|
||||
message: errorMessage,
|
||||
});
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
cache.update(reloadDataCacheFunc);
|
||||
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function save() {
|
||||
const driver = findEngineDriver($connection, $extensions);
|
||||
const tablePermissionRole = (await getTableInfo({ conid, database, schemaName, pureName }))?.tablePermissionRole;
|
||||
|
||||
if (tablePermissionRole == 'create_update_delete' || tablePermissionRole == 'update_only') {
|
||||
const resp = await apiCall('database-connections/save-table-data', {
|
||||
conid,
|
||||
database,
|
||||
changeSet: $changeSetStore?.value,
|
||||
});
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, {
|
||||
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
|
||||
message: errorMessage,
|
||||
});
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
cache.update(reloadDataCacheFunc);
|
||||
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
|
||||
}
|
||||
} else {
|
||||
const script = driver.createSaveChangeSetScript($changeSetStore?.value, $dbinfo, () =>
|
||||
changeSetToSql($changeSetStore?.value, $dbinfo, driver.dialect)
|
||||
);
|
||||
|
||||
const deleteCascades = getDeleteCascades($changeSetStore?.value, $dbinfo);
|
||||
const sql = scriptToSql(driver, script);
|
||||
const deleteCascadesScripts = _.map(deleteCascades, ({ title, commands }) => ({
|
||||
title,
|
||||
script: scriptToSql(driver, commands),
|
||||
}));
|
||||
// console.log('deleteCascadesScripts', deleteCascadesScripts);
|
||||
if (getBoolSettingsValue('skipConfirm.tableDataSave', false) && !deleteCascadesScripts?.length) {
|
||||
handleConfirmSql(sql);
|
||||
} else {
|
||||
showModal(ConfirmSqlModal, {
|
||||
sql,
|
||||
onConfirm: confirmedSql => handleConfirmSql(confirmedSql),
|
||||
engine: driver.engine,
|
||||
deleteCascadesScripts,
|
||||
skipConfirmSettingKey: deleteCascadesScripts?.length ? null : 'skipConfirm.tableDataSave',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canSave() {
|
||||
return changeSetContainsChanges($changeSetStore?.value);
|
||||
}
|
||||
|
||||
export function setAutoRefresh(interval) {
|
||||
autoRefreshInterval = interval;
|
||||
startAutoRefresh();
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
export function isAutoRefresh() {
|
||||
return autoRefreshStarted;
|
||||
}
|
||||
|
||||
export function startAutoRefresh() {
|
||||
closeRefreshTimer();
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
cache.update(reloadDataCacheFunc);
|
||||
}, autoRefreshInterval * 1000);
|
||||
autoRefreshStarted = true;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
export function stopAutoRefresh() {
|
||||
closeRefreshTimer();
|
||||
autoRefreshStarted = false;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
function closeRefreshTimer() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
$changeSetStore;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
registerMenu({ command: 'tableData.save', tag: 'save' });
|
||||
|
||||
const collapsedLeftColumnStore = writable(getLocalStorage('dataGrid_collapsedLeftColumn', false));
|
||||
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
|
||||
$: setLocalStorage('dataGrid_collapsedLeftColumn', $collapsedLeftColumnStore);
|
||||
|
||||
onDestroy(() => {
|
||||
closeRefreshTimer();
|
||||
});
|
||||
|
||||
const quickExportHandlerRef = createQuickExportHandlerRef();
|
||||
|
||||
function createAutoRefreshMenu() {
|
||||
return [
|
||||
{ divider: true },
|
||||
{ command: 'dataGrid.deepRefresh', hideDisabled: true },
|
||||
{ command: 'tableData.stopAutoRefresh', hideDisabled: true },
|
||||
{ command: 'tableData.startAutoRefresh', hideDisabled: true },
|
||||
'tableData.setAutoRefresh.1',
|
||||
...INTERVALS.map(seconds => ({
|
||||
command: `tableData.setAutoRefresh.${seconds}`,
|
||||
text: `...${seconds}` + ' ' + _t('command.datagrid.autoRefresh.seconds', { defaultMessage: 'seconds' }),
|
||||
})),
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<ToolStripContainer>
|
||||
<TableDataGrid
|
||||
{...$$props}
|
||||
config={$config}
|
||||
setConfig={config.update}
|
||||
cache={$cache}
|
||||
setCache={cache.update}
|
||||
changeSetState={$changeSetStore}
|
||||
focusOnVisible
|
||||
{changeSetStore}
|
||||
{dispatchChangeSet}
|
||||
/>
|
||||
|
||||
<svelte:fragment slot="toolstrip">
|
||||
<ToolStripButton
|
||||
icon="icon structure"
|
||||
iconAfter="icon arrow-link"
|
||||
on:click={() => {
|
||||
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
|
||||
lastUsedDefaultActions.update(actions => ({
|
||||
...actions,
|
||||
tables: 'openStructure',
|
||||
}));
|
||||
}
|
||||
|
||||
openNewTab({
|
||||
title: pureName,
|
||||
icon: 'img table-structure',
|
||||
tabComponent: 'TableStructureTab',
|
||||
tabPreviewMode: true,
|
||||
props: {
|
||||
schemaName,
|
||||
pureName,
|
||||
conid,
|
||||
database,
|
||||
objectTypeField: 'tables',
|
||||
defaultActionId: 'openStructure',
|
||||
},
|
||||
});
|
||||
}}>{_t('datagrid.structure', { defaultMessage: 'Structure' })}</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripButton
|
||||
icon="img sql-file"
|
||||
iconAfter="icon arrow-link"
|
||||
on:click={() => {
|
||||
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
|
||||
lastUsedDefaultActions.update(actions => ({
|
||||
...actions,
|
||||
tables: 'showSql',
|
||||
}));
|
||||
}
|
||||
|
||||
openNewTab({
|
||||
title: pureName,
|
||||
icon: 'img sql-file',
|
||||
tabComponent: 'SqlObjectTab',
|
||||
tabPreviewMode: true,
|
||||
props: {
|
||||
schemaName,
|
||||
pureName,
|
||||
conid,
|
||||
database,
|
||||
objectTypeField: 'tables',
|
||||
defaultActionId: 'showSql',
|
||||
},
|
||||
});
|
||||
}}>SQL</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripCommandSplitButton
|
||||
buttonLabel={autoRefreshStarted
|
||||
? _t('tableData.refreshEvery', {
|
||||
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
|
||||
values: { autoRefreshInterval },
|
||||
})
|
||||
: null}
|
||||
commands={['dataGrid.refresh', ...createAutoRefreshMenu()]}
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_refreshGrid"
|
||||
/>
|
||||
<ToolStripCommandSplitButton
|
||||
buttonLabel={autoRefreshStarted
|
||||
? _t('tableData.refreshEvery', {
|
||||
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
|
||||
values: { autoRefreshInterval },
|
||||
})
|
||||
: null}
|
||||
commands={['dataForm.refresh', ...createAutoRefreshMenu()]}
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_refreshForm"
|
||||
/>
|
||||
|
||||
<!-- <ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
|
||||
<ToolStripCommandButton command="dataForm.refresh" hideDisabled /> -->
|
||||
|
||||
<ToolStripCommandButton command="dataForm.goToFirst" hideDisabled data-testid="TableDataTab_goToFirst" />
|
||||
<ToolStripCommandButton command="dataForm.goToPrevious" hideDisabled data-testid="TableDataTab_goToPrevious" />
|
||||
<ToolStripCommandButton command="dataForm.goToNext" hideDisabled data-testid="TableDataTab_goToNext" />
|
||||
<ToolStripCommandButton command="dataForm.goToLast" hideDisabled data-testid="TableDataTab_goToLast" />
|
||||
|
||||
<ToolStripCommandButton
|
||||
command="tableData.save"
|
||||
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
|
||||
data-testid="TableDataTab_save"
|
||||
/>
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.revertAllChanges"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_revertAllChanges"
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled data-testid="TableDataTab_insertNewRow" />
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.deleteSelectedRows"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_deleteSelectedRows"
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.switchToForm" hideDisabled data-testid="TableDataTab_switchToForm" />
|
||||
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled data-testid="TableDataTab_switchToTable" />
|
||||
<ToolStripExportButton {quickExportHandlerRef} />
|
||||
|
||||
<ToolStripButton
|
||||
icon={$collapsedLeftColumnStore ? 'icon columns-outline' : 'icon columns'}
|
||||
on:click={() => collapsedLeftColumnStore.update(x => !x)}
|
||||
>{_t('tableData.viewColumns', { defaultMessage: 'View columns' })}</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.toggleCellDataView"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_toggleCellDataView"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ToolStripContainer>
|
||||
const getCurrentEditor = () => getActiveComponent('TableDataTab');
|
||||
const INTERVALS = [5, 10, 15, 30, 60];
|
||||
|
||||
const INTERVAL_COMMANDS = [
|
||||
{
|
||||
time: 5,
|
||||
name: __t('command.datagrid.setAutoRefresh.5', { defaultMessage: 'Refresh every 5 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 10,
|
||||
name: __t('command.datagrid.setAutoRefresh.10', { defaultMessage: 'Refresh every 10 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 15,
|
||||
name: __t('command.datagrid.setAutoRefresh.15', { defaultMessage: 'Refresh every 15 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 30,
|
||||
name: __t('command.datagrid.setAutoRefresh.30', { defaultMessage: 'Refresh every 30 seconds' }),
|
||||
},
|
||||
{
|
||||
time: 60,
|
||||
name: __t('command.datagrid.setAutoRefresh.60', { defaultMessage: 'Refresh every 60 seconds' }),
|
||||
},
|
||||
];
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.save',
|
||||
group: 'save',
|
||||
category: __t('command.tableData', { defaultMessage: 'Table data' }),
|
||||
name: __t('command.tableData.save', { defaultMessage: 'Save' }),
|
||||
// keyText: 'CtrlOrCommand+S',
|
||||
toolbar: true,
|
||||
isRelatedToTab: true,
|
||||
icon: 'icon save',
|
||||
testEnabled: () => getCurrentEditor()?.canSave(),
|
||||
onClick: () => getCurrentEditor().save(),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.setAutoRefresh.1',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.setAutoRefresh.1', { defaultMessage: 'Refresh every 1 second' }),
|
||||
isRelatedToTab: true,
|
||||
testEnabled: () => !!getCurrentEditor(),
|
||||
onClick: () => getCurrentEditor().setAutoRefresh(1),
|
||||
});
|
||||
|
||||
for (const { time, name } of INTERVAL_COMMANDS) {
|
||||
registerCommand({
|
||||
id: `tableData.setAutoRefresh.${time}`,
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name,
|
||||
isRelatedToTab: true,
|
||||
testEnabled: () => !!getCurrentEditor(),
|
||||
onClick: () => getCurrentEditor().setAutoRefresh(time),
|
||||
});
|
||||
}
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.stopAutoRefresh',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.stopAutoRefresh', { defaultMessage: 'Stop auto refresh' }),
|
||||
isRelatedToTab: true,
|
||||
keyText: 'CtrlOrCommand+Shift+R',
|
||||
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === true,
|
||||
onClick: () => getCurrentEditor().stopAutoRefresh(null),
|
||||
});
|
||||
|
||||
registerCommand({
|
||||
id: 'tableData.startAutoRefresh',
|
||||
category: __t('command.datagrid', { defaultMessage: 'Data grid' }),
|
||||
name: __t('command.datagrid.startAutoRefresh', { defaultMessage: 'Start auto refresh' }),
|
||||
isRelatedToTab: true,
|
||||
keyText: 'CtrlOrCommand+Shift+R',
|
||||
testEnabled: () => getCurrentEditor()?.isAutoRefresh() === false,
|
||||
onClick: () => getCurrentEditor().startAutoRefresh(),
|
||||
});
|
||||
|
||||
export const matchingProps = ['conid', 'database', 'schemaName', 'pureName', 'isRawMode'];
|
||||
export const allowAddToFavorites = props => true;
|
||||
export const allowSwitchDatabase = props => true;
|
||||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import _ from 'lodash';
|
||||
import App from '../App.svelte';
|
||||
import TableDataGrid from '../datagrid/TableDataGrid.svelte';
|
||||
import useGridConfig from '../utility/useGridConfig';
|
||||
import {
|
||||
changeSetChangedCount,
|
||||
changeSetContainsChanges,
|
||||
changeSetToSql,
|
||||
createChangeSet,
|
||||
createGridCache,
|
||||
getDeleteCascades,
|
||||
} from 'dbgate-datalib';
|
||||
import { findEngineDriver } from 'dbgate-tools';
|
||||
import { reloadDataCacheFunc } from 'dbgate-datalib';
|
||||
import { writable } from 'svelte/store';
|
||||
import createUndoReducer from '../utility/createUndoReducer';
|
||||
import invalidateCommands from '../commands/invalidateCommands';
|
||||
import { showModal } from '../modals/modalTools';
|
||||
import ErrorMessageModal from '../modals/ErrorMessageModal.svelte';
|
||||
import { getTableInfo, useConnectionInfo, useDatabaseInfo } from '../utility/metadataLoaders';
|
||||
import { scriptToSql } from 'dbgate-sqltree';
|
||||
import { extensions, lastUsedDefaultActions } from '../stores';
|
||||
import ConfirmSqlModal from '../modals/ConfirmSqlModal.svelte';
|
||||
import createActivator from '../utility/createActivator';
|
||||
import { registerMenu } from '../utility/contextMenu';
|
||||
import { showSnackbarSuccess } from '../utility/snackbar';
|
||||
import openNewTab from '../utility/openNewTab';
|
||||
import { onDestroy, setContext } from 'svelte';
|
||||
import { apiCall } from '../utility/api';
|
||||
import { getLocalStorage, setLocalStorage } from '../utility/storageCache';
|
||||
import ToolStripContainer from '../buttons/ToolStripContainer.svelte';
|
||||
import ToolStripCommandButton from '../buttons/ToolStripCommandButton.svelte';
|
||||
import ToolStripExportButton, { createQuickExportHandlerRef } from '../buttons/ToolStripExportButton.svelte';
|
||||
import ToolStripCommandSplitButton from '../buttons/ToolStripCommandSplitButton.svelte';
|
||||
import { getBoolSettingsValue, getIntSettingsValue } from '../settings/settingsTools';
|
||||
import useEditorData from '../query/useEditorData';
|
||||
import { markTabSaved, markTabUnsaved } from '../utility/common';
|
||||
import ToolStripButton from '../buttons/ToolStripButton.svelte';
|
||||
import { getNumberIcon } from '../icons/FontIcon.svelte';
|
||||
import { _t } from '../translations';
|
||||
|
||||
export let tabid;
|
||||
export let conid;
|
||||
export let database;
|
||||
export let schemaName;
|
||||
export let pureName;
|
||||
export let isRawMode = false;
|
||||
export let tabPreviewMode;
|
||||
|
||||
export const activator = createActivator('TableDataTab', true);
|
||||
|
||||
const config = useGridConfig(tabid);
|
||||
const cache = writable(createGridCache());
|
||||
const dbinfo = useDatabaseInfo({ conid, database });
|
||||
|
||||
let autoRefreshInterval = getIntSettingsValue('dataGrid.defaultAutoRefreshInterval', 10, 1, 3600);
|
||||
let autoRefreshStarted = false;
|
||||
let autoRefreshTimer = null;
|
||||
|
||||
$: connection = useConnectionInfo({ conid });
|
||||
|
||||
const { editorState, editorValue, setEditorData } = useEditorData({
|
||||
tabid,
|
||||
onInitialData: value => {
|
||||
dispatchChangeSet({ type: 'reset', value });
|
||||
invalidateCommands();
|
||||
if (changeSetContainsChanges(value)) {
|
||||
markTabUnsaved(tabid);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [changeSetStore, dispatchChangeSet] = createUndoReducer(createChangeSet());
|
||||
|
||||
$: {
|
||||
setEditorData($changeSetStore.value);
|
||||
if (changeSetContainsChanges($changeSetStore?.value)) {
|
||||
markTabUnsaved(tabid);
|
||||
} else {
|
||||
markTabSaved(tabid);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleConfirmSql(sql) {
|
||||
const resp = await apiCall('database-connections/run-script', { conid, database, sql, useTransaction: true });
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, {
|
||||
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
|
||||
message: errorMessage,
|
||||
});
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
cache.update(reloadDataCacheFunc);
|
||||
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
|
||||
}
|
||||
}
|
||||
|
||||
export async function save() {
|
||||
const driver = findEngineDriver($connection, $extensions);
|
||||
const tablePermissionRole = (await getTableInfo({ conid, database, schemaName, pureName }))?.tablePermissionRole;
|
||||
|
||||
if (tablePermissionRole == 'create_update_delete' || tablePermissionRole == 'update_only') {
|
||||
const resp = await apiCall('database-connections/save-table-data', {
|
||||
conid,
|
||||
database,
|
||||
changeSet: $changeSetStore?.value,
|
||||
});
|
||||
const { errorMessage } = resp || {};
|
||||
if (errorMessage) {
|
||||
showModal(ErrorMessageModal, {
|
||||
title: _t('tableData.errorWhenSaving', { defaultMessage: 'Error when saving' }),
|
||||
message: errorMessage,
|
||||
});
|
||||
} else {
|
||||
dispatchChangeSet({ type: 'reset', value: createChangeSet() });
|
||||
cache.update(reloadDataCacheFunc);
|
||||
showSnackbarSuccess(_t('tableData.savedToDatabase', { defaultMessage: 'Saved to database' }));
|
||||
}
|
||||
} else {
|
||||
const script = driver.createSaveChangeSetScript($changeSetStore?.value, $dbinfo, () =>
|
||||
changeSetToSql($changeSetStore?.value, $dbinfo, driver.dialect)
|
||||
);
|
||||
|
||||
const deleteCascades = getDeleteCascades($changeSetStore?.value, $dbinfo);
|
||||
const sql = scriptToSql(driver, script);
|
||||
const deleteCascadesScripts = _.map(deleteCascades, ({ title, commands }) => ({
|
||||
title,
|
||||
script: scriptToSql(driver, commands),
|
||||
}));
|
||||
// console.log('deleteCascadesScripts', deleteCascadesScripts);
|
||||
if (getBoolSettingsValue('skipConfirm.tableDataSave', false) && !deleteCascadesScripts?.length) {
|
||||
handleConfirmSql(sql);
|
||||
} else {
|
||||
showModal(ConfirmSqlModal, {
|
||||
sql,
|
||||
onConfirm: confirmedSql => handleConfirmSql(confirmedSql),
|
||||
engine: driver.engine,
|
||||
deleteCascadesScripts,
|
||||
skipConfirmSettingKey: deleteCascadesScripts?.length ? null : 'skipConfirm.tableDataSave',
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function canSave() {
|
||||
return changeSetContainsChanges($changeSetStore?.value);
|
||||
}
|
||||
|
||||
export function setAutoRefresh(interval) {
|
||||
autoRefreshInterval = interval;
|
||||
startAutoRefresh();
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
export function isAutoRefresh() {
|
||||
return autoRefreshStarted;
|
||||
}
|
||||
|
||||
export function startAutoRefresh() {
|
||||
closeRefreshTimer();
|
||||
autoRefreshTimer = setInterval(() => {
|
||||
cache.update(reloadDataCacheFunc);
|
||||
}, autoRefreshInterval * 1000);
|
||||
autoRefreshStarted = true;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
export function stopAutoRefresh() {
|
||||
closeRefreshTimer();
|
||||
autoRefreshStarted = false;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
function closeRefreshTimer() {
|
||||
if (autoRefreshTimer) {
|
||||
clearInterval(autoRefreshTimer);
|
||||
autoRefreshTimer = null;
|
||||
}
|
||||
}
|
||||
|
||||
$: {
|
||||
$changeSetStore;
|
||||
invalidateCommands();
|
||||
}
|
||||
|
||||
registerMenu({ command: 'tableData.save', tag: 'save' });
|
||||
|
||||
const collapsedLeftColumnStore = writable(getLocalStorage('dataGrid_collapsedLeftColumn', false));
|
||||
setContext('collapsedLeftColumnStore', collapsedLeftColumnStore);
|
||||
$: setLocalStorage('dataGrid_collapsedLeftColumn', $collapsedLeftColumnStore);
|
||||
|
||||
onDestroy(() => {
|
||||
closeRefreshTimer();
|
||||
});
|
||||
|
||||
const quickExportHandlerRef = createQuickExportHandlerRef();
|
||||
|
||||
function createAutoRefreshMenu() {
|
||||
return [
|
||||
{ divider: true },
|
||||
{ command: 'dataGrid.deepRefresh', hideDisabled: true },
|
||||
{ command: 'tableData.stopAutoRefresh', hideDisabled: true },
|
||||
{ command: 'tableData.startAutoRefresh', hideDisabled: true },
|
||||
'tableData.setAutoRefresh.1',
|
||||
...INTERVALS.map(seconds => ({
|
||||
command: `tableData.setAutoRefresh.${seconds}`,
|
||||
text: `...${seconds}` + ' ' + _t('command.datagrid.autoRefresh.seconds', { defaultMessage: 'seconds' }),
|
||||
})),
|
||||
];
|
||||
}
|
||||
</script>
|
||||
|
||||
<ToolStripContainer>
|
||||
<TableDataGrid
|
||||
{...$$props}
|
||||
config={$config}
|
||||
setConfig={config.update}
|
||||
cache={$cache}
|
||||
setCache={cache.update}
|
||||
changeSetState={$changeSetStore}
|
||||
focusOnVisible
|
||||
{changeSetStore}
|
||||
{dispatchChangeSet}
|
||||
/>
|
||||
|
||||
<svelte:fragment slot="toolstrip">
|
||||
<ToolStripButton
|
||||
icon="icon structure"
|
||||
iconAfter="icon arrow-link"
|
||||
on:click={() => {
|
||||
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
|
||||
lastUsedDefaultActions.update(actions => ({
|
||||
...actions,
|
||||
tables: 'openStructure',
|
||||
}));
|
||||
}
|
||||
|
||||
openNewTab({
|
||||
title: pureName,
|
||||
icon: 'img table-structure',
|
||||
tabComponent: 'TableStructureTab',
|
||||
tabPreviewMode: true,
|
||||
props: {
|
||||
schemaName,
|
||||
pureName,
|
||||
conid,
|
||||
database,
|
||||
objectTypeField: 'tables',
|
||||
defaultActionId: 'openStructure',
|
||||
},
|
||||
});
|
||||
}}>{_t('datagrid.structure', { defaultMessage: 'Structure' })}</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripButton
|
||||
icon="img sql-file"
|
||||
iconAfter="icon arrow-link"
|
||||
on:click={() => {
|
||||
if (tabPreviewMode && getBoolSettingsValue('defaultAction.useLastUsedAction', true)) {
|
||||
lastUsedDefaultActions.update(actions => ({
|
||||
...actions,
|
||||
tables: 'showSql',
|
||||
}));
|
||||
}
|
||||
|
||||
openNewTab({
|
||||
title: pureName,
|
||||
icon: 'img sql-file',
|
||||
tabComponent: 'SqlObjectTab',
|
||||
tabPreviewMode: true,
|
||||
props: {
|
||||
schemaName,
|
||||
pureName,
|
||||
conid,
|
||||
database,
|
||||
objectTypeField: 'tables',
|
||||
defaultActionId: 'showSql',
|
||||
},
|
||||
});
|
||||
}}>SQL</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripCommandSplitButton
|
||||
buttonLabel={autoRefreshStarted
|
||||
? _t('tableData.refreshEvery', {
|
||||
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
|
||||
values: { autoRefreshInterval },
|
||||
})
|
||||
: null}
|
||||
commands={['dataGrid.refresh', ...createAutoRefreshMenu()]}
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_refreshGrid"
|
||||
/>
|
||||
<ToolStripCommandSplitButton
|
||||
buttonLabel={autoRefreshStarted
|
||||
? _t('tableData.refreshEvery', {
|
||||
defaultMessage: 'Refresh (every {autoRefreshInterval}s)',
|
||||
values: { autoRefreshInterval },
|
||||
})
|
||||
: null}
|
||||
commands={['dataForm.refresh', ...createAutoRefreshMenu()]}
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_refreshForm"
|
||||
/>
|
||||
|
||||
<!-- <ToolStripCommandButton command="dataGrid.refresh" hideDisabled />
|
||||
<ToolStripCommandButton command="dataForm.refresh" hideDisabled /> -->
|
||||
|
||||
<ToolStripCommandButton command="dataForm.goToFirst" hideDisabled data-testid="TableDataTab_goToFirst" />
|
||||
<ToolStripCommandButton command="dataForm.goToPrevious" hideDisabled data-testid="TableDataTab_goToPrevious" />
|
||||
<ToolStripCommandButton command="dataForm.goToNext" hideDisabled data-testid="TableDataTab_goToNext" />
|
||||
<ToolStripCommandButton command="dataForm.goToLast" hideDisabled data-testid="TableDataTab_goToLast" />
|
||||
|
||||
<ToolStripCommandButton
|
||||
command="tableData.save"
|
||||
iconAfter={getNumberIcon(changeSetChangedCount($changeSetStore?.value))}
|
||||
data-testid="TableDataTab_save"
|
||||
/>
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.revertAllChanges"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_revertAllChanges"
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.insertNewRow" hideDisabled data-testid="TableDataTab_insertNewRow" />
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.deleteSelectedRows"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_deleteSelectedRows"
|
||||
/>
|
||||
<ToolStripCommandButton command="dataGrid.switchToForm" hideDisabled data-testid="TableDataTab_switchToForm" />
|
||||
<ToolStripCommandButton command="dataGrid.switchToTable" hideDisabled data-testid="TableDataTab_switchToTable" />
|
||||
<ToolStripExportButton {quickExportHandlerRef} />
|
||||
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled data-testid="TableDataTab_fetchAll" />
|
||||
|
||||
<ToolStripButton
|
||||
icon={$collapsedLeftColumnStore ? 'icon columns-outline' : 'icon columns'}
|
||||
on:click={() => collapsedLeftColumnStore.update(x => !x)}
|
||||
>{_t('tableData.viewColumns', { defaultMessage: 'View columns' })}</ToolStripButton
|
||||
>
|
||||
|
||||
<ToolStripCommandButton
|
||||
command="dataGrid.toggleCellDataView"
|
||||
hideDisabled
|
||||
data-testid="TableDataTab_toggleCellDataView"
|
||||
/>
|
||||
</svelte:fragment>
|
||||
</ToolStripContainer>
|
||||
|
||||
@@ -129,6 +129,7 @@
|
||||
|
||||
<ToolStripCommandButton command="dataGrid.refresh" />
|
||||
<ToolStripExportButton {quickExportHandlerRef} />
|
||||
<ToolStripCommandButton command="dataGrid.fetchAll" hideDisabled />
|
||||
<ToolStripCommandButton command="dataGrid.toggleCellDataView" hideDisabled />
|
||||
</svelte:fragment>
|
||||
</ToolStripContainer>
|
||||
|
||||
@@ -98,29 +98,31 @@ const clipboardTextFormatter = (delimiter, headers) => (columns, rows, options)
|
||||
|
||||
const clipboardJsonFormatter = () => (columns, rows) => {
|
||||
return JSON.stringify(
|
||||
rows.map(row => _.pick(row, columns)),
|
||||
rows.map(row => _.omitBy(_.pick(row, columns), _.isUndefined)),
|
||||
undefined,
|
||||
2
|
||||
);
|
||||
};
|
||||
|
||||
const clipboardYamlFormatter = () => (columns, rows) => {
|
||||
return yaml.dump(rows.map(row => _.pick(row, columns)));
|
||||
return yaml.dump(rows.map(row => _.omitBy(_.pick(row, columns), _.isUndefined)));
|
||||
};
|
||||
|
||||
const clipboardJsonLinesFormatter = () => (columns, rows) => {
|
||||
return rows.map(row => JSON.stringify(_.pick(row, columns))).join('\r\n');
|
||||
return rows.map(row => JSON.stringify(_.omitBy(_.pick(row, columns), _.isUndefined))).join('\r\n');
|
||||
};
|
||||
|
||||
const clipboardInsertsFormatter = () => (columns, rows, options) => {
|
||||
const { schemaName, pureName, driver } = options;
|
||||
const dmp = driver.createDumper();
|
||||
for (const row of rows) {
|
||||
const definedColumns = columns.filter(col => row[col] !== undefined);
|
||||
if (definedColumns.length === 0) continue;
|
||||
dmp.putCmd(
|
||||
'^insert ^into %f (%,i) ^values (%,v)',
|
||||
{ schemaName, pureName },
|
||||
columns,
|
||||
columns.map(col => row[col])
|
||||
definedColumns,
|
||||
definedColumns.map(col => row[col])
|
||||
);
|
||||
}
|
||||
return dmp.s;
|
||||
@@ -130,8 +132,10 @@ const clipboardUpdatesFormatter = () => (columns, rows, options) => {
|
||||
const { schemaName, pureName, driver, keyColumns } = options;
|
||||
const dmp = driver.createDumper();
|
||||
for (const row of rows) {
|
||||
const definedColumns = columns.filter(col => row[col] !== undefined);
|
||||
if (definedColumns.length === 0) continue;
|
||||
dmp.put('^update %f ^set ', { schemaName, pureName });
|
||||
dmp.putCollection(', ', columns, col => dmp.put('%i=%v', col, row[col]));
|
||||
dmp.putCollection(', ', definedColumns, col => dmp.put('%i=%v', col, row[col]));
|
||||
dmp.put(' ^where ');
|
||||
dmp.putCollection(' ^and ', keyColumns, col => dmp.put('%i=%v', col, row[col]));
|
||||
dmp.endCommand();
|
||||
@@ -141,7 +145,7 @@ const clipboardUpdatesFormatter = () => (columns, rows, options) => {
|
||||
|
||||
const clipboardMongoInsertFormatter = () => (columns, rows, options) => {
|
||||
const { pureName } = options;
|
||||
return rows.map(row => `db.${pureName}.insert(${JSON.stringify(_.pick(row, columns), undefined, 2)});`).join('\n');
|
||||
return rows.map(row => `db.${pureName}.insert(${JSON.stringify(_.omitBy(_.pick(row, columns), _.isUndefined), undefined, 2)});`).join('\n');
|
||||
};
|
||||
|
||||
export function formatClipboardRows(format, columns, rows, options) {
|
||||
|
||||
@@ -0,0 +1,353 @@
|
||||
import { apiCall, apiOff, apiOn } from './api';
|
||||
import getElectron from './getElectron';
|
||||
import resolveApi, { resolveApiHeaders } from './resolveApi';
|
||||
|
||||
export interface FetchAllCallbacks {
|
||||
/** Called with each page of rows as they arrive. */
|
||||
onPage(rows: object[]): void;
|
||||
/** Called once when all data has been received. */
|
||||
onFinished(): void;
|
||||
/** Called if an error occurs. */
|
||||
onError(message: string): void;
|
||||
}
|
||||
|
||||
export interface FetchAllHandle {
|
||||
/** Signal the loader to stop fetching. */
|
||||
cancel(): void;
|
||||
}
|
||||
|
||||
const STREAM_BATCH_SIZE = 1000;
|
||||
const WEB_PAGE_SIZE = 5000;
|
||||
|
||||
/**
|
||||
* Fetches all rows from a JSONL source.
|
||||
*
|
||||
* Electron: uses paginated `jsldata/get-rows` via IPC (already fast).
|
||||
* Web: waits for source to finish, then streams the entire JSONL file in a
|
||||
* single HTTP request via `jsldata/stream-rows`, parsing lines
|
||||
* progressively with ReadableStream. Falls back to paginated reads
|
||||
* with larger page sizes if streaming is unavailable.
|
||||
*/
|
||||
export function fetchAll(
|
||||
jslid: string,
|
||||
loadDataPage: (offset: number, limit: number) => Promise<any>,
|
||||
callbacks: FetchAllCallbacks,
|
||||
pageSize: number = 100
|
||||
): FetchAllHandle {
|
||||
const isElectron = !!getElectron();
|
||||
|
||||
if (isElectron) {
|
||||
return fetchAllPaginated(jslid, loadDataPage, callbacks, pageSize);
|
||||
} else {
|
||||
return fetchAllWeb(jslid, loadDataPage, callbacks);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Web strategy: listen to SSE stats for progress, once source is finished
|
||||
* stream the entire JSONL in one HTTP request.
|
||||
*/
|
||||
function fetchAllWeb(
|
||||
jslid: string,
|
||||
loadDataPage: (offset: number, limit: number) => Promise<any>,
|
||||
callbacks: FetchAllCallbacks
|
||||
): FetchAllHandle {
|
||||
let cancelled = false;
|
||||
let streamStarted = false;
|
||||
let abortController: AbortController | null = null;
|
||||
let streamReader: ReadableStreamDefaultReader<Uint8Array> | null = null;
|
||||
|
||||
// Initialize cancelFn before registering the SSE handler to avoid TDZ errors
|
||||
// if an immediate stats event triggers fallbackToPaginated() before initialization.
|
||||
let cancelFn = () => {
|
||||
cancelled = true;
|
||||
if (streamReader) {
|
||||
streamReader.cancel().catch(() => {});
|
||||
streamReader = null;
|
||||
}
|
||||
if (abortController) {
|
||||
abortController.abort();
|
||||
abortController = null;
|
||||
}
|
||||
cleanup();
|
||||
};
|
||||
|
||||
const handleStats = (stats: { rowCount: number; changeIndex: number; isFinished: boolean }) => {
|
||||
if (cancelled || streamStarted) return;
|
||||
|
||||
// Report progress while source is still writing
|
||||
if (!stats.isFinished) {
|
||||
callbacks.onPage([]); // trigger UI update with count info
|
||||
return;
|
||||
}
|
||||
|
||||
// Source finished — stream all rows at once
|
||||
streamStarted = true;
|
||||
startStream();
|
||||
};
|
||||
|
||||
apiOn(`jsldata-stats-${jslid}`, handleStats);
|
||||
|
||||
async function startStream() {
|
||||
abortController = new AbortController();
|
||||
try {
|
||||
const resp = await fetch(`${resolveApi()}/jsldata/stream-rows?jslid=${encodeURIComponent(jslid)}`, {
|
||||
method: 'GET',
|
||||
cache: 'no-cache',
|
||||
signal: abortController.signal,
|
||||
headers: {
|
||||
...resolveApiHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!resp.body || resp.status === 404 || resp.status === 405) {
|
||||
// Streaming endpoint not available in this environment — fall back to paginated reads
|
||||
cleanup();
|
||||
fallbackToPaginated();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!resp.ok) {
|
||||
// Non-recoverable server error (e.g. 403 security rejection, 5xx) — surface it
|
||||
callbacks.onError(`HTTP ${resp.status}: ${resp.statusText}`);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
streamReader = resp.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let isFirstLine = true;
|
||||
let batch: any[] = [];
|
||||
|
||||
while (!cancelled) {
|
||||
const { done, value } = await streamReader.read();
|
||||
if (done) break;
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (cancelled) break;
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
if (isFirstLine) {
|
||||
isFirstLine = false;
|
||||
// Check if first line is a header
|
||||
try {
|
||||
const parsed = JSON.parse(trimmed);
|
||||
if (parsed.__isStreamHeader) continue;
|
||||
// Not a header — it's a data row
|
||||
batch.push(parsed);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
batch.push(JSON.parse(trimmed));
|
||||
} catch {
|
||||
// skip malformed lines
|
||||
}
|
||||
if (batch.length >= STREAM_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
callbacks.onPage(batch);
|
||||
batch = [];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Flush the decoder — any bytes held for multi-byte char completion are released
|
||||
const flushed = decoder.decode();
|
||||
if (flushed) buffer += flushed;
|
||||
|
||||
// Process remaining buffer
|
||||
const remainingBuffer = buffer.trim();
|
||||
if (remainingBuffer && !cancelled) {
|
||||
try {
|
||||
const parsed = JSON.parse(remainingBuffer);
|
||||
if (!parsed.__isStreamHeader) {
|
||||
batch.push(parsed);
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
|
||||
if (batch.length > 0 && !cancelled) {
|
||||
callbacks.onPage(batch);
|
||||
}
|
||||
|
||||
if (!cancelled) {
|
||||
callbacks.onFinished();
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
callbacks.onError(err?.message ?? String(err));
|
||||
}
|
||||
} finally {
|
||||
streamReader = null;
|
||||
abortController = null;
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackToPaginated() {
|
||||
const handle = fetchAllPaginated(jslid, loadDataPage, callbacks, WEB_PAGE_SIZE);
|
||||
cancelFn = handle.cancel;
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
apiOff(`jsldata-stats-${jslid}`, handleStats);
|
||||
}
|
||||
|
||||
// Check if data is already finished
|
||||
checkInitialState();
|
||||
|
||||
async function checkInitialState() {
|
||||
try {
|
||||
const stats = await apiCall('jsldata/get-stats', { jslid });
|
||||
if (stats && stats.isFinished && stats.rowCount > 0) {
|
||||
streamStarted = true;
|
||||
startStream();
|
||||
} else if (stats && stats.isFinished && stats.rowCount === 0) {
|
||||
// Source finished with zero rows — no SSE event will follow, finish immediately
|
||||
cleanup();
|
||||
callbacks.onFinished();
|
||||
}
|
||||
// Source still writing or no stats yet — SSE events will trigger stream when done
|
||||
} catch {
|
||||
// Stats not available yet — SSE events will arrive
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
cancelFn();
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Paginated strategy (Electron / fallback): uses `jsldata/get-rows` with
|
||||
* SSE stats events to know when new data is available.
|
||||
*/
|
||||
function fetchAllPaginated(
|
||||
jslid: string,
|
||||
loadDataPage: (offset: number, limit: number) => Promise<any>,
|
||||
callbacks: FetchAllCallbacks,
|
||||
pageSize: number
|
||||
): FetchAllHandle {
|
||||
let cancelled = false;
|
||||
let finished = false;
|
||||
let offset = 0;
|
||||
let isRunning = false;
|
||||
let isSourceFinished = false;
|
||||
let drainRequested = false;
|
||||
|
||||
function finish() {
|
||||
if (finished) return;
|
||||
finished = true;
|
||||
callbacks.onFinished();
|
||||
cleanup();
|
||||
}
|
||||
|
||||
const handleStats = (stats: { rowCount: number; changeIndex: number; isFinished: boolean }) => {
|
||||
isSourceFinished = stats.isFinished;
|
||||
if (stats.rowCount > offset) {
|
||||
scheduleDrain();
|
||||
} else if (stats.isFinished && stats.rowCount === offset) {
|
||||
finish();
|
||||
}
|
||||
};
|
||||
|
||||
function scheduleDrain() {
|
||||
if (isRunning) {
|
||||
drainRequested = true;
|
||||
return;
|
||||
}
|
||||
drain();
|
||||
}
|
||||
|
||||
apiOn(`jsldata-stats-${jslid}`, handleStats);
|
||||
|
||||
async function drain() {
|
||||
if (isRunning || cancelled) return;
|
||||
isRunning = true;
|
||||
drainRequested = false;
|
||||
|
||||
try {
|
||||
while (!cancelled) {
|
||||
const rows = await loadDataPage(offset, pageSize);
|
||||
if (cancelled) break;
|
||||
|
||||
if (rows.errorMessage) {
|
||||
callbacks.onError(rows.errorMessage);
|
||||
cleanup();
|
||||
return;
|
||||
}
|
||||
|
||||
if (rows.length > 0) {
|
||||
offset += rows.length;
|
||||
callbacks.onPage(rows);
|
||||
}
|
||||
|
||||
if (rows.length < pageSize) {
|
||||
if (isSourceFinished) {
|
||||
finish();
|
||||
return;
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
await new Promise(resolve => setTimeout(resolve, 0));
|
||||
}
|
||||
} catch (err) {
|
||||
if (!cancelled) {
|
||||
const msg = err?.message ?? String(err);
|
||||
if (msg.includes('ENOENT')) {
|
||||
// File not ready yet
|
||||
} else {
|
||||
callbacks.onError(msg);
|
||||
cleanup();
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
isRunning = false;
|
||||
if (drainRequested && !cancelled) {
|
||||
scheduleDrain();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function cleanup() {
|
||||
apiOff(`jsldata-stats-${jslid}`, handleStats);
|
||||
}
|
||||
|
||||
checkInitialState();
|
||||
|
||||
async function checkInitialState() {
|
||||
try {
|
||||
const stats = await apiCall('jsldata/get-stats', { jslid });
|
||||
if (stats) {
|
||||
isSourceFinished = stats.isFinished;
|
||||
if (stats.rowCount > 0) {
|
||||
scheduleDrain();
|
||||
} else if (stats.isFinished && !cancelled) {
|
||||
// rowCount === 0: source finished empty — no SSE event will follow
|
||||
finish();
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// Stats not available yet
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
cancel() {
|
||||
cancelled = true;
|
||||
cleanup();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -8,7 +8,7 @@ import {
|
||||
unsubscribeCachePeek,
|
||||
} from './cache';
|
||||
import stableStringify from 'json-stable-stringify';
|
||||
import { derived } from 'svelte/store';
|
||||
import { derived, writable } from 'svelte/store';
|
||||
import { extendDatabaseInfo } from 'dbgate-tools';
|
||||
import { setLocalStorage } from '../utility/storageCache';
|
||||
import { apiCall, apiOff, apiOn } from './api';
|
||||
@@ -190,17 +190,22 @@ const authTypesLoader = ({ engine }) => ({
|
||||
errorValue: null,
|
||||
});
|
||||
|
||||
const publicCloudErrorStore = writable(false);
|
||||
const cloudContentErrorStore = writable(false);
|
||||
|
||||
const publicCloudFilesLoader = () => ({
|
||||
url: 'cloud/public-files',
|
||||
params: {},
|
||||
reloadTrigger: { key: `public-cloud-changed` },
|
||||
errorValue: [],
|
||||
onError: err => publicCloudErrorStore.set(!!err),
|
||||
});
|
||||
const cloudContentListLoader = () => ({
|
||||
url: 'cloud/content-list',
|
||||
params: {},
|
||||
reloadTrigger: { key: `cloud-content-changed` },
|
||||
errorValue: [],
|
||||
onError: err => cloudContentErrorStore.set(!!err),
|
||||
});
|
||||
const teamFilesLoader = () => ({
|
||||
url: 'team-files/list',
|
||||
@@ -226,15 +231,17 @@ const fileThemesLoader = () => ({
|
||||
});
|
||||
|
||||
async function getCore(loader, args) {
|
||||
const { url, params, reloadTrigger, transform, onLoaded, errorValue } = loader(args);
|
||||
const { url, params, reloadTrigger, transform, onLoaded, onError, errorValue } = loader(args);
|
||||
const key = stableStringify({ url, ...params });
|
||||
|
||||
async function doLoad() {
|
||||
const resp = await apiCall(url, params);
|
||||
if (resp?.errorMessage && errorValue !== undefined) {
|
||||
if (onError) onError(resp.errorMessage);
|
||||
if (onLoaded) onLoaded(errorValue);
|
||||
return errorValue;
|
||||
}
|
||||
if (onError) onError(null);
|
||||
const res = (transform || (x => x))(resp);
|
||||
if (onLoaded) onLoaded(res);
|
||||
return res;
|
||||
@@ -557,6 +564,9 @@ export function getPublicCloudFiles(args) {
|
||||
export function usePublicCloudFiles(args = {}) {
|
||||
return useCore(publicCloudFilesLoader, args);
|
||||
}
|
||||
export function usePublicCloudError() {
|
||||
return publicCloudErrorStore;
|
||||
}
|
||||
|
||||
export function getCloudContentList(args) {
|
||||
return getCore(cloudContentListLoader, args);
|
||||
@@ -564,6 +574,9 @@ export function getCloudContentList(args) {
|
||||
export function useCloudContentList(args = {}) {
|
||||
return useCore(cloudContentListLoader, args);
|
||||
}
|
||||
export function useCloudContentError() {
|
||||
return cloudContentErrorStore;
|
||||
}
|
||||
|
||||
export function getTeamFiles(args) {
|
||||
return getCore(teamFilesLoader, args);
|
||||
|
||||
@@ -4,7 +4,12 @@
|
||||
|
||||
import AppObjectList from '../appobj/AppObjectList.svelte';
|
||||
import * as cloudContentAppObject from '../appobj/CloudContentAppObject.svelte';
|
||||
import { useCloudContentList, usePublicCloudFiles, useServerStatus } from '../utility/metadataLoaders';
|
||||
import {
|
||||
useCloudContentList,
|
||||
usePublicCloudFiles,
|
||||
useServerStatus,
|
||||
useCloudContentError,
|
||||
} from '../utility/metadataLoaders';
|
||||
import { _t } from '../translations';
|
||||
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
@@ -47,6 +52,7 @@
|
||||
|
||||
const cloudContentList = useCloudContentList();
|
||||
const serverStatus = useServerStatus();
|
||||
const cloudContentError = useCloudContentError();
|
||||
const cloudContentColorFactory = useCloudContentColorFactory();
|
||||
const connectionColorFactory = useConnectionColorFactory();
|
||||
|
||||
@@ -259,16 +265,19 @@
|
||||
icon="icon plus-thick"
|
||||
menu={createAddItemMenu}
|
||||
title={_t('privateCloudWidget.addNewConnectionOrFile', { defaultMessage: 'Add new connection or file' })}
|
||||
disabled={$cloudContentError}
|
||||
/>
|
||||
<DropDownButton
|
||||
icon="icon add-folder"
|
||||
menu={createAddFolderMenu}
|
||||
title={_t('privateCloudWidget.addNewFolder', { defaultMessage: 'Add new folder' })}
|
||||
disabled={$cloudContentError}
|
||||
/>
|
||||
<InlineButton
|
||||
on:click={handleRefreshContent}
|
||||
title={_t('privateCloudWidget.refreshFiles', { defaultMessage: 'Refresh files' })}
|
||||
data-testid="CloudItemsWidget_buttonRefreshContent"
|
||||
disabled={$cloudContentError}
|
||||
>
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
@@ -300,7 +309,14 @@
|
||||
groupContextMenu={createGroupContextMenu}
|
||||
/>
|
||||
|
||||
{#if !cloudContentFlat?.length}
|
||||
{#if $cloudContentError}
|
||||
<ErrorInfo
|
||||
message={_t('privateCloudWidget.cloudUnavailable', {
|
||||
defaultMessage: 'DbGate Cloud is temporarily unavailable',
|
||||
})}
|
||||
icon="img warn"
|
||||
/>
|
||||
{:else if !cloudContentFlat?.length}
|
||||
<ErrorInfo
|
||||
message={_t('privateCloudWidget.noContent', { defaultMessage: 'You have no content on DbGate cloud' })}
|
||||
icon="img info"
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
|
||||
import AppObjectList from '../appobj/AppObjectList.svelte';
|
||||
import * as publicCloudFileAppObject from '../appobj/PublicCloudFileAppObject.svelte';
|
||||
import { usePublicCloudFiles } from '../utility/metadataLoaders';
|
||||
import { usePublicCloudFiles, usePublicCloudError } from '../utility/metadataLoaders';
|
||||
import { _t } from '../translations';
|
||||
|
||||
import WidgetsInnerContainer from './WidgetsInnerContainer.svelte';
|
||||
@@ -20,6 +20,7 @@
|
||||
let filter = '';
|
||||
|
||||
const publicFiles = usePublicCloudFiles();
|
||||
const publicCloudError = usePublicCloudError();
|
||||
|
||||
function handleRefreshPublic() {
|
||||
refreshPublicCloudFiles(true);
|
||||
@@ -42,6 +43,7 @@
|
||||
on:click={handleRefreshPublic}
|
||||
title={_t('publicCloudWidget.refreshFiles', { defaultMessage: 'Refresh files' })}
|
||||
data-testid="CloudItemsWidget_buttonRefreshPublic"
|
||||
disabled={$publicCloudError}
|
||||
>
|
||||
<FontIcon icon="icon refresh" />
|
||||
</InlineButton>
|
||||
@@ -54,7 +56,14 @@
|
||||
{filter}
|
||||
/>
|
||||
|
||||
{#if !$publicFiles?.length}
|
||||
{#if $publicCloudError}
|
||||
<ErrorInfo
|
||||
message={_t('publicCloudWidget.cloudUnavailable', {
|
||||
defaultMessage: 'DbGate Cloud is temporarily unavailable',
|
||||
})}
|
||||
icon="img warn"
|
||||
/>
|
||||
{:else if !$publicFiles?.length}
|
||||
<ErrorInfo
|
||||
message={_t('publicCloudWidget.noFilesFound', { defaultMessage: 'No files found for your configuration' })}
|
||||
/>
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Přidat nové připojení nebo soubor",
|
||||
"privateCloudWidget.addNewFolder": "Přidat novou složku",
|
||||
"privateCloudWidget.administrateAccess": "Spravovat přístup",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud je dočasně nedostupný",
|
||||
"privateCloudWidget.createConnection": "Vytvořit připojení na DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Vytvořit sdílenou složku",
|
||||
"privateCloudWidget.deleteFolder": "Smazat složku",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Přejmenovat složku",
|
||||
"privateCloudWidget.searchPlaceholder": "Hledat cloudová připojení a soubory",
|
||||
"privateCloudWidget.yourInviteLink": "Váš pozvánkový odkaz (ve tvaru dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud je dočasně nedostupný",
|
||||
"publicCloudWidget.noFilesFound": "Pro vaši konfiguraci nebyly nalezeny žádné soubory",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Jsou zobrazeny pouze soubory relevantní pro vaše připojení, platformu a edici DbGate. Nejprve prosím definujte připojení.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Veřejná znalostní báze",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Neue Verbindung oder Datei hinzufügen",
|
||||
"privateCloudWidget.addNewFolder": "Neuen Ordner hinzufügen",
|
||||
"privateCloudWidget.administrateAccess": "Zugriff verwalten",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud ist vorübergehend nicht verfügbar",
|
||||
"privateCloudWidget.createConnection": "Verbindung in DbGate Cloud erstellen",
|
||||
"privateCloudWidget.createSharedFolder": "Freigegebenen Ordner erstellen",
|
||||
"privateCloudWidget.deleteFolder": "Ordner löschen",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Ordner umbenennen",
|
||||
"privateCloudWidget.searchPlaceholder": "Cloud-Verbindungen und Dateien suchen",
|
||||
"privateCloudWidget.yourInviteLink": "Ihr Einladungslink (im Format dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud ist vorübergehend nicht verfügbar",
|
||||
"publicCloudWidget.noFilesFound": "Keine Dateien für Ihre Konfiguration gefunden",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Es werden nur Dateien aufgelistet, die für Ihre Verbindungen, Plattform und DbGate-Edition relevant sind. Bitte definieren Sie zuerst Verbindungen.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Öffentliche Wissensdatenbank",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Add new connection or file",
|
||||
"privateCloudWidget.addNewFolder": "Add new folder",
|
||||
"privateCloudWidget.administrateAccess": "Administrate access",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud is temporarily unavailable",
|
||||
"privateCloudWidget.createConnection": "Create connection on DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Create shared folder",
|
||||
"privateCloudWidget.deleteFolder": "Delete folder",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Rename folder",
|
||||
"privateCloudWidget.searchPlaceholder": "Search cloud connections and files",
|
||||
"privateCloudWidget.yourInviteLink": "Your invite link (in form dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud is temporarily unavailable",
|
||||
"publicCloudWidget.noFilesFound": "No files found for your configuration",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Only files relevant for your connections, platform and DbGate edition are listed. Please define connections at first.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Public Knowledge Base",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Agregar nueva conexión o archivo",
|
||||
"privateCloudWidget.addNewFolder": "Agregar nueva carpeta",
|
||||
"privateCloudWidget.administrateAccess": "Administrar acceso",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud está temporalmente no disponible",
|
||||
"privateCloudWidget.createConnection": "Crear conexión en DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Crear carpeta compartida",
|
||||
"privateCloudWidget.deleteFolder": "Eliminar carpeta",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Renombrar carpeta",
|
||||
"privateCloudWidget.searchPlaceholder": "Buscar conexiones y archivos en la nube",
|
||||
"privateCloudWidget.yourInviteLink": "Su enlace de invitación (en forma dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud está temporalmente no disponible",
|
||||
"publicCloudWidget.noFilesFound": "No se encontraron archivos para su configuración",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Solo se listan archivos relevantes para sus conexiones, plataforma y edición de DbGate. Defina primero las conexiones.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Base de conocimiento pública",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Ajouter une nouvelle connexion ou un fichier",
|
||||
"privateCloudWidget.addNewFolder": "Ajouter un nouveau dossier",
|
||||
"privateCloudWidget.administrateAccess": "Gérer les accès",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud est temporairement indisponible",
|
||||
"privateCloudWidget.createConnection": "Créer une connexion sur DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Créer un dossier partagé",
|
||||
"privateCloudWidget.deleteFolder": "Supprimer le dossier",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Renommer le dossier",
|
||||
"privateCloudWidget.searchPlaceholder": "Rechercher des connexions et fichiers cloud",
|
||||
"privateCloudWidget.yourInviteLink": "Votre lien d'invitation (sous la forme dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud est temporairement indisponible",
|
||||
"publicCloudWidget.noFilesFound": "Aucun fichier trouvé pour votre configuration",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Seuls les fichiers pertinents pour vos connexions, votre plateforme et votre édition de DbGate sont listés. Veuillez d'abord définir des connexions.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Base de connaissances publique",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Aggiungi nuova connessione o file",
|
||||
"privateCloudWidget.addNewFolder": "Aggiungi nuova cartella",
|
||||
"privateCloudWidget.administrateAccess": "Amministra accesso",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud è temporaneamente non disponibile",
|
||||
"privateCloudWidget.createConnection": "Crea connessione su DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Crea cartella condivisa",
|
||||
"privateCloudWidget.deleteFolder": "Elimina cartella",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Rinomina cartella",
|
||||
"privateCloudWidget.searchPlaceholder": "Cerca connessioni e file cloud",
|
||||
"privateCloudWidget.yourInviteLink": "Il tuo link di invito (nel formato dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud è temporaneamente non disponibile",
|
||||
"publicCloudWidget.noFilesFound": "Nessun file trovato per la tua configurazione",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Sono elencati solo i file rilevanti per le tue connessioni, piattaforma ed edizione DbGate. Definisci prima le connessioni.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Knowledge Base pubblica",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "新しい接続またはファイルを追加",
|
||||
"privateCloudWidget.addNewFolder": "新しいフォルダーを追加",
|
||||
"privateCloudWidget.administrateAccess": "アクセスを管理",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGateクラウドは一時的に利用できません",
|
||||
"privateCloudWidget.createConnection": "DbGateクラウドに接続を作成",
|
||||
"privateCloudWidget.createSharedFolder": "共有フォルダーを作成",
|
||||
"privateCloudWidget.deleteFolder": "フォルダーを削除",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "フォルダー名を変更",
|
||||
"privateCloudWidget.searchPlaceholder": "クラウド接続とファイルを検索",
|
||||
"privateCloudWidget.yourInviteLink": "招待リンク(dbgate://folder/xxx の形式)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGateクラウドは一時的に利用できません",
|
||||
"publicCloudWidget.noFilesFound": "設定に該当するファイルが見つかりません",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "接続、プラットフォーム、DbGateエディションに関連するファイルのみが一覧表示されます。まず接続を定義してください。",
|
||||
"publicCloudWidget.publicKnowledgeBase": "公開ナレッジベース",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "새 연결 또는 파일 추가",
|
||||
"privateCloudWidget.addNewFolder": "새 폴더 추가",
|
||||
"privateCloudWidget.administrateAccess": "접근 권한 관리",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate 클라우드는 일시적으로 사용할 수 없습니다",
|
||||
"privateCloudWidget.createConnection": "DbGate 클라우드에 연결 생성",
|
||||
"privateCloudWidget.createSharedFolder": "공유 폴더 생성",
|
||||
"privateCloudWidget.deleteFolder": "폴더 삭제",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "폴더 이름 변경",
|
||||
"privateCloudWidget.searchPlaceholder": "클라우드 연결 및 파일 검색",
|
||||
"privateCloudWidget.yourInviteLink": "초대 링크(dbgate://folder/xxx 형식)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate 클라우드는 일시적으로 사용할 수 없습니다",
|
||||
"publicCloudWidget.noFilesFound": "구성에 해당하는 파일을 찾을 수 없습니다",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "연결, 플랫폼 및 DbGate 에디션과 관련된 파일만 표시됩니다. 먼저 연결을 정의하세요.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "공개 지식 베이스",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Adicionar nova conexão ou arquivo",
|
||||
"privateCloudWidget.addNewFolder": "Adicionar nova pasta",
|
||||
"privateCloudWidget.administrateAccess": "Administrar acesso",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud está temporariamente indisponível",
|
||||
"privateCloudWidget.createConnection": "Criar conexão no DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Criar pasta compartilhada",
|
||||
"privateCloudWidget.deleteFolder": "Excluir pasta",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Renomear pasta",
|
||||
"privateCloudWidget.searchPlaceholder": "Pesquisar conexões e arquivos na nuvem",
|
||||
"privateCloudWidget.yourInviteLink": "Seu link de convite (no formato dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud está temporariamente indisponível",
|
||||
"publicCloudWidget.noFilesFound": "Nenhum arquivo encontrado para sua configuração",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Apenas arquivos relevantes para suas conexões, plataforma e edição do DbGate são listados. Defina conexões primeiro.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Base de Conhecimento Pública",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "Pridať nové pripojenie alebo súbor",
|
||||
"privateCloudWidget.addNewFolder": "Pridať nový priečinok",
|
||||
"privateCloudWidget.administrateAccess": "Spravovať prístup",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate Cloud je dočasne nedostupný",
|
||||
"privateCloudWidget.createConnection": "Vytvoriť pripojenie na DbGate Cloud",
|
||||
"privateCloudWidget.createSharedFolder": "Vytvoriť zdieľaný priečinok",
|
||||
"privateCloudWidget.deleteFolder": "Odstrániť priečinok",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "Premenovať priečinok",
|
||||
"privateCloudWidget.searchPlaceholder": "Hľadať cloud pripojenia a súbory",
|
||||
"privateCloudWidget.yourInviteLink": "Váš pozývací odkaz (vo forme dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate Cloud je dočasne nedostupný",
|
||||
"publicCloudWidget.noFilesFound": "Pre vašu konfiguráciu neboli nájdené žiadne súbory",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "Sú zobrazené iba súbory relevantné pre vaše pripojenia, platformu a edíciu DbGate. Najprv definujte pripojenia.",
|
||||
"publicCloudWidget.publicKnowledgeBase": "Verejná znalostná báza",
|
||||
|
||||
@@ -1195,6 +1195,7 @@
|
||||
"privateCloudWidget.addNewConnectionOrFile": "添加新连接或文件",
|
||||
"privateCloudWidget.addNewFolder": "添加新文件夹",
|
||||
"privateCloudWidget.administrateAccess": "管理访问权限",
|
||||
"privateCloudWidget.cloudUnavailable": "DbGate 云暂时不可用",
|
||||
"privateCloudWidget.createConnection": "在 DbGate 云上创建连接",
|
||||
"privateCloudWidget.createSharedFolder": "创建共享文件夹",
|
||||
"privateCloudWidget.deleteFolder": "删除文件夹",
|
||||
@@ -1207,6 +1208,7 @@
|
||||
"privateCloudWidget.renameFolder": "重命名文件夹",
|
||||
"privateCloudWidget.searchPlaceholder": "搜索云连接和文件",
|
||||
"privateCloudWidget.yourInviteLink": "您的邀请链接(格式为 dbgate://folder/xxx)",
|
||||
"publicCloudWidget.cloudUnavailable": "DbGate 云暂时不可用",
|
||||
"publicCloudWidget.noFilesFound": "未找到适合您配置的文件",
|
||||
"publicCloudWidget.onlyRelevantFilesListed": "仅列出与您的连接、平台和 DbGate 版本相关的文件。请先定义连接。",
|
||||
"publicCloudWidget.publicKnowledgeBase": "公共知识库",
|
||||
|
||||
@@ -7,7 +7,7 @@ checkout-and-merge-pro:
|
||||
repository: dbgate/dbgate-pro
|
||||
token: ${{ secrets.GH_TOKEN }}
|
||||
path: dbgate-pro
|
||||
ref: 5baa88d0ad253537298e911868579bae0835888d
|
||||
ref: 6b5e2ff831db9baedb2a43862daa4247810b15de
|
||||
- name: Merge dbgate/dbgate-pro
|
||||
run: |
|
||||
mkdir ../dbgate-pro
|
||||
|
||||
@@ -26,30 +26,37 @@ jobs:
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- _include: checkout-and-merge-pro
|
||||
|
||||
- name: yarn install
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
yarn install
|
||||
|
||||
- name: Integration tests
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd integration-tests
|
||||
yarn test:ci
|
||||
|
||||
- name: Filter parser tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/filterparser
|
||||
yarn test:ci
|
||||
|
||||
- name: Datalib (perspective) tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/datalib
|
||||
yarn test:ci
|
||||
|
||||
- name: Tools tests
|
||||
if: always()
|
||||
run: |
|
||||
cd ../dbgate-merged
|
||||
cd packages/tools
|
||||
yarn test:ci
|
||||
|
||||
@@ -138,3 +145,16 @@ jobs:
|
||||
FIREBIRD_USE_LEGACY_AUTH: true
|
||||
ports:
|
||||
- '3050:3050'
|
||||
|
||||
mongodb:
|
||||
image: mongo:4.0.12
|
||||
ports:
|
||||
- '27017:27017'
|
||||
volumes:
|
||||
- mongo-data:/data/db
|
||||
- mongo-config:/data/configdb
|
||||
|
||||
dynamodb:
|
||||
image: amazon/dynamodb-local
|
||||
ports:
|
||||
- '8000:8000'
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
name: SAST
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- master
|
||||
|
||||
jobs:
|
||||
sast:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
container:
|
||||
image: semgrep/semgrep
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
with:
|
||||
fetch-depth: 1
|
||||
|
||||
- name: Clone Apiiro malicious code ruleset
|
||||
run: git clone https://github.com/apiiro/malicious-code-ruleset.git /apiiro-ruleset
|
||||
|
||||
- name: Run Semgrep SAST scan
|
||||
run: semgrep ci --code --config=auto --config /apiiro-ruleset --json > sast-report.json || true
|
||||
|
||||
- name: Upload SAST report
|
||||
uses: actions/upload-artifact@v4
|
||||
with:
|
||||
name: sast-report
|
||||
path: sast-report.json
|
||||
Reference in New Issue
Block a user