/** * SQL View Designer — SSMS Query Designer duzeninde gorsel view olusturma araci. * * Uc panel: Diagram (tablolar + join oklari) / Criteria grid / T-SQL onizleme. * Mevcut bir view acilirken tanim `parseViewSql` ile geri okunmaya calisilir; * tasarimcinin kanonik sekline uymayan bir tanimda dialog "ham SQL" moduna * duser ve kullanici Query Editor'dan devam eder. */ import { useCallback, useEffect, useMemo, useState } from 'react' import { DndContext, type DragEndEvent, type DragMoveEvent } from '@dnd-kit/core' import { Button, Dialog, Notification, toast } from '@/components/ui' import { FaCloudUploadAlt, FaCode, FaColumns, FaExclamationTriangle, FaEye, FaMinus, FaPlus, FaProjectDiagram, FaSearch, FaTable, } from 'react-icons/fa' import { sqlObjectManagerService } from '@/services/sql-query-manager.service' import { useLocalization } from '@/utils/hooks/useLocalization' import type { DatabaseColumnDto } from '@/proxy/sql-query-manager/models' import DiagramPane, { type PendingLink } from './sqlViewDesigner/DiagramPane' import CriteriaGrid from './sqlViewDesigner/CriteriaGrid' import Input from '@/components/ui/Input' import { BOX_DEFAULT_ROWS, createEmptySettings, createExpressionRow, createRow, generateViewSql, isDerivedSource, newId, nextSourcePosition, parseViewSql, suggestAlias, validateViewModel, type CriteriaRow, type JoinOperator, type ViewModel, type ViewSource, } from './sqlViewDesigner/viewModel' interface ViewDesignerDialogProps { isOpen: boolean onClose: () => void dataSource: string | null isPostgreSql?: boolean /** Dolu ise duzenleme modu; view tanimi cekilip parse edilmeye calisilir. */ initialViewData?: { schemaName: string; objectName: string } | null onDeployed?: (view: { schemaName: string; objectName: string }) => void | Promise /** Parse edilemeyen tanimi Query Editor'a devretmek icin. */ onOpenInEditor?: (sql: string) => void } interface CatalogObject { schemaName: string objectName: string objectType: 'table' | 'view' } interface ForeignKeyRow { parentTable: string parentColumn: string referencedTable: string referencedColumn: string } const columnKey = (schemaName: string, objectName: string) => `${schemaName}.${objectName}` const SqlViewDesignerDialog = ({ isOpen, onClose, dataSource, isPostgreSql = false, initialViewData, onDeployed, onOpenInEditor, }: ViewDesignerDialogProps) => { const { translate } = useLocalization() const isEditMode = !!initialViewData const [sources, setSources] = useState([]) const [rows, setRows] = useState([]) const [settings, setSettings] = useState(() => createEmptySettings(isPostgreSql)) const [catalog, setCatalog] = useState([]) const [catalogLoading, setCatalogLoading] = useState(false) const [catalogSearch, setCatalogSearch] = useState('') const [paletteOpen, setPaletteOpen] = useState(true) const [columnCache, setColumnCache] = useState>({}) const [loadingKeys, setLoadingKeys] = useState>(new Set()) const [foreignKeys, setForeignKeys] = useState([]) const [zoom, setZoom] = useState(1) const [dragPreview, setDragPreview] = useState<{ id: string delta: { x: number; y: number } } | null>(null) const [pendingLink, setPendingLink] = useState(null) const [selectedJoinKey, setSelectedJoinKey] = useState(null) const [showDiagram, setShowDiagram] = useState(true) const [showCriteria, setShowCriteria] = useState(true) const [showSql, setShowSql] = useState(true) const [isDeploying, setIsDeploying] = useState(false) const [definitionLoading, setDefinitionLoading] = useState(false) /** Parse edilemeyen mevcut view'in ham tanimi; dolu ise tasarimci devre disi. */ const [rawDefinition, setRawDefinition] = useState(null) // ── Katalog ─────────────────────────────────────────────────────────────── useEffect(() => { if (!isOpen || !dataSource) return setCatalogLoading(true) sqlObjectManagerService .getAllObjects(dataSource) .then((res) => { const tables: CatalogObject[] = (res.data?.tables ?? []).map((t) => ({ schemaName: t.schemaName, objectName: t.tableName, objectType: 'table', })) const views: CatalogObject[] = (res.data?.views ?? []) .filter( (v) => !initialViewData || v.schemaName !== initialViewData.schemaName || v.objectName !== initialViewData.objectName, ) .map((v) => ({ schemaName: v.schemaName, objectName: v.objectName, objectType: 'view' as const, })) setCatalog([...tables, ...views]) }) .catch(() => setCatalog([])) .finally(() => setCatalogLoading(false)) }, [isOpen, dataSource, initialViewData]) // ── FK listesi (otomatik join onerisi icin) ─────────────────────────────── useEffect(() => { if (!isOpen || !dataSource || isPostgreSql) return const fkQuery = [ 'SELECT', ' pt.name AS parentTable,', ' pc.name AS parentColumn,', ' rt.name AS referencedTable,', ' rc.name AS referencedColumn', 'FROM sys.foreign_keys fk', 'INNER JOIN sys.foreign_key_columns fkc ON fk.object_id = fkc.constraint_object_id', 'INNER JOIN sys.tables pt ON fkc.parent_object_id = pt.object_id', 'INNER JOIN sys.columns pc ON fkc.parent_object_id = pc.object_id AND fkc.parent_column_id = pc.column_id', 'INNER JOIN sys.tables rt ON fkc.referenced_object_id = rt.object_id', 'INNER JOIN sys.columns rc ON fkc.referenced_object_id = rc.object_id AND fkc.referenced_column_id = rc.column_id', ].join('\n') sqlObjectManagerService .executeQuery({ queryText: fkQuery, dataSourceCode: dataSource }) .then((res) => setForeignKeys((res.data?.data as ForeignKeyRow[]) ?? [])) .catch(() => setForeignKeys([])) }, [isOpen, dataSource, isPostgreSql]) // ── Duzenleme modu ──────────────────────────────────────────────────────── useEffect(() => { if (!isOpen) return if (!initialViewData || !dataSource) { setRawDefinition(null) return } setDefinitionLoading(true) sqlObjectManagerService .getNativeObjectDefinition(dataSource, initialViewData.schemaName, initialViewData.objectName) .then((res) => { const definition = res.data || '' const parsed = parseViewSql(definition) if (parsed) { setSources(parsed.sources) setRows(parsed.rows) setSettings(parsed.settings) setRawDefinition(null) } else { setRawDefinition(definition) } }) .catch(() => setRawDefinition('')) .finally(() => setDefinitionLoading(false)) }, [isOpen, dataSource, initialViewData]) // ── Kolon metadata ──────────────────────────────────────────────────────── const ensureColumns = useCallback( (schemaName: string, objectName: string) => { const key = columnKey(schemaName, objectName) if (!dataSource || columnCache[key]) return setLoadingKeys((prev) => new Set(prev).add(key)) sqlObjectManagerService .getTableColumns(dataSource, schemaName, objectName) .then((res) => setColumnCache((prev) => ({ ...prev, [key]: res.data ?? [] }))) .catch(() => setColumnCache((prev) => ({ ...prev, [key]: [] }))) .finally(() => setLoadingKeys((prev) => { const next = new Set(prev) next.delete(key) return next }), ) }, [dataSource, columnCache], ) useEffect(() => { sources.forEach((s) => { if (!isDerivedSource(s)) ensureColumns(s.schemaName, s.objectName) }) }, [sources, ensureColumns]) const columnsOf = useCallback( (sourceId: string): DatabaseColumnDto[] => { const src = sources.find((s) => s.id === sourceId) if (!src) return [] if (isDerivedSource(src)) { return (src.derivedColumns ?? []).map((columnName) => ({ columnName, dataType: 'subquery', isNullable: true, })) } return columnCache[columnKey(src.schemaName, src.objectName)] ?? [] }, [sources, columnCache], ) const loadingSourceIds = useMemo(() => { const set = new Set() sources.forEach((s) => { if (!isDerivedSource(s) && loadingKeys.has(columnKey(s.schemaName, s.objectName))) { set.add(s.id) } }) return set }, [sources, loadingKeys]) // ── Kaynak islemleri ────────────────────────────────────────────────────── /** Iki tablo arasinda FK varsa hazir join kosulu uretir. */ const suggestJoin = (target: CatalogObject, existing: ViewSource[]) => { for (const src of [...existing].reverse()) { const fk = foreignKeys.find( (f) => (f.parentTable === target.objectName && f.referencedTable === src.objectName) || (f.parentTable === src.objectName && f.referencedTable === target.objectName), ) if (!fk) continue const targetIsParent = fk.parentTable === target.objectName return { id: newId(), leftSourceId: src.id, leftColumn: targetIsParent ? fk.referencedColumn : fk.parentColumn, operator: '=' as JoinOperator, rightColumn: targetIsParent ? fk.parentColumn : fk.referencedColumn, } } return null } const addSource = (obj: CatalogObject) => { setSources((prev) => { const pos = nextSourcePosition(prev.length) const suggested = prev.length > 0 ? suggestJoin(obj, prev) : null const next: ViewSource = { id: newId(), schemaName: obj.schemaName, objectName: obj.objectName, alias: suggestAlias( obj.objectName, prev.map((s) => s.alias), ), joinKind: 'INNER', conditions: suggested ? [suggested] : [], x: pos.x, y: pos.y, collapsed: false, showSelectedOnly: false, visibleRows: BOX_DEFAULT_ROWS, } return [...prev, next] }) ensureColumns(obj.schemaName, obj.objectName) } const removeSource = (sourceId: string) => { setSources((prev) => prev .filter((s) => s.id !== sourceId) .map((s) => ({ ...s, conditions: s.conditions.filter((c) => c.leftSourceId !== sourceId), })), ) setRows((prev) => prev.filter((r) => r.sourceId !== sourceId)) } const updateSource = (sourceId: string, patch: Partial) => setSources((prev) => prev.map((s) => (s.id === sourceId ? { ...s, ...patch } : s))) const updateJoin = (sourceId: string, conditionId: string, patch: { operator?: JoinOperator }) => setSources((prev) => prev.map((s) => s.id === sourceId ? { ...s, conditions: s.conditions.map((c) => (c.id === conditionId ? { ...c, ...patch } : c)), } : s, ), ) const removeJoin = (sourceId: string, conditionId: string) => setSources((prev) => prev.map((s) => s.id === sourceId ? { ...s, conditions: s.conditions.filter((c) => c.id !== conditionId) } : s, ), ) /** Diagramda kolondan kolona baglanti tamamlandiginda join kurar. */ const completeLink = (target: PendingLink) => { if (!pendingLink) return setSources((prev) => { const fromIdx = prev.findIndex((s) => s.id === pendingLink.sourceId) const toIdx = prev.findIndex((s) => s.id === target.sourceId) if (fromIdx < 0 || toIdx < 0 || fromIdx === toIdx) return prev // Join kosulu her zaman FROM'da sonra gelen kaynagin uzerinde durur. const ownerIdx = Math.max(fromIdx, toIdx) const otherIdx = Math.min(fromIdx, toIdx) const ownerIsTarget = ownerIdx === toIdx return prev.map((s, i) => { if (i !== ownerIdx) return s const exists = s.conditions.some( (c) => c.leftSourceId === prev[otherIdx].id && c.leftColumn === (ownerIsTarget ? pendingLink.columnName : target.columnName) && c.rightColumn === (ownerIsTarget ? target.columnName : pendingLink.columnName), ) if (exists) return s return { ...s, // Kosul eklenen CROSS JOIN otomatik olarak INNER'a doner. joinKind: s.joinKind === 'CROSS' ? ('INNER' as const) : s.joinKind, conditions: [ ...s.conditions, { id: newId(), leftSourceId: prev[otherIdx].id, leftColumn: ownerIsTarget ? pendingLink.columnName : target.columnName, operator: '=' as JoinOperator, rightColumn: ownerIsTarget ? target.columnName : pendingLink.columnName, }, ], } }) }) setPendingLink(null) } // ── Grid satirlari ──────────────────────────────────────────────────────── const isColumnSelected = useCallback( (sourceId: string, columnName: string) => rows.some((r) => r.sourceId === sourceId && r.columnName === columnName && r.output), [rows], ) const toggleColumn = (sourceId: string, columnName: string) => { setRows((prev) => { const existing = prev.find( (r) => r.sourceId === sourceId && r.columnName === columnName && r.output, ) if (existing) { // Filtresi varsa satiri koru, yalnizca ciktidan cikar. const hasFilter = existing.filters.some((f) => f.trim()) if (hasFilter) { return prev.map((r) => r.id === existing.id ? { ...r, output: false, groupMode: 'Where' as const } : r, ) } return prev.filter((r) => r.id !== existing.id) } return [...prev, createRow(sourceId, columnName)] }) } const toggleAllColumns = (sourceId: string) => { const list = columnsOf(sourceId) const allSelected = list.length > 0 && list.every((c) => isColumnSelected(sourceId, c.columnName)) if (allSelected) { setRows((prev) => prev.filter((r) => !(r.sourceId === sourceId && r.output))) return } setRows((prev) => { const next = [...prev] list.forEach((c) => { if ( !next.some((r) => r.sourceId === sourceId && r.columnName === c.columnName && r.output) ) { next.push(createRow(sourceId, c.columnName)) } }) return next }) } const updateRow = (id: string, patch: Partial) => setRows((prev) => prev.map((r) => (r.id === id ? { ...r, ...patch } : r))) const updateFilter = (id: string, index: number, value: string) => setRows((prev) => prev.map((r) => { if (r.id !== id) return r const filters = [...r.filters] while (filters.length <= index) filters.push('') filters[index] = value return { ...r, filters } }), ) const removeRow = (id: string) => setRows((prev) => prev.filter((r) => r.id !== id)) const moveRow = (id: string, delta: number) => setRows((prev) => { const idx = prev.findIndex((r) => r.id === id) const target = idx + delta if (idx < 0 || target < 0 || target >= prev.length) return prev const next = [...prev] const [item] = next.splice(idx, 1) next.splice(target, 0, item) return next }) const addExpressionRow = () => setRows((prev) => [...prev, createExpressionRow()]) const addEmptyRow = () => { const src = sources[0] if (!src) return const first = columnsOf(src.id)[0] setRows((prev) => [...prev, createRow(src.id, first?.columnName ?? '')]) } /** * Sigma dugmesi. Kapatilirken aggregate secimleri geri alinir, yoksa grid'de * gorunmeyen bir mod SQL'i sessizce degistirirdi. */ const toggleGroupBy = (enabled: boolean) => { setSettings((s) => ({ ...s, groupBy: enabled })) if (!enabled) { setRows((prev) => prev.map((r) => (r.groupMode === 'Where' ? r : { ...r, groupMode: 'GroupBy' as const })), ) } } // ── Turetilenler ────────────────────────────────────────────────────────── const model: ViewModel = useMemo(() => ({ sources, rows, settings }), [sources, rows, settings]) const grouped = settings.groupBy /** * Serbest ifadeler ve alt sorgu govdeleri alias'lara metin olarak refere * eder; alias degisimi bunlari sessizce bozacagi icin kilitlenir. */ const lockAliases = useMemo( () => sources.some(isDerivedSource) || rows.some((r) => r.expression !== undefined), [sources, rows], ) const validationTexts = useMemo( () => ({ noSource: translate('::App.SqlViewDesigner.ValidationNoSource'), aliasRequired: translate('::App.SqlViewDesigner.ValidationAliasRequired'), aliasDuplicate: translate('::App.SqlViewDesigner.ValidationAliasDuplicate'), joinConditionRequired: translate('::App.SqlViewDesigner.ValidationJoinRequired'), noOutputColumn: translate('::App.SqlViewDesigner.ValidationNoColumn'), columnAliasDuplicate: translate('::App.SqlViewDesigner.ValidationColumnAliasDuplicate'), expressionAliasRequired: translate('::App.SqlViewDesigner.ValidationExpressionAlias'), viewNameRequired: translate('::App.SqlViewDesigner.ValidationViewNameRequired'), viewNameInvalid: translate('::App.SqlViewDesigner.ValidationViewNameInvalid'), }), [translate], ) const issues = useMemo(() => validateViewModel(model, validationTexts), [model, validationTexts]) const generatedSql = useMemo( () => generateViewSql(model, { isPostgreSql }), [model, isPostgreSql], ) const filteredCatalog = useMemo(() => { const term = catalogSearch.trim().toLowerCase() if (!term) return catalog return catalog.filter((o) => `${o.schemaName}.${o.objectName}`.toLowerCase().includes(term)) }, [catalog, catalogSearch]) // ── Kapatma / deploy ────────────────────────────────────────────────────── const handleClose = () => { setSources([]) setRows([]) setSettings(createEmptySettings(isPostgreSql)) setCatalogSearch('') setRawDefinition(null) setPendingLink(null) setSelectedJoinKey(null) setZoom(1) onClose() } const handleDeploy = async () => { if (!dataSource || !generatedSql) return setIsDeploying(true) try { const executable = generatedSql .split(/^\s*GO\s*$/gim) .map((b) => b.trim()) .filter(Boolean) .join('\n') const result = await sqlObjectManagerService.executeQuery({ queryText: executable, dataSourceCode: dataSource, }) if (result.data?.success) { toast.push( {translate('::App.SqlViewDesigner.DeploySucceeded')} , { placement: 'bottom-end' }, ) await onDeployed?.({ schemaName: settings.schemaName, objectName: settings.viewName.trim(), }) handleClose() } else { toast.push( {result.data?.error || result.data?.message || translate('::App.SqlViewDesigner.DeployFailed')} , { placement: 'bottom-end' }, ) } } catch (error: any) { toast.push( {error.response?.data?.error?.message || translate('::App.SqlViewDesigner.DeployFailed')} , { placement: 'bottom-end' }, ) } finally { setIsDeploying(false) } } // ── Surukleme ───────────────────────────────────────────────────────────── const handleDragMove = (event: DragMoveEvent | null) => { if (!event) { setDragPreview(null) return } setDragPreview({ id: String(event.active.id), delta: { x: event.delta.x / zoom, y: event.delta.y / zoom }, }) } const handleDragEnd = (event: DragEndEvent) => { const id = String(event.active.id) setSources((prev) => prev.map((s) => s.id === id ? { ...s, x: Math.max(0, s.x + event.delta.x / zoom), y: Math.max(0, s.y + event.delta.y / zoom), } : s, ), ) setDragPreview(null) } // ── Render ──────────────────────────────────────────────────────────────── const inputCls = 'rounded border px-2 py-1 text-xs dark:border-gray-600 dark:bg-gray-700 dark:text-white' const renderToolbar = () => (
{translate('::App.SqlViewDesigner.Schema')} setSettings((s) => ({ ...s, schemaName: e.target.value }))} /> {translate('::App.SqlViewDesigner.ViewName')} * setSettings((s) => ({ ...s, viewName: e.target.value }))} /> {settings.topEnabled && ( <> setSettings((s) => ({ ...s, topCount: e.target.value }))} /> {!isPostgreSql && ( )} )}
{/* SSMS'teki panel gosterme/gizleme dugmeleri */} } title={translate('::App.SqlViewDesigner.PaneDiagram')} onClick={() => setShowDiagram((v) => !v)} /> } title={translate('::App.SqlViewDesigner.PaneCriteria')} onClick={() => setShowCriteria((v) => !v)} /> } title={translate('::App.SqlViewDesigner.PaneSql')} onClick={() => setShowSql((v) => !v)} /> {Math.round(zoom * 100)}%
) const renderPalette = () => (
{paletteOpen && ( <>
setCatalogSearch(e.target.value)} />
{catalogLoading && (
{translate('::App.Platform.Loading')}
)} {!catalogLoading && filteredCatalog.length === 0 && (
{translate('::App.SqlViewDesigner.NoObjectsFound')}
)} {filteredCatalog.map((obj) => ( ))}
)}
) const renderRawMode = () => (
{translate('::App.SqlViewDesigner.ParseFailedTitle')}
{translate('::App.SqlViewDesigner.ParseFailedDescription')}
        {rawDefinition}
      
{onOpenInEditor && ( )}
) return (
{isEditMode ? `${translate('::App.SqlViewDesigner.EditView')} — ${initialViewData?.objectName}` : translate('::App.SqlViewDesigner.Title')}

{translate('::App.SqlViewDesigner.Description')}

{rawDefinition === null && renderToolbar()}
{definitionLoading ? (
◠ {translate('::App.Platform.Loading')}
) : rawDefinition !== null ? ( renderRawMode() ) : (
{issues.length > 0 && (
{issues.slice(0, 4).map((issue, i) => (
{issue.message}
))}
)} {showDiagram && (
{renderPalette()}
handleDragMove(null)} onDragEnd={handleDragEnd} >
)} {showCriteria && (
)} {showSql && (
{onOpenInEditor && ( )}
                  {generatedSql || translate('::App.SqlViewDesigner.NothingToGenerate')}
                
)}
)}
{rawDefinition === null && ( )}
) } function PaneToggle({ active, icon, title, onClick, }: { active: boolean icon: React.ReactNode title: string onClick: () => void }) { return ( ) } export default SqlViewDesignerDialog