Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
102 changes: 100 additions & 2 deletions src/renderer/components/WorkspaceTabQueryTable.vue
Original file line number Diff line number Diff line change
Expand Up @@ -14,13 +14,15 @@
:context-event="contextEvent"
:selected-rows="selectedRows"
:selected-cell="selectedCell"
:key-usage="keyUsage"
:mode="mode"
@show-delete-modal="showDeleteConfirmModal"
@set-null="setNull"
@copy-cell="copyCell"
@fill-cell="fillCell"
@copy-row="copyRow"
@duplicate-row="duplicateRow"
@go-to-foreign-key="goToForeignKey"
@close-context="closeContext"
/>
<ul v-if="resultsWithRows.length > 1" class="tab tab-block result-tabs">
Expand Down Expand Up @@ -98,6 +100,7 @@
@select-row="selectRow"
@update-field="updateField($event, row)"
@contextmenu="contextMenu"
@ctrl-click-cell="handleForeignKeyClick"
/>
</template>
</BaseVirtualScroll>
Expand Down Expand Up @@ -253,7 +256,7 @@
<script setup lang="ts">
/* eslint-disable @typescript-eslint/no-explicit-any */
import { BLOB, DATE, DATETIME, LONG_TEXT, TEXT, TIME } from 'common/fieldTypes';
import { QueryResult, TableField } from 'common/interfaces/antares';
import { QueryForeign, QueryResult, TableField } from 'common/interfaces/antares';
import { TableUpdateParams } from 'common/interfaces/tableApis';
import { fakerCustom } from 'common/libs/fakerCustom';
import { jsonToSqlInsert } from 'common/libs/sqlUtils';
Expand Down Expand Up @@ -282,7 +285,8 @@ const { t } = useI18n();

const settingsStore = useSettingsStore();
const consoleStore = useConsoleStore();
const { getWorkspace } = useWorkspacesStore();
const workspacesStore = useWorkspacesStore();
const { getWorkspace, newTab } = workspacesStore;

const { /* dataTabLimit: pageSize, */ defaultCopyType } = storeToRefs(settingsStore);

Expand Down Expand Up @@ -537,6 +541,100 @@ const closeContext = () => {
isContext.value = false;
};

