mirror of
https://github.com/beekeeper-studio/beekeeper-studio.git
synced 2026-03-13 10:12:54 +08:00
Merge pull request #3580 from beekeeper-studio/rc-54
This commit is contained in:
53
.github/workflows/studio-publish.yml
vendored
53
.github/workflows/studio-publish.yml
vendored
@@ -14,6 +14,9 @@ jobs:
|
||||
assets_url: ${{ steps.create_release.outputs.assets_url }}
|
||||
id: ${{ steps.create_release.outputs.id }}
|
||||
json: ${{ steps.create_release.outputs.json }}
|
||||
azure_client_id: ${{ steps.azure_creds.outputs.client_id }}
|
||||
azure_tenant_id: ${{ steps.azure_creds.outputs.tenant_id }}
|
||||
azure_keyvault_url: ${{ steps.azure_creds.outputs.keyvault_url }}
|
||||
steps:
|
||||
- name: Check out Git repository
|
||||
uses: actions/checkout@v1
|
||||
@@ -31,6 +34,18 @@ jobs:
|
||||
script: |
|
||||
const script = require('./.github/scripts/create_draft_release.js')
|
||||
await script({github, context, core}, process.env.OWNER, process.env.REPO, process.env.TAG_NAME)
|
||||
|
||||
- name: Extract Azure credentials from existing secrets
|
||||
id: azure_creds
|
||||
env:
|
||||
KEYVAULT_AUTH: "${{secrets.keyvault_auth}}"
|
||||
run: |
|
||||
CLIENT_ID=$(echo "$KEYVAULT_AUTH" | jq -r '.id')
|
||||
TENANT_ID=$(echo "$KEYVAULT_AUTH" | jq -r '.tenant')
|
||||
KEYVAULT_URL=$(echo "$KEYVAULT_AUTH" | jq -r '.url')
|
||||
echo "client_id=$CLIENT_ID" >> $GITHUB_OUTPUT
|
||||
echo "tenant_id=$TENANT_ID" >> $GITHUB_OUTPUT
|
||||
echo "keyvault_url=$KEYVAULT_URL" >> $GITHUB_OUTPUT
|
||||
# electron-builder comes built in with channels -- latest, beta, alpha.
|
||||
# To support these for deb, rpm, and snap, we need to extract the channel from the package version
|
||||
# outputs: latest, beta, alpha
|
||||
@@ -99,9 +114,15 @@ jobs:
|
||||
run: bash ./.github/scripts/install-build-deps.sh
|
||||
|
||||
- name: Install azuresigntool
|
||||
run: 'dotnet tool install --global AzureSignTool --version 4.0.1'
|
||||
run: 'dotnet tool install --global AzureSignTool --version 6.0.1'
|
||||
if: matrix.os.type == 'windows'
|
||||
|
||||
- name: Azure Login
|
||||
if: matrix.os.type == 'windows'
|
||||
uses: azure/login@v2
|
||||
with:
|
||||
creds: '{"clientId":"${{ needs.create_draft_release.outputs.azure_client_id }}","clientSecret":"${{ secrets.keyvault_auth_secret }}","subscriptionId":"${{ secrets.azure_subscription_id }}","tenantId":"${{ needs.create_draft_release.outputs.azure_tenant_id }}"}'
|
||||
|
||||
- uses: ruby/setup-ruby@v1
|
||||
with:
|
||||
ruby-version: 3.0.2
|
||||
@@ -247,8 +268,7 @@ jobs:
|
||||
- name: Build & Publish (NT)
|
||||
if: matrix.os.type == 'windows'
|
||||
env:
|
||||
KEYVAULT_AUTH: "${{secrets.keyvault_auth}}"
|
||||
KEYVAULT_AUTH_SECRET: "${{ secrets.keyvault_auth_secret }}"
|
||||
KEYVAULT_URL: "${{ needs.create_draft_release.outputs.azure_keyvault_url }}"
|
||||
KV_WIN_CERTIFICATE: "${{secrets.kv_win_certificate}}"
|
||||
PYTHON_PATH: "${{'$PYTHON_PATH' }}"
|
||||
PYTHONPATH: "${{ '$PYTHONPATH' }}"
|
||||
@@ -374,14 +394,29 @@ jobs:
|
||||
const merge = mergeFiles();
|
||||
fs.writeFileSync('mac.yml', merge, 'utf8');
|
||||
- name: Upload fixed mac yml
|
||||
uses: actions/upload-release-asset@v1
|
||||
uses: actions/github-script@v7
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GH_DEPLOY_TOKEN }}
|
||||
RELEASE_ID: ${{ needs.create_draft_release.outputs.id }}
|
||||
with:
|
||||
upload_url: ${{ needs.create_draft_release.outputs.upload_url}}
|
||||
asset_path: "./mac.yml"
|
||||
asset_name: "latest-mac.yml"
|
||||
asset_content_type: 'application/octet-stream'
|
||||
github-token: ${{ secrets.GH_DEPLOY_TOKEN }}
|
||||
script: |
|
||||
const fs = require('fs');
|
||||
const releaseId = process.env.RELEASE_ID;
|
||||
|
||||
// Read the merged yml file
|
||||
const ymlContent = fs.readFileSync('mac.yml', 'utf8');
|
||||
|
||||
// Upload the asset using the release ID
|
||||
await github.rest.repos.uploadReleaseAsset({
|
||||
owner: 'beekeeper-studio',
|
||||
repo: 'beekeeper-studio',
|
||||
release_id: releaseId,
|
||||
name: 'latest-mac.yml',
|
||||
data: ymlContent,
|
||||
headers: {
|
||||
'content-type': 'application/octet-stream'
|
||||
}
|
||||
});
|
||||
|
||||
publish_repositories:
|
||||
needs: [release, create_draft_release, identify_channel]
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
|
||||
Beekeeper Studio is a cross-platform SQL editor and database manager available for Linux, Mac, and Windows.
|
||||
|
||||
|
||||
[Download Beekeeper Studio](https://beekeeperstudio.io/get-community)
|
||||
|
||||
We publish binaries for MacOS, Windows, and Linux.
|
||||
|
||||
@@ -7,33 +7,27 @@ function isEmpty(value) {
|
||||
exports.default = async function (configuration) {
|
||||
|
||||
const certificate = process.env.KV_WIN_CERTIFICATE;
|
||||
const auth_raw = process.env.KEYVAULT_AUTH;
|
||||
const auth_secret = process.env.KEYVAULT_AUTH_SECRET;
|
||||
const keyvaultUrl = process.env.KEYVAULT_URL;
|
||||
|
||||
// this way we don't have to sign EVERY build
|
||||
if(isEmpty(certificate) || isEmpty(auth_raw)) {
|
||||
console.warn(`build/sign.js: Cannot sign exe, no KV_WIN_CERTIFICATE/KEYVAULT_AUTH provided for ${configuration.path}`);
|
||||
if(isEmpty(certificate) || isEmpty(keyvaultUrl)) {
|
||||
console.warn(`build/sign.js: Cannot sign exe, no KV_WIN_CERTIFICATE/KEYVAULT_URL provided for ${configuration.path}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const keyvault = JSON.parse(auth_raw)
|
||||
const timeserver = "http://timestamp.digicert.com"
|
||||
|
||||
// This took me 2 weeks to figure out.
|
||||
// Hi there Matthew in 2026, hope this still works.
|
||||
|
||||
// Matthew in 2025 here, the secret expired in 24 months, but the cert expired in 36 months
|
||||
// so I've had to patch this with another env variable for the secret. Yuck.
|
||||
// 2026 Matthew -- you should move to Azure's managed keys which expire after like a day.
|
||||
// Don't buy another cert from digisign.
|
||||
// Updated to use Azure managed identity authentication via azure/login action
|
||||
// This uses the token obtained from the azure/login step in the GitHub workflow
|
||||
// The -kvm flag enables managed identity authentication (uses Azure CLI credentials)
|
||||
const command = [
|
||||
'azuresigntool.exe sign -fd sha384',
|
||||
'-kvu', keyvault.url,
|
||||
'-kvi', keyvault.id,
|
||||
'-kvt', keyvault.tenant,
|
||||
'-kvs', auth_secret,
|
||||
'-kvu', keyvaultUrl,
|
||||
'-kvm', // Use managed identity / Azure CLI authentication
|
||||
'-kvc', certificate,
|
||||
"-tr", timeserver,
|
||||
'-td', 'sha384',
|
||||
'--max-degree-of-parallelism', '1',
|
||||
'-v'
|
||||
]
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "beekeeper-studio",
|
||||
"version": "5.4.0",
|
||||
"version": "5.4.9",
|
||||
"private": true,
|
||||
"description": "An easy-to use SQL query editor and database UI for Mac, Windows, and Linux",
|
||||
"author": {
|
||||
|
||||
@@ -43,7 +43,6 @@ export interface SurrealDBFunctionInfo {
|
||||
permissions: boolean
|
||||
}
|
||||
|
||||
// TODO (@day): are both of these necessary?
|
||||
export interface SurrealDBResult {
|
||||
result: any[];
|
||||
status: string;
|
||||
@@ -89,7 +88,8 @@ export class SurrealDBClient extends BasicDatabaseClient<SurrealDBQueryResult> {
|
||||
namespace: this.database.namespace,
|
||||
database: this.db,
|
||||
auth: config,
|
||||
reconnect: true
|
||||
reconnect: true,
|
||||
versionCheck: false
|
||||
});
|
||||
|
||||
// Test the pool
|
||||
@@ -117,8 +117,9 @@ export class SurrealDBClient extends BasicDatabaseClient<SurrealDBQueryResult> {
|
||||
port = this.server.config.localPort;
|
||||
}
|
||||
|
||||
const portString = port ? `:${port}` : '';
|
||||
const protocol = surrealDbOptions?.protocol || 'wss';
|
||||
this.connectionString = `${protocol}://${host || 'localhost'}:${port || 8000}/rpc`;
|
||||
this.connectionString = `${protocol}://${host || 'localhost'}${portString}/rpc`;
|
||||
|
||||
switch (surrealDbOptions.authType) {
|
||||
case SurrealAuthType.Root:
|
||||
|
||||
@@ -1,8 +1,9 @@
|
||||
import Surreal, { ConnectionStatus, ConnectOptions } from "surrealdb";
|
||||
import Surreal, { AnyAuth, ConnectionStatus, ConnectOptions, Token } from "surrealdb";
|
||||
import rawLog from "@bksLogger";
|
||||
import { uuidv4 } from "@/lib/uuid";
|
||||
import ws from "ws";
|
||||
import BksConfig from "@/common/bksConfig";
|
||||
import _ from "lodash"
|
||||
|
||||
// HACK (@day): this is so websockets can work in a node process (smh surreal)
|
||||
// @ts-ignore
|
||||
@@ -28,6 +29,9 @@ export class SurrealConn extends Surreal {
|
||||
|
||||
export class SurrealPool {
|
||||
config: ConnectOptions;
|
||||
database: { namespace?: string | null, database?: string | null }
|
||||
auth: AnyAuth;
|
||||
token: Token;
|
||||
connectionString: string;
|
||||
maxSize: number;
|
||||
pool: SurrealConn[] = [];
|
||||
@@ -35,6 +39,15 @@ export class SurrealPool {
|
||||
|
||||
constructor(url: string, config: ConnectOptions, maxSize = 8) {
|
||||
this.connectionString = url;
|
||||
|
||||
this.database = _.pick(config, "namespace", "database");
|
||||
config = _.omit(config, "namespace", "database")
|
||||
if (typeof config.auth !== 'string') {
|
||||
this.auth = config.auth;
|
||||
} else {
|
||||
this.token = config.auth
|
||||
}
|
||||
config = _.omit(config, "auth")
|
||||
this.config = config;
|
||||
this.maxSize = maxSize;
|
||||
}
|
||||
@@ -51,7 +64,19 @@ export class SurrealPool {
|
||||
if (this.pool.length < this.maxSize) {
|
||||
const newConn = new SurrealConn(this);
|
||||
log.info('Acquiring new connection', newConn.id);
|
||||
log.info('CONFIG: ', this.config)
|
||||
await newConn.connect(this.connectionString, this.config);
|
||||
log.info("Connected")
|
||||
newConn.info
|
||||
await newConn.use(this.database);
|
||||
log.info("Used", this.database)
|
||||
if (this.auth) {
|
||||
log.info("Signing in", this.auth)
|
||||
await newConn.signin(this.auth);
|
||||
} else {
|
||||
log.info("Authenticating: ", this.token)
|
||||
await newConn.authenticate(this.token)
|
||||
}
|
||||
await newConn.ready;
|
||||
|
||||
this.pool.push(newConn);
|
||||
|
||||
@@ -2,11 +2,15 @@ import { ValueTransformer } from 'typeorm';
|
||||
import Encryptor, { SimpleEncryptor } from 'simple-encryptor'
|
||||
import { AzureAuthOptions } from '../models/saved_connection';
|
||||
import { SurrealDBOptions } from '@/lib/db/types';
|
||||
import _ from 'lodash'
|
||||
import rawLog from '@bksLogger'
|
||||
|
||||
const log = rawLog.scope("Transformers")
|
||||
|
||||
|
||||
export class EncryptTransformer implements ValueTransformer {
|
||||
private encryptor: SimpleEncryptor
|
||||
|
||||
|
||||
constructor(key: string) {
|
||||
this.encryptor = Encryptor(key)
|
||||
}
|
||||
@@ -29,11 +33,12 @@ export class SurrealDbEncryptTransformer implements ValueTransformer {
|
||||
}
|
||||
|
||||
to(value: SurrealDBOptions): SurrealDBOptions {
|
||||
if (value?.token) {
|
||||
value.token = this.encryptor.encrypt(value.token);
|
||||
const newVal = _.cloneDeep(value)
|
||||
if (newVal?.token) {
|
||||
newVal.token = this.encryptor.encrypt(value.token);
|
||||
}
|
||||
|
||||
return value;
|
||||
return newVal;
|
||||
}
|
||||
|
||||
from(value: SurrealDBOptions): SurrealDBOptions {
|
||||
@@ -54,15 +59,16 @@ export class AzureCredsEncryptTransformer implements ValueTransformer {
|
||||
}
|
||||
|
||||
to(value: AzureAuthOptions): AzureAuthOptions {
|
||||
if (value?.tenantId) {
|
||||
value.tenantId = this.encryptor.encrypt(value.tenantId);
|
||||
const newVal = _.cloneDeep(value);
|
||||
if (newVal?.tenantId) {
|
||||
newVal.tenantId = this.encryptor.encrypt(newVal.tenantId);
|
||||
}
|
||||
|
||||
if (value?.clientSecret) {
|
||||
value.clientSecret = this.encryptor.encrypt(value.clientSecret);
|
||||
if (newVal?.clientSecret) {
|
||||
newVal.clientSecret = this.encryptor.encrypt(newVal.clientSecret);
|
||||
}
|
||||
|
||||
return value;
|
||||
return newVal;
|
||||
}
|
||||
|
||||
from(value: AzureAuthOptions): AzureAuthOptions {
|
||||
|
||||
@@ -174,7 +174,7 @@ export default Vue.extend({
|
||||
return this.$plugin.buildUrlFor(this.tab.context.pluginId, tabType.entry);
|
||||
},
|
||||
shouldInitialize() {
|
||||
return this.active && !this.initialized;
|
||||
return !this.isCommunity && this.active && !this.initialized;
|
||||
},
|
||||
errors() {
|
||||
return this.error ? [this.error] : null;
|
||||
@@ -199,8 +199,11 @@ export default Vue.extend({
|
||||
},
|
||||
},
|
||||
watch: {
|
||||
shouldInitialize() {
|
||||
if (this.shouldInitialize) this.initialize();
|
||||
async shouldInitialize() {
|
||||
if (this.shouldInitialize) {
|
||||
await this.$nextTick();
|
||||
this.initialize();
|
||||
}
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
@@ -235,6 +238,14 @@ export default Vue.extend({
|
||||
this.$nextTick(() => {
|
||||
this.tableHeight = this.$refs.bottomPanel.clientHeight;
|
||||
});
|
||||
|
||||
if (this.containerResizeObserver) {
|
||||
this.containerResizeObserver.disconnect();
|
||||
}
|
||||
this.containerResizeObserver = new ResizeObserver(() => {
|
||||
this.tableHeight = this.$refs.bottomPanel?.clientHeight || 0;
|
||||
});
|
||||
this.containerResizeObserver.observe(this.$refs.container);
|
||||
},
|
||||
download(format) {
|
||||
this.$refs.table.download(format)
|
||||
@@ -336,11 +347,6 @@ export default Vue.extend({
|
||||
await this.$nextTick();
|
||||
this.initialize();
|
||||
}
|
||||
|
||||
this.containerResizeObserver = new ResizeObserver(() => {
|
||||
this.tableHeight = this.$refs.bottomPanel?.clientHeight || 0;
|
||||
});
|
||||
this.containerResizeObserver.observe(this.$refs.container);
|
||||
},
|
||||
beforeDestroy() {
|
||||
if (this.split) {
|
||||
|
||||
@@ -30,6 +30,15 @@
|
||||
</div>
|
||||
|
||||
<!-- Authentication -->
|
||||
<div class="form-group">
|
||||
<label for="user">User (optional)</label>
|
||||
<masked-input
|
||||
v-model="config.username"
|
||||
:privacy-mode="privacyMode"
|
||||
placeholder="username"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group form-group-password">
|
||||
<label for="password">Password (optional)</label>
|
||||
<input
|
||||
|
||||
@@ -49,7 +49,7 @@
|
||||
</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="row gutter">
|
||||
<div v-if="!this.isTokenAuth" class="row gutter">
|
||||
<div class="form-group col s6">
|
||||
<label for="user">User</label>
|
||||
<masked-input
|
||||
@@ -67,7 +67,21 @@
|
||||
>
|
||||
<i
|
||||
@click.prevent="togglePassword"
|
||||
class="material-icons password"
|
||||
class="material-icons password-icon"
|
||||
>{{ togglePasswordIcon }}</i>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="row gutter">
|
||||
<div class="form-group col">
|
||||
<label for="token">Token</label>
|
||||
<input
|
||||
:type="togglePasswordInputType"
|
||||
v-model="config.surrealDbOptions.token"
|
||||
class="password form-control"
|
||||
>
|
||||
<i
|
||||
@click.prevent="togglePassword"
|
||||
class="material-icons password-icon"
|
||||
>{{ togglePasswordIcon }}</i>
|
||||
</div>
|
||||
</div>
|
||||
@@ -109,7 +123,7 @@ export default Vue.extend({
|
||||
data() {
|
||||
return {
|
||||
authTypes: SurrealAuthTypes,
|
||||
authType: null, // maybe we should just default to null?
|
||||
authType: null,
|
||||
protocols: ['http', 'https', 'ws', 'wss'],
|
||||
showPassword: false
|
||||
}
|
||||
@@ -121,6 +135,9 @@ export default Vue.extend({
|
||||
},
|
||||
togglePasswordInputType() {
|
||||
return this.showPassword ? "text" : "password"
|
||||
},
|
||||
isTokenAuth() {
|
||||
return this.config.surrealDbOptions.authType === SurrealAuthType.Token;
|
||||
}
|
||||
},
|
||||
methods: {
|
||||
|
||||
@@ -276,11 +276,9 @@ export default Vue.extend({
|
||||
field: 'extra',
|
||||
tooltip: true,
|
||||
headerTooltip: 'eg AUTO_INCREMENT',
|
||||
editable: this.isCellEditable.bind(this, 'alterColumn'),
|
||||
editable: false,
|
||||
cssClass: this.customColumnCssClass('alterColumn'),
|
||||
formatter: this.cellFormatter,
|
||||
cellEdited: this.cellEdited,
|
||||
editor: vueEditor(NullableInputEditorVue),
|
||||
minWidth: 90,
|
||||
}),
|
||||
(this.disabledFeatures?.comments ? null : {
|
||||
|
||||
@@ -42,7 +42,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
database: IDbConnectionDatabase;
|
||||
client: bq.BigQuery;
|
||||
config: any = {};
|
||||
|
||||
|
||||
constructor(server: IDbConnectionServer, database: IDbConnectionDatabase) {
|
||||
super(null, bigqueryContext, server, database);
|
||||
}
|
||||
@@ -56,11 +56,11 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
}
|
||||
|
||||
async supportedFeatures(): Promise<SupportedFeatures> {
|
||||
return {
|
||||
customRoutines: false,
|
||||
comments: false,
|
||||
properties: true,
|
||||
partitions: false,
|
||||
return {
|
||||
customRoutines: false,
|
||||
comments: false,
|
||||
properties: true,
|
||||
partitions: false,
|
||||
editPartitions: false,
|
||||
backups: false,
|
||||
backDirFormat: false,
|
||||
@@ -86,7 +86,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
|
||||
log.debug("configDatabase config: ", this.config)
|
||||
|
||||
|
||||
|
||||
this.knex = knexlib({
|
||||
client: BigQueryKnexClient as Client,
|
||||
connection: { ...this.config }
|
||||
@@ -190,7 +190,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
onDelete: row.delete_rule,
|
||||
isComposite: false
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
async query(queryText: string, options: any = {}): Promise<CancelableQuery> {
|
||||
log.debug('bigQuery query: ' + queryText);
|
||||
@@ -209,7 +209,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
[job] = await this.client.createQueryJob(jobOptions)
|
||||
log.debug("created job: ", job.id)
|
||||
|
||||
if (options.dryRun) {
|
||||
if (options?.dryRun) {
|
||||
const metadata = job.metadata;
|
||||
return [this.parseDryRunMetadata(metadata)];
|
||||
}
|
||||
@@ -246,7 +246,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
// if (queryText instanceof String) {
|
||||
// queryText = { query: queries }
|
||||
// }
|
||||
let job = options.job;
|
||||
let job = options?.job;
|
||||
log.info("BIGQUERY, executing", queryText)
|
||||
if (!job) {
|
||||
[job] = await this.client.createQueryJob({query: queryText})
|
||||
@@ -508,7 +508,7 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
const queryArgs = {query: q, ...options };
|
||||
if (!job) {
|
||||
[job] = await this.client.createQueryJob(queryArgs);
|
||||
}
|
||||
}
|
||||
|
||||
// Wait for the query to finish
|
||||
const results = await job.getQueryResults();
|
||||
@@ -538,10 +538,10 @@ export class BigQueryClient extends BasicDatabaseClient<BigQueryResult> {
|
||||
log.debug(`listTablesOrViews for type:${type} data: `, data);
|
||||
return data;
|
||||
}
|
||||
|
||||
|
||||
// wtf typescript
|
||||
// eslint-disable-next-line
|
||||
// @ts-ignore
|
||||
// @ts-ignore
|
||||
private parseDryRunMetadata(metadata) {
|
||||
const queryStatistics = metadata.statistics.query;
|
||||
// bytes -> TiB * bq price per TiB processed
|
||||
|
||||
@@ -439,6 +439,7 @@ export class MysqlClient extends BasicDatabaseClient<ResultType> {
|
||||
_schema?: string,
|
||||
connection?: Connection
|
||||
): Promise<ExtendedTableColumn[]> {
|
||||
const hasGeneratedSupport = !isVersionLessThanOrEqual(this.versionInfo, { major: 5, minor: 7, patch: 5 });
|
||||
const clause = table ? `AND table_name = ?` : "";
|
||||
const sql = `
|
||||
SELECT
|
||||
@@ -450,6 +451,9 @@ export class MysqlClient extends BasicDatabaseClient<ResultType> {
|
||||
column_default as 'column_default',
|
||||
ordinal_position as 'ordinal_position',
|
||||
COLUMN_COMMENT as 'column_comment',
|
||||
CHARACTER_SET_NAME as 'character_set',
|
||||
COLLATION_NAME as 'collation',
|
||||
${hasGeneratedSupport ? "GENERATION_EXPRESSION as 'generation_expression'," : ''}
|
||||
extra as 'extra'
|
||||
FROM information_schema.columns
|
||||
WHERE table_schema = database()
|
||||
@@ -475,6 +479,9 @@ export class MysqlClient extends BasicDatabaseClient<ResultType> {
|
||||
hasDefault: this.hasDefaultValue(this.resolveDefault(row.column_default), _.isEmpty(row.extra) ? null : row.extra),
|
||||
comment: _.isEmpty(row.column_comment) ? null : row.column_comment,
|
||||
generated: /^(STORED|VIRTUAL) GENERATED$/.test(row.extra || ""),
|
||||
generationExpression: row.generation_expression,
|
||||
characterSet: row.character_set,
|
||||
collation: row.collation,
|
||||
bksField: this.parseTableColumn(row),
|
||||
}));
|
||||
}
|
||||
@@ -727,13 +734,13 @@ export class MysqlClient extends BasicDatabaseClient<ResultType> {
|
||||
const params = [table];
|
||||
|
||||
const { rows } = await this.driverExecuteSingle(sql, { params });
|
||||
|
||||
|
||||
// Group by constraint name to identify composite keys
|
||||
const groupedKeys = _.groupBy(rows, 'constraint_name');
|
||||
|
||||
|
||||
return Object.keys(groupedKeys).map(constraintName => {
|
||||
const keyParts = groupedKeys[constraintName];
|
||||
|
||||
|
||||
// If there's only one part, return a simple key (backward compatibility)
|
||||
if (keyParts.length === 1) {
|
||||
const row = keyParts[0];
|
||||
@@ -751,8 +758,8 @@ export class MysqlClient extends BasicDatabaseClient<ResultType> {
|
||||
fromSchema: "",
|
||||
isComposite: false,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// If there are multiple parts, it's a composite key
|
||||
const firstPart = keyParts[0];
|
||||
return {
|
||||
|
||||
@@ -85,6 +85,7 @@ export class RedisClient extends BasicDatabaseClient<RedisQueryResult> {
|
||||
const config: RedisOptions = {
|
||||
host: this.server.config.host || "localhost",
|
||||
port: this.server.config.port || 6379,
|
||||
username: this.server.config.user || undefined,
|
||||
password: this.server.config.password || "",
|
||||
db: parseInt(this.database.database, 10) || 0,
|
||||
lazyConnect: true, // needed to return promise
|
||||
|
||||
@@ -100,6 +100,9 @@ export interface ExtendedTableColumn extends SchemaItem {
|
||||
tableName: string
|
||||
hasDefault?: boolean
|
||||
generated?: boolean
|
||||
generationExpression?: string
|
||||
characterSet?: string
|
||||
collation?: string
|
||||
array?: boolean
|
||||
bksField: BksField
|
||||
}
|
||||
|
||||
@@ -134,9 +134,11 @@ export const SurrealAuthTypes = [
|
||||
{ name: 'Root', value: SurrealAuthType.Root },
|
||||
{ name: 'Namespace', value: SurrealAuthType.Namespace },
|
||||
{ name: 'Database', value: SurrealAuthType.Database },
|
||||
{ name: 'Record Access', value: SurrealAuthType.RecordAccess },
|
||||
// NOTE (@day): disabling for now, as will take a bit more work
|
||||
// { name: 'Record Access', value: SurrealAuthType.RecordAccess },
|
||||
{ name: 'Token', value: SurrealAuthType.Token },
|
||||
{ name: 'Anonymous', value: SurrealAuthType.Anonymous }
|
||||
// NOTE (@day): this doesn't seem to do anything? Won't be able to access tables or query data
|
||||
// { name: 'Anonymous', value: SurrealAuthType.Anonymous }
|
||||
];
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import _ from 'lodash'
|
||||
import CodeMirror from 'codemirror'
|
||||
import { Version } from '@/common/version'
|
||||
import { ExtendedTableColumn } from '@/lib/db/models'
|
||||
|
||||
const communityDialects = ['postgresql', 'sqlite', 'sqlserver', 'mysql', 'redshift', 'bigquery', 'redis'] as const
|
||||
const ultimateDialects = ['oracle', 'cassandra', 'firebird', 'clickhouse', 'mongodb', 'duckdb', 'sqlanywhere', 'surrealdb', 'trino'] as const
|
||||
@@ -9,7 +10,7 @@ export const Dialects = [...communityDialects, ...ultimateDialects] as const
|
||||
|
||||
interface ImportDefaultDataTypes {
|
||||
stringType?: string
|
||||
longStringType?: string
|
||||
longStringType?: string
|
||||
dateType?: string
|
||||
booleanType?: string
|
||||
integerType?: string
|
||||
@@ -275,7 +276,7 @@ export interface AlterTableSpec {
|
||||
alterations?: SchemaItemChange[]
|
||||
adds?: SchemaItem[]
|
||||
drops?: string[]
|
||||
reorder? : { newOrder: SchemaItem[], oldOrder: SchemaItem[] } | null
|
||||
reorder? : { newOrder: ExtendedTableColumn[], oldOrder: ExtendedTableColumn[] } | null
|
||||
}
|
||||
|
||||
export interface PartitionExpressionChange {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { TableColumn } from "@/lib/db/models";
|
||||
import { ExtendedTableColumn } from "@/lib/db/models";
|
||||
import { getDialectData } from "@shared/lib/dialects";
|
||||
import { AlterTableSpec, CreateIndexSpec, CreateRelationSpec, Dialect, DialectData, DropIndexSpec, SchemaItem, SchemaItemChange } from "@shared/lib/dialects/models";
|
||||
import _ from "lodash";
|
||||
@@ -169,8 +169,6 @@ export abstract class ChangeBuilderBase {
|
||||
const reorderColumns = spec.reorder ? this.reorderColumns(spec.reorder.oldOrder, spec.reorder.newOrder) : null
|
||||
let alterTable = alterations.length ? `${beginning} ${alterations.join(", ")}` : null
|
||||
|
||||
console.log('{{reorderColumns}}')
|
||||
console.log(reorderColumns)
|
||||
// some dbs (SQLITE) don't support multiple operations in a single ALTER
|
||||
if (this.multiStatementMode) {
|
||||
alterTable = alterations.map((a) => {
|
||||
@@ -250,8 +248,8 @@ export abstract class ChangeBuilderBase {
|
||||
return specs.map((spec) => this.singleRelation(spec)).join(";")
|
||||
}
|
||||
|
||||
// shouldn't be abstract because not all clients support reordering a column
|
||||
reorderColumns(_oldColumnOrder: TableColumn[], _newColumnOrder: TableColumn[]): string {
|
||||
// shouldn't be abstract because not all clients support reordering a column
|
||||
reorderColumns(_oldColumnOrder: ExtendedTableColumn[], _newColumnOrder: ExtendedTableColumn[]): string {
|
||||
throw new Error('reorderColumns must be added via a subclass')
|
||||
}
|
||||
|
||||
|
||||
@@ -2,27 +2,32 @@ import { ChangeBuilderBase } from "@shared/lib/sql/change_builder/ChangeBuilderB
|
||||
import { MysqlData } from "@shared/lib/dialects/mysql";
|
||||
import { CreateIndexSpec, Dialect, DropIndexSpec, SchemaItem, SchemaItemChange } from "@shared/lib/dialects/models";
|
||||
import _ from 'lodash'
|
||||
import { TableColumn } from "@/lib/db/models";
|
||||
import { ExtendedTableColumn } from "@/lib/db/models";
|
||||
import rawLog from '@bksLogger';
|
||||
|
||||
const log = rawLog.scope('MysqlChangeBuilder')
|
||||
|
||||
export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
dialect: Dialect = 'mysql'
|
||||
existingColumns: SchemaItem[]
|
||||
existingColumns: ExtendedTableColumn[]
|
||||
wrapIdentifier = MysqlData.wrapIdentifier
|
||||
wrapLiteral = MysqlData.wrapLiteral
|
||||
escapeString = MysqlData.escapeString
|
||||
|
||||
constructor(table: string, existingColumns: SchemaItem[]) {
|
||||
constructor(table: string, existingColumns: ExtendedTableColumn[]) {
|
||||
super(table)
|
||||
this.existingColumns = existingColumns
|
||||
}
|
||||
|
||||
defaultValue(defaultValue) {
|
||||
|
||||
defaultValue(defaultValue, isGenerated: boolean = false) {
|
||||
// MySQL is a cluster when it comes to default values.
|
||||
if (!defaultValue) return null
|
||||
if (defaultValue === 'CURRENT_TIMESTAMP') return defaultValue
|
||||
if (defaultValue.toString().startsWith('(')) return defaultValue
|
||||
// string, already quoted
|
||||
if (defaultValue.startsWith("'")) return this.wrapLiteral(defaultValue)
|
||||
// is a generated expression, so don't quote
|
||||
if (isGenerated) return `(${defaultValue.replace(/\\'/g, "'")})`;
|
||||
// string, not quoted.
|
||||
return this.escapeString(defaultValue.toString(), true);
|
||||
}
|
||||
@@ -62,7 +67,7 @@ export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
}).join(';')
|
||||
}
|
||||
|
||||
ddl(existing: SchemaItem, updated: SchemaItem): string {
|
||||
ddl(existing: ExtendedTableColumn, updated: SchemaItem): string {
|
||||
const column = existing.columnName
|
||||
const newName = updated.columnName
|
||||
const nameChanged = column !== newName
|
||||
@@ -71,17 +76,70 @@ export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
// mysql 8 allows literal values PLUS expressions like ('foo')
|
||||
// https://dev.mysql.com/doc/refman/8.0/en/data-type-defaults.html
|
||||
// it's very confusing.
|
||||
let characterSet = null;
|
||||
if (existing.characterSet) {
|
||||
characterSet = `CHARACTER SET ${existing.characterSet}`;
|
||||
}
|
||||
|
||||
return [
|
||||
let collation = null;
|
||||
if (existing.collation) {
|
||||
collation = `COLLATE ${existing.collation}`;
|
||||
}
|
||||
|
||||
let extra = updated.extra;
|
||||
let generationExpression = null;
|
||||
let defaultValue = updated.defaultValue;
|
||||
let defaultGenerated = false;
|
||||
|
||||
if (existing.generated && existing.generationExpression) {
|
||||
const isStored = /STORED GENERATED/gi.test(extra);
|
||||
// MySQL stores generation expressions with escaped quotes, we need to unescape them
|
||||
let unescapedExpression = existing.generationExpression.replace(/\\'/g, "'");
|
||||
|
||||
generationExpression = `GENERATED ALWAYS AS (${unescapedExpression}) ${isStored ? 'STORED' : 'VIRTUAL'}`;
|
||||
extra = null;
|
||||
defaultValue = null; // Generated columns cannot have explicit default values
|
||||
} else if (extra) {
|
||||
const expr = /DEFAULT_GENERATED/gi;
|
||||
if (expr.test(extra)) {
|
||||
extra = extra.replace(expr, '').replace(/\s+/g, ' ').trim();
|
||||
defaultGenerated = true;
|
||||
}
|
||||
if (!extra) extra = null;
|
||||
}
|
||||
|
||||
// For generated columns, we should not include NULL/NOT NULL after the generation expression
|
||||
// The correct order is: data type, charset, collation, generation expression, then comment
|
||||
const parts = [
|
||||
nameChanged ? `CHANGE` : 'MODIFY',
|
||||
this.wrapIdentifier(column),
|
||||
nameChanged ? this.wrapIdentifier(newName) : null,
|
||||
updated.dataType,
|
||||
updated.defaultValue ? `DEFAULT ${this.defaultValue(updated.defaultValue)}` : null,
|
||||
updated.nullable ? 'NULL' : 'NOT NULL',
|
||||
updated.extra,
|
||||
updated.comment ? `COMMENT ${this.escapeString(updated.comment, true)}` : null,
|
||||
].filter((c) => !!c).join(" ")
|
||||
characterSet,
|
||||
collation,
|
||||
];
|
||||
|
||||
if (generationExpression) {
|
||||
// For generated columns: just the generation expression and comment
|
||||
parts.push(generationExpression);
|
||||
if (updated.comment) {
|
||||
parts.push(`COMMENT ${this.escapeString(updated.comment, true)}`);
|
||||
}
|
||||
} else {
|
||||
// For regular columns: default, null/not null, extra, comment
|
||||
if (defaultValue) {
|
||||
parts.push(`DEFAULT ${this.defaultValue(defaultValue, defaultGenerated)}`);
|
||||
}
|
||||
parts.push(updated.nullable ? 'NULL' : 'NOT NULL');
|
||||
if (extra) {
|
||||
parts.push(extra);
|
||||
}
|
||||
if (updated.comment) {
|
||||
parts.push(`COMMENT ${this.escapeString(updated.comment, true)}`);
|
||||
}
|
||||
}
|
||||
|
||||
return parts.filter((c) => !!c).join(" ")
|
||||
}
|
||||
|
||||
getExisting(column: string) {
|
||||
@@ -92,7 +150,7 @@ export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
return c
|
||||
}
|
||||
|
||||
buildUpdatedSchema(existing: SchemaItem, specs: SchemaItemChange[]) {
|
||||
buildUpdatedSchema(existing: ExtendedTableColumn, specs: SchemaItemChange[]) {
|
||||
let result = { ...existing }
|
||||
specs.forEach((spec) => {
|
||||
if (spec.changeType === 'columnName') result = { ...result, columnName: spec.newValue.toString()}
|
||||
@@ -124,19 +182,63 @@ export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
return []
|
||||
}
|
||||
|
||||
reorderColumns(oldColumnOrder: TableColumn[], newColumnOrder: TableColumn[]): string {
|
||||
reorderColumns(oldColumnOrder: ExtendedTableColumn[], newColumnOrder: ExtendedTableColumn[]): string {
|
||||
log.info("COLUMN ORDER: ", oldColumnOrder, newColumnOrder)
|
||||
const newOrder = newColumnOrder.reduce((acc, NCO, index, arr) => {
|
||||
if ( oldColumnOrder.length < index + 1) return acc
|
||||
const { columnName, dataType } = NCO
|
||||
const { columnName, dataType, nullable, defaultValue, extra, generated, generationExpression, comment, characterSet, collation } = NCO
|
||||
const { columnName: oldColumnName } = oldColumnOrder[index]
|
||||
if ( columnName !== oldColumnName) {
|
||||
let columnDef = `${this.wrapIdentifier(columnName)} ${dataType}`;
|
||||
|
||||
if (characterSet) {
|
||||
columnDef += ` CHARACTER SET ${characterSet}`;
|
||||
}
|
||||
|
||||
if (collation) {
|
||||
columnDef += ` COLLATE ${collation}`;
|
||||
}
|
||||
|
||||
if (nullable === false) {
|
||||
columnDef += ' NOT NULL'
|
||||
}
|
||||
|
||||
if (!_.isNil(defaultValue)) {
|
||||
const isGenerated = /DEFAULT_GENERATED/gi.test(extra);
|
||||
columnDef += ` DEFAULT ${this.defaultValue(defaultValue, isGenerated)}`;
|
||||
}
|
||||
|
||||
if (extra && !generated) {
|
||||
let processedExtra = extra.replace(/DEFAULT_GENERATED/gi, '');
|
||||
|
||||
// Clean up extra whitespace
|
||||
processedExtra = processedExtra.replace(/\s+/g, ' ').trim();
|
||||
|
||||
if (processedExtra) {
|
||||
columnDef += ` ${processedExtra}`;
|
||||
}
|
||||
} else if (generated && generationExpression) {
|
||||
const isStored = /STORED GENERATED/gi.test(extra);
|
||||
// MySQL stores generation expressions with escaped quotes, we need to unescape them
|
||||
let unescapedExpression = generationExpression.replace(/\\'/g, "'");
|
||||
|
||||
if (!unescapedExpression.startsWith('(')) {
|
||||
unescapedExpression = `(${unescapedExpression})`;
|
||||
}
|
||||
columnDef += ` GENERATED ALWAYS AS (${unescapedExpression}) ${isStored ? 'STORED' : 'VIRTUAL'}`;
|
||||
}
|
||||
|
||||
if (comment) {
|
||||
columnDef += ` COMMENT ${this.escapeString(comment, true)}`;
|
||||
}
|
||||
|
||||
if (index === 0) {
|
||||
acc.push(`MODIFY ${this.wrapIdentifier(columnName)} ${dataType} FIRST`)
|
||||
acc.push(`MODIFY ${columnDef} FIRST`)
|
||||
} else {
|
||||
acc.push(`MODIFY ${this.wrapIdentifier(columnName)} ${dataType} AFTER ${this.wrapIdentifier(arr[index - 1].columnName)}`)
|
||||
acc.push(`MODIFY ${columnDef} AFTER ${this.wrapIdentifier(arr[index - 1].columnName)}`)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return acc
|
||||
}, [])
|
||||
|
||||
@@ -148,4 +250,4 @@ export class MySqlChangeBuilder extends ChangeBuilderBase {
|
||||
return []
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ import { State as RootState } from "../index";
|
||||
import _ from "lodash";
|
||||
import MenuBuilder from "@/common/menus/MenuBuilder";
|
||||
import ClientMenuActionHandler from "@/lib/menu/ClientMenuActionHandler";
|
||||
import config from "@/config";
|
||||
import RawLog from "@bksLogger";
|
||||
import { ExternalMenuItem } from "@/types";
|
||||
|
||||
@@ -48,7 +47,7 @@ export const MenuBarModule: Module<State, RootState> = {
|
||||
const builder = new MenuBuilder(
|
||||
rootGetters["settings/settings"],
|
||||
actionHandler,
|
||||
config,
|
||||
window.platformInfo,
|
||||
window.bksConfig
|
||||
);
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user