Merge pull request #3580 from beekeeper-studio/rc-54

This commit is contained in:
Day Matchullis
2025-10-23 11:25:54 -06:00
committed by GitHub
21 changed files with 2229 additions and 103 deletions

View File

@@ -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]

View File

@@ -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.

View File

@@ -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'
]

View File

@@ -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": {

View File

@@ -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:

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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) {

View File

@@ -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

View File

@@ -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: {

View File

@@ -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 : {

View File

@@ -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

View File

@@ -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 {

View File

@@ -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

View File

@@ -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
}

View File

@@ -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 }
];

View File

@@ -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 {

View File

@@ -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')
}

View File

@@ -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 []
}
}
}

View File

@@ -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
);

View File

File diff suppressed because it is too large Load Diff