/**
* Escapes an identifier (schema, table, or field name) for safe use in SQL queries.
* Handles embedded quote characters by doubling them according to database conventions.
* @param client - The database client type ('pg', 'mysql', 'maria', 'sqlite', 'firebird', etc.)
* @param identifier - The identifier to escape
* @returns The escaped and quoted identifier
*/
const escapeIdentifier = (client: string, identifier: unknown): string => {
// Validate identifier is a string
if (typeof identifier !== 'string')
throw new Error(`Invalid identifier: expected string, got ${typeof identifier}`);

switch (client) {
case 'mysql':
case 'maria':
// MySQL/MariaDB: escape backticks by doubling them, wrap in backticks
return '`' + identifier.replace(/`/g, '``') + '`';
case 'pg':
case 'sqlite':
case 'firebird':
default:
// PostgreSQL, SQLite, Firebird, and others: escape double quotes by doubling them, wrap in double quotes
return '"' + identifier.replace(/"/g, '""') + '"';
}
};

const buildForeignKeyQuery = (fk: QueryForeign, value: any): string => {
const schema = fk.refSchema;
const table = fk.refTable;
const field = fk.refField;
const client = workspaceClient.value;

// Quote and escape identifiers based on client
let quotedTable: string;

// Build the quoted table reference (with optional schema)
if (schema && typeof schema === 'string')
quotedTable = `${escapeIdentifier(client, schema)}.${escapeIdentifier(client, table)}`;

else
quotedTable = escapeIdentifier(client, table);

const quotedField = escapeIdentifier(client, field);

// Format the value for the WHERE clause
let formattedValue: string;
if (value === null)
return `SELECT * FROM ${quotedTable} WHERE ${quotedField} IS NULL`;

else if (typeof value === 'string')
formattedValue = `'${value.replace(/'/g, '\'\'')}'`;

else if (typeof value === 'number' || typeof value === 'bigint')
formattedValue = String(value);

else if (typeof value === 'boolean')
formattedValue = value ? 'TRUE' : 'FALSE';

else
formattedValue = `'${String(value).replace(/'/g, '\'\'')}'`;

return `SELECT * FROM ${quotedTable} WHERE ${quotedField} = ${formattedValue}`;
};

const goToForeignKey = (fk: QueryForeign) => {
if (!selectedCell.value) return;

// Get the current cell value
const selectedRow = localResults.value.find((row: any) => row._antares_id === selectedRows.value[0]);
if (!selectedRow) return;

// Find the actual field value - handle both "field" and "table.field" formats
let cellValue: any;
const orgField = selectedCell.value.orgField;
if (orgField in selectedRow)
cellValue = selectedRow[orgField];

else {
// Try with just the field name
const fieldName = orgField.includes('.') ? orgField.split('.').pop() : orgField;
const matchingKey = Object.keys(selectedRow).find(k => k === fieldName || k.endsWith(`.${fieldName}`));
if (matchingKey)
cellValue = selectedRow[matchingKey];
}

const sql = buildForeignKeyQuery(fk, cellValue);
newTab({ uid: props.connUid, content: sql, type: 'query', schema: fk.refSchema || workspaceSchema.value, autorun: true });
};

const handleForeignKeyClick = (payload: { keyUsage: QueryForeign; value: any }) => {
const sql = buildForeignKeyQuery(payload.keyUsage, payload.value);
newTab({ uid: props.connUid, content: sql, type: 'query', schema: payload.keyUsage.refSchema || workspaceSchema.value, autorun: true });
};

const showDeleteConfirmModal = (e: any) => {
if (e && e.code !== 'Delete') return;
if (e && e.path && ['INPUT', 'TEXTAREA', 'SELECT'].includes(e.path[0].tagName))
Expand Down
34 changes: 33 additions & 1 deletion src/renderer/components/WorkspaceTabQueryTableContext.vue
Original file line number Diff line number Diff line change
Expand Up @@ -132,6 +132,20 @@
/> {{ t('database.setNull') }}
</span>
</div>
<div
v-if="selectedRows.length === 1 && foreignKey && selectedCell.value !== null"
class="context-element"
@click="goToForeignKey"
>
<span class="d-flex">
<BaseIcon
icon-name="mdiKeyLink"
class="mr-1 mt-1 text-light"
:size="18"
/> {{ t('database.goToForeignKey') }}
</span>
<span class="text-light ml-2"> (Ctrl+{{ t('general.click') }})</span>
</div>
<div
v-if="selectedCell.isEditable"
class="context-element"
Expand All @@ -150,6 +164,7 @@

<script setup lang="ts">
import { DATE, DATETIME, FLOAT, LONG_TEXT, NUMBER, TEXT, TIME, UUID } from 'common/fieldTypes';
import { QueryForeign } from 'common/interfaces/antares';
import { computed, Prop } from 'vue';
import { useI18n } from 'vue-i18n';

Expand All @@ -162,6 +177,7 @@ const props = defineProps({
contextEvent: MouseEvent,
selectedRows: Array,
selectedCell: Object,
keyUsage: Array as Prop<QueryForeign[]>,
mode: String as Prop<'table' | 'query'>
});

Expand All @@ -172,7 +188,8 @@ const emit = defineEmits([
'copy-cell',
'copy-row',
'duplicate-row',
'fill-cell'
'fill-cell',
'go-to-foreign-key'
]);

const fakerMethods = {
Expand Down Expand Up @@ -227,6 +244,14 @@ const fakerGroup = computed(() => {
return false;
});

const foreignKey = computed(() => {
if (!props.keyUsage || !props.selectedCell) return null;
let fieldName = props.selectedCell.field;
if (fieldName && fieldName.includes('.'))
fieldName = fieldName.split('.').pop();
return props.keyUsage.find(key => key.field === fieldName) || null;
});

const showConfirmModal = () => {
emit('show-delete-modal');
};
Expand Down Expand Up @@ -259,4 +284,11 @@ const fillCell = (method: {name: string; group: string}) => {
emit('fill-cell', { ...method, type: fakerGroup.value });
closeContext();
};

const goToForeignKey = () => {
if (foreignKey.value)
emit('go-to-foreign-key', foreignKey.value);

closeContext();
};
</script>
22 changes: 19 additions & 3 deletions src/renderer/components/WorkspaceTabQueryTableRow.vue
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,8 @@
id: row._antares_id,
orgField: cKey,
type: fields[cKey].type,
length: fields[cKey].charLength || fields[cKey].length
length: fields[cKey].charLength || fields[cKey].length,
value: col
})"
>
<template v-if="cKey !== '_antares_id'">
Expand Down Expand Up @@ -271,7 +272,7 @@ const props = defineProps({
selectedCell: { type: String, default: null }
});

const emit = defineEmits(['update-field', 'select-row', 'contextmenu', 'start-editing', 'stop-editing']);
const emit = defineEmits(['update-field', 'select-row', 'contextmenu', 'start-editing', 'stop-editing', 'ctrl-click-cell']);

// eslint-disable-next-line @typescript-eslint/no-explicit-any
const isInlineEditor: Ref<any> = ref({});
Expand Down Expand Up @@ -558,7 +559,21 @@ const prepareToDelete = () => {
willBeDeleted.value = true;
};

const selectRow = (event: Event, field: string) => {
const selectRow = (event: MouseEvent, field: string) => {
// Check for CTRL+Click on a foreign key column
if ((event.ctrlKey || event.metaKey) && isForeignKey(field)) {
const cellValue = props.row[field];
// Don't navigate if the value is NULL
if (cellValue !== null) {
event.preventDefault();
event.stopPropagation();
const keyUsageInfo = getKeyUsage(field);
if (keyUsageInfo) {
emit('ctrl-click-cell', { keyUsage: keyUsageInfo, value: cellValue });
return;
}
}
}
emit('select-row', event, props.row, field);
};

Expand All @@ -575,6 +590,7 @@ const openContext = (event: MouseEvent, payload: {
isEditable?: boolean;
type: string;
length: number | false;
value?: unknown;
}) => {
payload.field = props.fields[payload.orgField].name;// Ensures field name only
payload.isEditable = isEditable.value;
Expand Down
6 changes: 4 additions & 2 deletions src/renderer/i18n/ar-SA.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,8 @@ export const arSA = {
insert: 'أدرج',
seconds: 'ثواني',
deleteConfirm: 'هل أنت متأكد من حذف الإتصال؟',
uploadFile: 'رفع ملف'
uploadFile: 'رفع ملف',
click: 'انقر'
},
connection: {
connectionName: 'إسم الإتصال',
Expand Down Expand Up @@ -52,7 +53,8 @@ export const arSA = {
deleteRows: 'حذف صف | حذف {count} صفوف',
confirmToDeleteRows: 'هل أنت متأكد من حذف صف واحد؟? | هل أنت متأكد من حذف {count} صف?',
addNewRow: 'إضافة صف جديد',
numberOfInserts: 'عدد الإدراجات'
numberOfInserts: 'عدد الإدراجات',
goToForeignKey: 'انتقل إلى صف المفتاح الخارجي'
},
application: {
settings: 'الإعدادات',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/ca-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,7 +64,8 @@ export const caES = {
actionSuccessful: '{action} realitzat amb èxit',
outputFormat: 'Format de sortida',
singleFile: 'Arxiu {ext} únic',
zipCompressedFile: 'Arxiu {ext} comprimit en ZIP'
zipCompressedFile: 'Arxiu {ext} comprimit en ZIP',
click: 'Clic'
},
connection: {
connectionName: 'Nom de la connexió',
Expand Down Expand Up @@ -214,6 +215,7 @@ export const caES = {
fakeDataLanguage: 'Idioma de dades fictícies',
queryDuration: 'Durada de la consulta',
setNull: 'Estableix NULL',
goToForeignKey: 'Ves a la fila de la clau estrangera',
processesList: 'Llista de processos',
processInfo: 'Informació del procés',
manageUsers: 'Gestiona usuaris',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/cs-CZ.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,8 @@ export const csCZ = {
title: 'Titulek',
archive: 'Archivovat', // verb
undo: 'Zpět',
moveTo: 'Přesunout do'
moveTo: 'Přesunout do',
click: 'Kliknutí'
},
connection: { // Database connection
connection: 'Připojení',
Expand Down Expand Up @@ -239,6 +240,7 @@ export const csCZ = {
fakeDataLanguage: 'Jazyk pro fake data',
queryDuration: 'Doba trvání dotazu',
setNull: 'Nastavit NULL',
goToForeignKey: 'Přejít na řádek cizího klíče',
processesList: 'Seznam procesů',
processInfo: 'Informace o procesu',
manageUsers: 'Správa uživatelů',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/de-DE.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,8 @@ export const deDE = {
deleteConfirm: 'Bestätige den Abbruch von',
uploadFile: 'Datei hochladen',
manualValue: 'Manueller Wert',
selectAll: 'Alle auswählen'
selectAll: 'Alle auswählen',
click: 'Klick'
},
connection: {
connectionName: 'Verbindungsname',
Expand Down Expand Up @@ -172,6 +173,7 @@ export const deDE = {
fakeDataLanguage: 'Fingierte Datensprache',
queryDuration: 'Dauer der Abfrage',
setNull: 'Setze NULL',
goToForeignKey: 'Zur Fremdschlüsselzeile gehen',
processesList: 'Prozessliste',
processInfo: 'Prozessinformationen',
manageUsers: 'Benutzer verwalten',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/en-US.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export const enUS = {
title: 'Title',
archive: 'Archive', // verb
undo: 'Undo',
moveTo: 'Move to'
moveTo: 'Move to',
click: 'Click'
},
connection: { // Database connection
connection: 'Connection',
Expand Down Expand Up @@ -243,6 +244,7 @@ export const enUS = {
fakeDataLanguage: 'Fake data language',
queryDuration: 'Query duration',
setNull: 'Set NULL',
goToForeignKey: 'Go to foreign key row',
processesList: 'Processes list',
processInfo: 'Process info',
manageUsers: 'Manage users',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/es-ES.ts
Original file line number Diff line number Diff line change
Expand Up @@ -80,7 +80,8 @@ export const esES = {
title: 'Título',
archive: 'Archivo', // verb
undo: 'Deshacer',
moveTo: 'Mover a'
moveTo: 'Mover a',
click: 'Clic'
},
connection: { // Database connection
connection: 'Conexión',
Expand Down Expand Up @@ -243,6 +244,7 @@ export const esES = {
fakeDataLanguage: 'Lenguaje de datos dummy',
queryDuration: 'Duración de la consulta',
setNull: 'Establecer a NULL',
goToForeignKey: 'Ir a la fila de la clave foránea',
processesList: 'Lista de procesos',
processInfo: 'Información de proceso',
manageUsers: 'Administrar usuarios',
Expand Down
4 changes: 3 additions & 1 deletion src/renderer/i18n/fr-FR.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,8 @@ export const frFR = {
actionSuccessful: '{action} réussie',
outputFormat: 'Format de sortie',
singleFile: 'Fichier seul avec l\'extension {ext}',
zipCompressedFile: 'Fichier compréssé avec l\'extension {ext}'
zipCompressedFile: 'Fichier compréssé avec l\'extension {ext}',
click: 'Clic'
},
connection: {
connectionName: 'Nom de la connexion',
Expand Down Expand Up @@ -211,6 +212,7 @@ export const frFR = {
fakeDataLanguage: 'Langue des fausses données',
queryDuration: 'Temps de requête',
setNull: 'Définir comme NULL',
goToForeignKey: 'Aller à la ligne de la clé étrangère',
processesList: 'Liste des processus',
processInfo: 'Information sur le processus',
manageUsers: 'Organisation des utilisateurs',
Expand Down
Loading